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
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 |
|
|
true |
|
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.cssClassobject 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))
)
)
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.
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")
)
)
-
Compile check:
mill summercms.compileMust succeed with no errors.
-
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
-
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
-
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>