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
527 lines
20 KiB
Markdown
527 lines
20 KiB
Markdown
---
|
|
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"
|
|
---
|
|
|
|
<objective>
|
|
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.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@/home/jin/.claude/get-shit-done/workflows/execute-plan.md
|
|
@/home/jin/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<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
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto">
|
|
<name>Task 1: Implement basic input widgets (Text, Textarea, Dropdown, Checkbox)</name>
|
|
<files>
|
|
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
|
|
</files>
|
|
<action>
|
|
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))
|
|
)
|
|
)
|
|
```
|
|
</action>
|
|
<verify>
|
|
Run `mill summercms.compile` - should compile without errors.
|
|
Verify each widget produces expected HTML structure.
|
|
</verify>
|
|
<done>
|
|
Basic input widgets (Text, Textarea, Dropdown, Checkbox) implemented with proper attribute handling, value binding, and CSS classes.
|
|
</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: Implement DatePicker and Repeater widgets</name>
|
|
<files>
|
|
summercms/src/admin/forms/widgets/DatePickerWidget.scala
|
|
summercms/src/admin/forms/widgets/RepeaterWidget.scala
|
|
</files>
|
|
<action>
|
|
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
|
|
</action>
|
|
<verify>
|
|
Run `mill summercms.compile` - should compile without errors.
|
|
Verify DatePicker produces input with Flatpickr data attributes.
|
|
Verify Repeater produces item list structure with controls.
|
|
</verify>
|
|
<done>
|
|
DatePicker widget renders with Flatpickr integration attributes. Repeater widget renders item list with collapse, drag-reorder, add/remove controls and nested field support.
|
|
</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 3: Implement Relation widget and Widget bootstrap</name>
|
|
<files>
|
|
summercms/src/admin/forms/widgets/RelationWidget.scala
|
|
summercms/src/admin/forms/WidgetBootstrap.scala
|
|
</files>
|
|
<action>
|
|
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")
|
|
)
|
|
)
|
|
```
|
|
</action>
|
|
<verify>
|
|
Run `mill summercms.compile` - should compile without errors.
|
|
Verify RelationWidget produces modal trigger structure.
|
|
Verify WidgetBootstrap registers all widgets when called.
|
|
</verify>
|
|
<done>
|
|
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.
|
|
</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<verification>
|
|
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.
|
|
</verification>
|
|
|
|
<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>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/07-admin-forms-lists/07-02-SUMMARY.md`
|
|
</output>
|