---
phase: 07-admin-forms-lists
plan: 02
type: execute
wave: 2
depends_on: ["07-01"]
files_modified:
- summercms/src/admin/forms/widgets/TextWidget.scala
- summercms/src/admin/forms/widgets/TextareaWidget.scala
- summercms/src/admin/forms/widgets/DropdownWidget.scala
- summercms/src/admin/forms/widgets/CheckboxWidget.scala
- summercms/src/admin/forms/widgets/DatePickerWidget.scala
- summercms/src/admin/forms/widgets/RepeaterWidget.scala
- summercms/src/admin/forms/widgets/RelationWidget.scala
- summercms/src/admin/forms/WidgetBootstrap.scala
autonomous: true
must_haves:
truths:
- "Text input renders with proper attributes (placeholder, required, disabled)"
- "Textarea renders with rows/cols and comment help text"
- "Dropdown renders select element with options from config or method"
- "Checkbox renders with label alignment and checked state"
- "DatePicker includes Flatpickr initialization attributes"
- "Repeater renders item list with add/remove/reorder controls"
- "Relation widget renders modal trigger with list display"
artifacts:
- path: "summercms/src/admin/forms/widgets/TextWidget.scala"
provides: "Text input widget (text, email, password, number, url types)"
exports: ["TextWidget"]
- path: "summercms/src/admin/forms/widgets/TextareaWidget.scala"
provides: "Textarea widget with size variants"
exports: ["TextareaWidget"]
- path: "summercms/src/admin/forms/widgets/DropdownWidget.scala"
provides: "Select dropdown widget with options"
exports: ["DropdownWidget"]
- path: "summercms/src/admin/forms/widgets/CheckboxWidget.scala"
provides: "Checkbox and switch widgets"
exports: ["CheckboxWidget", "SwitchWidget"]
- path: "summercms/src/admin/forms/widgets/DatePickerWidget.scala"
provides: "Date/time picker with Flatpickr integration"
exports: ["DatePickerWidget"]
- path: "summercms/src/admin/forms/widgets/RepeaterWidget.scala"
provides: "Repeater widget with nested form fields"
exports: ["RepeaterWidget"]
- path: "summercms/src/admin/forms/widgets/RelationWidget.scala"
provides: "Relation widget with modal management"
exports: ["RelationWidget"]
- path: "summercms/src/admin/forms/WidgetBootstrap.scala"
provides: "Registers all standard widgets at startup"
exports: ["WidgetBootstrap"]
key_links:
- from: "summercms/src/admin/forms/widgets/*.scala"
to: "summercms/src/admin/forms/WidgetRegistry.scala"
via: "implements FormWidget trait"
pattern: "extends FormWidget"
- from: "summercms/src/admin/forms/WidgetBootstrap.scala"
to: "summercms/src/admin/forms/WidgetRegistry.scala"
via: "registers widgets on boot"
pattern: "registry\\.register"
---
Implement all standard form widgets for the admin backend.
Purpose: This plan creates the widget implementations that render actual form inputs. Each widget type handles its specific UI requirements, from simple text inputs to complex repeaters and relation modals.
Output: Complete set of form widgets (text, textarea, dropdown, checkbox, datepicker, repeater, relation) that integrate with the WidgetRegistry from Plan 07-01.
@/home/jin/.claude/get-shit-done/workflows/execute-plan.md
@/home/jin/.claude/get-shit-done/templates/summary.md
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/07-admin-forms-lists/07-CONTEXT.md
@.planning/phases/07-admin-forms-lists/07-RESEARCH.md
@.planning/phases/07-admin-forms-lists/07-01-SUMMARY.md
Task 1: Implement basic input widgets (Text, Textarea, Dropdown, Checkbox)
summercms/src/admin/forms/widgets/TextWidget.scala
summercms/src/admin/forms/widgets/TextareaWidget.scala
summercms/src/admin/forms/widgets/DropdownWidget.scala
summercms/src/admin/forms/widgets/CheckboxWidget.scala
Create TextWidget.scala:
- Implements FormWidget trait
- Handles multiple input types via field.type: text, email, password, number, url
- Renders input element with:
- type attribute based on field type
- name attribute for form submission
- value from context data
- placeholder from field.placeholder
- required attribute if field.required
- disabled attribute if field.disabled
- readonly attribute if field.readOnly
- Custom attributes from field.attributes map
- CSS classes: "form-control" base + field.cssClass
```scala
object TextWidget extends FormWidget:
def render(field: FormField, name: String, value: Any, ctx: FormContext): Frag =
val inputType = field.fieldType match
case "password" => "password"
case "email" => "email"
case "url" => "url"
case "number" => "number"
case _ => "text"
input(
cls := s"form-control ${field.cssClass.getOrElse("")}",
tpe := inputType,
attr("name") := name,
attr("value") := Option(value).map(_.toString).getOrElse(""),
// ... additional attributes
)
```
Create TextareaWidget.scala:
- Renders textarea element
- Supports size variant: tiny (2 rows), small (4), default (6), large (10), huge (15), giant (20)
- Size determined by field.size property
- Content goes inside textarea tags, not value attribute
Create DropdownWidget.scala:
- Renders select element with option children
- Options from field.options (Map[String, String] from YAML)
- Support emptyOption for "-- Select --" placeholder
- Mark selected option based on current value
- Handle multiple selection if field indicates (future enhancement)
```scala
object DropdownWidget extends FormWidget:
def render(field: FormField, name: String, value: Any, ctx: FormContext): Frag =
val currentValue = Option(value).map(_.toString).getOrElse("")
select(
cls := s"form-control ${field.cssClass.getOrElse("")}",
attr("name") := name,
field.required.filter(identity).map(_ => attr("required") := "required"),
field.emptyOption.map(empty => option(attr("value") := "", empty)),
field.options.map(_.toSeq.map { case (k, v) =>
option(
attr("value") := k,
if k == currentValue then Some(attr("selected") := "selected") else None,
v
)
}).getOrElse(Nil)
)
```
Create CheckboxWidget.scala:
- Renders checkbox input with label wrapper
- Also supports "switch" type for toggle-style checkbox
- Checkbox value handling: hidden input with "0" + checkbox with "1" (standard pattern)
- Checked state based on truthy value (true, 1, "1", "yes", "on")
```scala
object CheckboxWidget extends FormWidget:
def render(field: FormField, name: String, value: Any, ctx: FormContext): Frag =
val isChecked = value match
case b: Boolean => b
case n: Number => n.intValue() != 0
case s: String => Set("true", "1", "yes", "on").contains(s.toLowerCase)
case _ => false
div(cls := "checkbox-wrapper",
input(tpe := "hidden", attr("name") := name, attr("value") := "0"),
label(cls := "checkbox-label",
input(
tpe := "checkbox",
attr("name") := name,
attr("value") := "1",
if isChecked then Some(attr("checked") := "checked") else None
),
span(field.label.getOrElse(name))
)
)
```
Run `mill summercms.compile` - should compile without errors.
Verify each widget produces expected HTML structure.
Basic input widgets (Text, Textarea, Dropdown, Checkbox) implemented with proper attribute handling, value binding, and CSS classes.
Task 2: Implement DatePicker and Repeater widgets
summercms/src/admin/forms/widgets/DatePickerWidget.scala
summercms/src/admin/forms/widgets/RepeaterWidget.scala
Create DatePickerWidget.scala:
- Renders text input with Flatpickr data attributes for JS initialization
- Support modes: date, datetime, time (via field.mode property)
- Data attributes for Flatpickr configuration:
- data-flatpickr: marker for JS initialization
- data-enable-time: for datetime/time modes
- data-no-calendar: for time-only mode
- data-date-format: based on mode (Y-m-d, Y-m-d H:i, H:i)
- data-min-date, data-max-date: if specified in field config
```scala
object DatePickerWidget extends FormWidget:
def render(field: FormField, name: String, value: Any, ctx: FormContext): Frag =
val mode = field.mode.getOrElse("date")
val (enableTime, noCalendar, format) = mode match
case "datetime" => (true, false, "Y-m-d H:i")
case "time" => (true, true, "H:i")
case _ => (false, false, "Y-m-d")
input(
cls := s"form-control flatpickr-input ${field.cssClass.getOrElse("")}",
tpe := "text",
attr("name") := name,
attr("value") := Option(value).map(_.toString).getOrElse(""),
data("flatpickr") := "true",
if enableTime then Some(data("enable-time") := "true") else None,
if noCalendar then Some(data("no-calendar") := "true") else None,
data("date-format") := format
)
```
Create RepeaterWidget.scala (complex widget per CONTEXT.md):
- Renders list of repeater items with nested form fields
- Each item has:
- Collapse/expand toggle (items collapsible per CONTEXT.md)
- Title from field.titleFrom (shows value of specified field as item title)
- Drag handle for reordering (Sortable.js data attributes)
- Remove button
- Nested form fields from field.form.fields
- Below items:
- Add button with prompt text from field.prompt
- Item wrapper with unique UUID-based keys (not sequential indexes per RESEARCH.md)
- Respects minItems/maxItems constraints (disable add/remove accordingly)
- Data attributes for Sortable.js: data-sortable, data-handle
```scala
object RepeaterWidget extends FormWidget:
def render(field: FormField, name: String, value: Any, ctx: FormContext): Frag =
val items = value match
case list: List[_] => list.asInstanceOf[List[Map[String, Any]]]
case _ => List.empty[Map[String, Any]]
val nestedFields = field.form.map(_.fields).getOrElse(Map.empty)
val minItems = field.minItems.getOrElse(0)
val maxItems = field.maxItems.getOrElse(Int.MaxValue)
div(cls := "repeater-widget",
data("repeater") := "true",
data("min-items") := minItems.toString,
data("max-items") := maxItems.toString,
div(cls := "repeater-items", data("sortable") := "true",
items.zipWithIndex.map { case (itemData, idx) =>
val itemKey = itemData.getOrElse("_key", java.util.UUID.randomUUID().toString)
renderRepeaterItem(field, name, itemKey.toString, itemData, nestedFields, idx, ctx)
}
),
div(cls := "repeater-add",
button(
tpe := "button",
cls := "btn btn-secondary btn-add-item",
attr("hx-get") := s"/admin/repeater/add-item?field=$name",
attr("hx-target") := ".repeater-items",
attr("hx-swap") := "beforeend",
if items.size >= maxItems then Some(attr("disabled") := "disabled") else None,
field.prompt.getOrElse("Add Item")
)
)
)
private def renderRepeaterItem(
field: FormField,
baseName: String,
itemKey: String,
itemData: Map[String, Any],
nestedFields: Map[String, FormFieldConfig],
index: Int,
ctx: FormContext
): Frag =
div(cls := "repeater-item", data("item-key") := itemKey,
div(cls := "repeater-item-header",
span(cls := "drag-handle", data("handle") := "true", "::"),
span(cls := "item-title",
field.titleFrom.flatMap(tf => itemData.get(tf).map(_.toString)).getOrElse(s"Item ${index + 1}")
),
button(tpe := "button", cls := "btn-collapse", data("toggle") := "collapse", "-"),
button(tpe := "button", cls := "btn-remove", data("action") := "remove", "x")
),
div(cls := "repeater-item-body",
nestedFields.map { case (fieldName, fieldConfig) =>
val nestedName = s"${baseName}[$itemKey][$fieldName]"
val nestedValue = itemData.getOrElse(fieldName, null)
// Render nested field (simplified - would use FormRenderer in real impl)
renderNestedField(fieldConfig, nestedName, nestedValue, ctx)
}
),
input(tpe := "hidden", attr("name") := s"${baseName}[$itemKey][_key]", attr("value") := itemKey)
)
```
Note: Repeater is complex - the implementation should include:
- HTMX endpoint for adding new items dynamically
- JavaScript for Sortable.js initialization
- Proper name indexing for form submission
Run `mill summercms.compile` - should compile without errors.
Verify DatePicker produces input with Flatpickr data attributes.
Verify Repeater produces item list structure with controls.
DatePicker widget renders with Flatpickr integration attributes. Repeater widget renders item list with collapse, drag-reorder, add/remove controls and nested field support.
Task 3: Implement Relation widget and Widget bootstrap
summercms/src/admin/forms/widgets/RelationWidget.scala
summercms/src/admin/forms/WidgetBootstrap.scala
Create RelationWidget.scala (RelationController-style per CONTEXT.md):
- Modal-based relation management (not simple dropdown)
- Renders:
- Display area showing currently selected related records
- Buttons: "Link", "Create" (if allowed), "Remove" for selected
- Hidden inputs with selected relation IDs
- Modal loaded via HTMX on button click
- Supports relation types: belongsTo, hasOne, hasMany, belongsToMany
```scala
object RelationWidget extends FormWidget:
def render(field: FormField, name: String, value: Any, ctx: FormContext): Frag =
val selectedIds = value match
case list: List[_] => list.map(_.toString)
case single if single != null => List(single.toString)
case _ => List.empty
val relationName = field.nameFrom.getOrElse("name")
val sessionKey = ctx.sessionKey
div(cls := "relation-widget",
data("relation") := "true",
data("session-key") := sessionKey,
// Display selected relations
div(cls := "relation-items",
selectedIds.map(id => renderRelationItem(name, id, relationName))
),
// Hidden inputs for form submission
selectedIds.map(id =>
input(tpe := "hidden", attr("name") := s"${name}[]", attr("value") := id)
),
// Action buttons
div(cls := "relation-actions",
button(
tpe := "button",
cls := "btn btn-secondary btn-link-relation",
attr("hx-get") := s"/admin/relation/modal?field=$name&session=$sessionKey",
attr("hx-target") := "#relation-modal .modal-content",
attr("hx-trigger") := "click",
"Link Existing"
),
button(
tpe := "button",
cls := "btn btn-secondary btn-create-relation",
attr("hx-get") := s"/admin/relation/create-modal?field=$name&session=$sessionKey",
attr("hx-target") := "#relation-modal .modal-content",
attr("hx-trigger") := "click",
"Create New"
)
),
// Modal placeholder
div(id := "relation-modal", cls := "modal",
div(cls := "modal-content")
)
)
private def renderRelationItem(name: String, id: String, displayAttr: String): Frag =
div(cls := "relation-item", data("id") := id,
span(cls := "item-display", s"Loading..."), // Would be populated via HTMX or initial load
button(
tpe := "button",
cls := "btn-remove-relation",
data("action") := "remove",
data("id") := id,
"x"
)
)
```
Create WidgetBootstrap.scala:
- Registers all standard widgets with the WidgetRegistry on application boot
- Can be called from app initialization or plugin boot lifecycle
```scala
object WidgetBootstrap:
def register: ZIO[WidgetRegistry, Nothing, Unit] =
for
registry <- ZIO.service[WidgetRegistry]
_ <- registry.register("text", TextWidget)
_ <- registry.register("password", TextWidget) // Same widget, different type attr
_ <- registry.register("email", TextWidget)
_ <- registry.register("url", TextWidget)
_ <- registry.register("number", TextWidget)
_ <- registry.register("textarea", TextareaWidget)
_ <- registry.register("dropdown", DropdownWidget)
_ <- registry.register("select", DropdownWidget) // Alias
_ <- registry.register("checkbox", CheckboxWidget)
_ <- registry.register("switch", SwitchWidget)
_ <- registry.register("datepicker", DatePickerWidget)
_ <- registry.register("date", DatePickerWidget) // Alias
_ <- registry.register("datetime", DatePickerWidget)
_ <- registry.register("time", DatePickerWidget)
_ <- registry.register("repeater", RepeaterWidget)
_ <- registry.register("relation", RelationWidget)
yield ()
val layer: ZLayer[WidgetRegistry, Nothing, Unit] =
ZLayer.fromZIO(register)
```
Also create SwitchWidget in CheckboxWidget.scala file (variant of checkbox with different styling):
```scala
object SwitchWidget extends FormWidget:
// Similar to CheckboxWidget but with switch CSS classes
def render(field: FormField, name: String, value: Any, ctx: FormContext): Frag =
val isChecked = CheckboxWidget.isValueChecked(value)
div(cls := "switch-wrapper",
label(cls := "switch",
input(tpe := "hidden", attr("name") := name, attr("value") := "0"),
input(
tpe := "checkbox",
attr("name") := name,
attr("value") := "1",
if isChecked then Some(attr("checked") := "checked") else None
),
span(cls := "slider")
)
)
```
Run `mill summercms.compile` - should compile without errors.
Verify RelationWidget produces modal trigger structure.
Verify WidgetBootstrap registers all widgets when called.
RelationWidget implements modal-based relation management with link/create actions. WidgetBootstrap registers all standard widgets (text, textarea, dropdown, checkbox, switch, datepicker, repeater, relation) with the registry.
After completing all tasks:
1. **Compile check:**
```bash
mill summercms.compile
```
Must succeed with no errors.
2. **Widget output verification:**
For each widget, verify rendered HTML contains:
- TextWidget: input[type="text"] with form-control class
- TextareaWidget: textarea element with rows attribute
- DropdownWidget: select element with option children
- CheckboxWidget: hidden input + checkbox input with label
- DatePickerWidget: input with data-flatpickr attribute
- RepeaterWidget: .repeater-items container with add button
- RelationWidget: .relation-items with modal trigger buttons
3. **Widget registration verification:**
After WidgetBootstrap.register runs, verify:
- registry.resolve("text") returns Some(TextWidget)
- registry.resolve("textarea") returns Some(TextareaWidget)
- registry.resolve("dropdown") returns Some(DropdownWidget)
- All registered types are resolvable
4. **Integration verification:**
Use FormRenderer from 07-01 with sample fields.yaml containing various field types.
Verify form renders with appropriate widgets for each field type.
- [ ] TextWidget handles text, email, password, number, url input types
- [ ] TextareaWidget supports size variants (tiny, small, default, large, huge, giant)
- [ ] DropdownWidget renders select with options from config, handles emptyOption
- [ ] CheckboxWidget renders checkbox with hidden field pattern for proper form submission
- [ ] SwitchWidget provides toggle-style checkbox variant
- [ ] DatePickerWidget includes Flatpickr data attributes for JS initialization
- [ ] RepeaterWidget renders item list with collapse, drag handle, add/remove
- [ ] Repeater uses UUID keys (not sequential indexes) for items
- [ ] RelationWidget renders modal triggers for link/create actions
- [ ] WidgetBootstrap registers all widgets with proper type mappings
- [ ] All widgets implement FormWidget trait consistently
- [ ] Project compiles successfully with `mill summercms.compile`