--- 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` After completion, create `.planning/phases/07-admin-forms-lists/07-02-SUMMARY.md`