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
This commit is contained in:
Jakub Zych
2026-02-05 14:55:56 +01:00
parent 8d00fe904b
commit 34806c1845
4 changed files with 1614 additions and 5 deletions

View File

@@ -0,0 +1,526 @@
---
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>