Files
Jakub Zych 34806c1845 docs(07): create phase plan
Phase 07: Admin Forms & Lists
- 3 plan(s) in 2 wave(s)
- Wave 1: 07-01 (YAML parsing, WidgetRegistry, FormRenderer)
- Wave 2: 07-02 (form widgets), 07-03 (list rendering) - parallel
- Ready for execution
2026-02-05 14:55:56 +01:00

20 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, must_haves
phase plan type wave depends_on files_modified autonomous must_haves
07-admin-forms-lists 02 execute 2
07-01
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
true
truths artifacts key_links
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
path provides exports
summercms/src/admin/forms/widgets/TextWidget.scala Text input widget (text, email, password, number, url types)
TextWidget
path provides exports
summercms/src/admin/forms/widgets/TextareaWidget.scala Textarea widget with size variants
TextareaWidget
path provides exports
summercms/src/admin/forms/widgets/DropdownWidget.scala Select dropdown widget with options
DropdownWidget
path provides exports
summercms/src/admin/forms/widgets/CheckboxWidget.scala Checkbox and switch widgets
CheckboxWidget
SwitchWidget
path provides exports
summercms/src/admin/forms/widgets/DatePickerWidget.scala Date/time picker with Flatpickr integration
DatePickerWidget
path provides exports
summercms/src/admin/forms/widgets/RepeaterWidget.scala Repeater widget with nested form fields
RepeaterWidget
path provides exports
summercms/src/admin/forms/widgets/RelationWidget.scala Relation widget with modal management
RelationWidget
path provides exports
summercms/src/admin/forms/WidgetBootstrap.scala Registers all standard widgets at startup
WidgetBootstrap
from to via pattern
summercms/src/admin/forms/widgets/*.scala summercms/src/admin/forms/WidgetRegistry.scala implements FormWidget trait extends FormWidget
from to via pattern
summercms/src/admin/forms/WidgetBootstrap.scala summercms/src/admin/forms/WidgetRegistry.scala registers widgets on boot 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.

<execution_context> @/home/jin/.claude/get-shit-done/workflows/execute-plan.md @/home/jin/.claude/get-shit-done/templates/summary.md </execution_context>

@.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
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)
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")
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
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
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
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
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):

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:

    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.

<success_criteria>

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