Widgets & Forms¶
Customize how form fields are rendered and validated.
How Forms Work¶
Forms are generated in two layers:
SQLAlchemy Model
│
▼
┌─────────────────────────────────────────┐
│ LAYER 1 — Python Widget Class │
│ Parse, validate, produce context │
└─────────────────────┬───────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ LAYER 2 — Jinja2 Macro (HTML) │
│ Renders the HTML using the context │
└─────────────────────────────────────────┘
You can override either layer independently:
| Override | Changes |
|---|---|
| Widget class only | Parse logic + validation |
| Macro only | HTML output |
| Both | Complete custom field |
| Neither | Zero config auto-detection |
Built-in Widgets¶
TextInputWidget¶
For String and VARCHAR columns:
from fastapi_admin_kit.widgets import TextInputWidget
class ProductAdmin(ModelAdmin):
form_widgets = {
"name": TextInputWidget(maxlength=100),
}
TextareaWidget¶
For Text columns:
from fastapi_admin_kit.widgets import TextareaWidget
class ProductAdmin(ModelAdmin):
form_widgets = {
"description": TextareaWidget(rows=10),
}
NumberInputWidget¶
For Integer, Float, Numeric columns:
from fastapi_admin_kit.widgets import NumberInputWidget
class ProductAdmin(ModelAdmin):
form_widgets = {
"price": NumberInputWidget(step=0.01, min=0),
"stock": NumberInputWidget(step=1, min=0),
}
ToggleWidget¶
For Boolean columns:
from fastapi_admin_kit.widgets import ToggleWidget
class ProductAdmin(ModelAdmin):
form_widgets = {
"is_active": ToggleWidget(),
}
SelectWidget¶
For Enum columns:
from fastapi_admin_kit.widgets import SelectWidget
class ProductAdmin(ModelAdmin):
form_widgets = {
"status": SelectWidget(choices=[
("draft", "Draft"),
("published", "Published"),
("archived", "Archived"),
]),
}
DateTimeWidget¶
For DateTime and Date columns:
from fastapi_admin_kit.widgets import DateTimeWidget
class ProductAdmin(ModelAdmin):
form_widgets = {
"published_at": DateTimeWidget(),
}
JSONEditorWidget¶
For JSON columns:
from fastapi_admin_kit.widgets import JSONEditorWidget
class ProductAdmin(ModelAdmin):
form_widgets = {
"metadata": JSONEditorWidget(),
}
RelationPickerWidget¶
For ForeignKey columns:
from fastapi_admin_kit.widgets import RelationPickerWidget
class ProductAdmin(ModelAdmin):
form_widgets = {
"category": RelationPickerWidget(
search_fields=["name"], # Fields to search
display_field="name", # Field to display
),
}
MultiRelationPickerWidget¶
For relationship() with uselist=True:
from fastapi_admin_kit.widgets import MultiRelationPickerWidget
class ProductAdmin(ModelAdmin):
form_widgets = {
"tags": MultiRelationPickerWidget(
search_fields=["name"],
display_field="name",
),
}
AutocompleteWidget¶
Datalist autocomplete for text fields with static or dynamic suggestions:
from fastapi_admin_kit.widgets import AutocompleteWidget
class ProductAdmin(ModelAdmin):
form_widgets = {
"brand": AutocompleteWidget(
suggestions=["Nike", "Adidas", "Puma", "Reebok"],
),
}
Dynamic suggestions via a callable:
def get_category_suggestions():
return ["Electronics", "Clothing", "Home", "Sports"]
class ProductAdmin(ModelAdmin):
form_widgets = {
"category_name": AutocompleteWidget(
suggestions_fn=get_category_suggestions,
),
}
PasswordWidget¶
For password fields — never pre-fills values:
from fastapi_admin_kit.widgets import PasswordWidget
class UserAdmin(ModelAdmin):
form_widgets = {
"password": PasswordWidget(),
}
ReadOnlyWidget¶
Displays a value without allowing edits:
from fastapi_admin_kit.widgets import ReadOnlyWidget
class ProductAdmin(ModelAdmin):
form_widgets = {
"sku": ReadOnlyWidget(),
}
HiddenWidget¶
Hidden input field:
from fastapi_admin_kit.widgets import HiddenWidget
class ProductAdmin(ModelAdmin):
form_widgets = {
"internal_code": HiddenWidget(),
}
DatePickerWidget¶
For Date columns (separate from DateTimePicker):
from fastapi_admin_kit.widgets import DatePickerWidget
class ProductAdmin(ModelAdmin):
form_widgets = {
"release_date": DatePickerWidget(),
}
DateTimePickerWidget¶
For DateTime columns:
from fastapi_admin_kit.widgets import DateTimePickerWidget
class ProductAdmin(ModelAdmin):
form_widgets = {
"published_at": DateTimePickerWidget(),
}
FileUploadWidget¶
For file uploads with size limits and type filtering:
from fastapi_admin_kit.widgets import FileUploadWidget
class ProductAdmin(ModelAdmin):
form_widgets = {
"document": FileUploadWidget(
max_size_mb=10,
accept=".pdf,.doc,.docx",
),
}
ImageUploadWidget¶
Specialized file upload restricted to images:
from fastapi_admin_kit.widgets import ImageUploadWidget
class ProductAdmin(ModelAdmin):
form_widgets = {
"avatar": ImageUploadWidget(
max_size_mb=5,
accept="image/*",
),
}
WysiwygWidget¶
Rich text editor for HTML content:
from fastapi_admin_kit.widgets import WysiwygWidget
class ArticleAdmin(ModelAdmin):
form_widgets = {
"content": WysiwygWidget(height=300),
}
ArrayWidget¶
Dynamic list input for JSON array columns:
from fastapi_admin_kit.widgets import ArrayWidget
class ProductAdmin(ModelAdmin):
form_widgets = {
"tags": ArrayWidget(min_items=1, max_items=10),
}
Custom Widget¶
Create your own widget by extending the base class:
from fastapi_admin_kit.widgets.base import Widget, FieldMeta
from typing import Any
class ColorPickerWidget(Widget):
macro_name = "color_picker"
def render_context(self, field: FieldMeta, value: Any) -> dict:
ctx = super().render_context(field, value)
ctx["presets"] = ["#ef4444", "#f59e0b", "#10b981", "#3b82f6", "#8b5cf6"]
return ctx
def parse(self, raw: str | None) -> str | None:
if raw and not raw.startswith("#"):
return f"#{raw}"
return raw
def validate(self, value: Any, field: FieldMeta) -> list[str]:
errors = super().validate(value, field)
if value and not value.startswith("#"):
errors.append(f"{field.label} must be a valid hex color.")
return errors
Register the Widget¶
from fastapi_admin_kit.widgets import WidgetRegistry
# Register globally
WidgetRegistry.register("color", ColorPickerWidget)
# Or use per-model
@admin.register(Product)
class ProductAdmin(ModelAdmin):
form_widgets = {
"color": ColorPickerWidget(),
}
Create the Jinja2 Macro¶
{# templates/macros/form_fields.html #}
{% macro color_picker(field, value, id, name, presets) %}
<div class="color-picker-wrapper">
<input
type="color"
id="{{ id }}"
name="{{ name }}"
value="{{ value or '#000000' }}"
class="form-color"
>
<div class="color-presets">
{% for preset in presets %}
<button
type="button"
class="color-preset"
style="background-color: {{ preset }}"
@click="$refs.colorInput.value = '{{ preset }}'"
></button>
{% endfor %}
</div>
</div>
{% endmacro %}
Widget Registry¶
The widget registry maps column types to widgets:
from fastapi_admin_kit.widgets import WidgetRegistry
# Default mappings
WidgetRegistry.COLUMN_TYPE_MAP = {
"String": "text_input",
"Text": "textarea",
"Integer": "number_input",
"Float": "number_input",
"Boolean": "toggle",
"Date": "date_picker",
"DateTime": "datetime_picker",
"Enum": "select",
"JSON": "json_editor",
}
Override by Type¶
# Make all String fields use textarea
WidgetRegistry.register_type("String", TextareaWidget(rows=3))
Override by Field Name¶
# Make any field named "description" use textarea
WidgetRegistry.register_field_pattern("description", TextareaWidget(rows=10))
Form Validation¶
Field-Level Validation¶
from fastapi_admin_kit.widgets.base import Widget, FieldMeta
from typing import Any
class PriceWidget(Widget):
macro_name = "number_input"
def validate(self, value: Any, field: FieldMeta) -> list[str]:
errors = super().validate(value, field)
if value is not None:
try:
price = float(value)
if price < 0:
errors.append("Price cannot be negative.")
if price > 999999.99:
errors.append("Price is too large.")
except ValueError:
errors.append("Price must be a number.")
return errors
Object-Level Validation¶
@admin.register(Product)
class ProductAdmin(ModelAdmin):
def validate_object(self, obj, data, request) -> list[str]:
"""Validate the entire object after all fields are parsed"""
errors = []
if data.get("sale_price") and data["sale_price"] >= data.get("price", 0):
errors.append("Sale price must be less than regular price.")
if data.get("stock") == 0 and data.get("is_active"):
errors.append("Out of stock items cannot be active.")
return errors
Global Validation Hook¶
admin = Admin(
app=app,
engine=engine,
secret_key="...",
global_validator=my_global_validator,
)
def my_global_validator(obj, data, request) -> list[str]:
"""Validate across all models"""
return []
HTMX Partial Validation¶
Fields validate on blur via HTMX:
<input
type="text"
name="name"
hx-post="/admin/products/validate/field/"
hx-trigger="blur"
hx-vals='{"field": "name"}'
hx-target="#field-name-errors"
>
<div id="field-name-errors"></div>
The server returns error HTML that replaces the error container.
Relationship Widgets¶
ForeignKey (Searchable Dropdown)¶
class Product(Base):
category_id = Column(Integer, ForeignKey("categories.id"))
category = relationship("Category")
Automatically rendered as:
<select id="field-category" name="category_id" hx-get="/admin/categories/search/" hx-trigger="keyup delay:300ms">
<option value="">Select category...</option>
<!-- Options loaded via HTMX -->
</select>
Many-to-Many (Multi-Select)¶
Rendered as a multi-select with search and removable tags.
File Upload¶
File uploads are handled by the storage backend configured on the Admin instance:
admin = Admin(
app=app,
engine=engine,
secret_key="...",
storage=LocalStorageBackend(path="/uploads"),
uploads_url="/uploads",
)
Use FileUploadWidget or ImageUploadWidget on the model field to enable upload UI:
@admin.register(Product)
class ProductAdmin(ModelAdmin):
form_widgets = {
"image_url": ImageUploadWidget(max_size_mb=5),
"document": FileUploadWidget(max_size_mb=10, accept=".pdf,.doc"),
}
Form Context¶
The form context dictionary passed to templates:
{
"model_name": "products",
"verbose_name": "Product",
"obj": product_instance, # None for create
"form_fields": [
{
"name": "name",
"label": "Name",
"required": True,
"readonly": False,
"widget": TextInputWidget,
"value": "Product Name",
"errors": [],
},
# ... more fields
],
"permissions": {
"can_view": True,
"can_create": True,
"can_edit": True,
"can_delete": True,
},
"submit_url": "/admin/products/create/",
"back_url": "/admin/products/",
}
Next Steps¶
- Audit Logging — Track all changes
- Plugins — Extend with custom plugins
- API Reference — Widget API documentation