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

@@ -132,12 +132,12 @@ Plans:
3. Developer defines columns.yaml and admin renders corresponding list/table 3. Developer defines columns.yaml and admin renders corresponding list/table
4. List supports column types (text, date, relation) and filters 4. List supports column types (text, date, relation) and filters
5. Forms validate input and display errors appropriately 5. Forms validate input and display errors appropriately
**Plans**: TBD **Plans**: 3 plans
Plans: Plans:
- [ ] 07-01: YAML parsing and form rendering - [ ] 07-01-PLAN.md - YAML parsing infrastructure and basic form rendering (circe-yaml, WidgetRegistry, FormRenderer)
- [ ] 07-02: Form widgets implementation - [ ] 07-02-PLAN.md - Form widget implementations (text, textarea, dropdown, checkbox, datepicker, repeater, relation)
- [ ] 07-03: List rendering and filters - [ ] 07-03-PLAN.md - List rendering with columns, filters, and pagination (ColumnRegistry, ListRenderer)
### Phase 8: Admin Dashboard ### Phase 8: Admin Dashboard
**Goal**: Provide customizable dashboard and plugin settings interface **Goal**: Provide customizable dashboard and plugin settings interface
@@ -212,7 +212,7 @@ Phases execute in numeric order: 1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7 -> 8 -> 9 -> 10
| 4. Theme Engine | 0/2 | Planned | - | | 4. Theme Engine | 0/2 | Planned | - |
| 5. CLI Scaffolding | 0/2 | Planned | - | | 5. CLI Scaffolding | 0/2 | Planned | - |
| 6. Backend Authentication | 0/3 | Planned | - | | 6. Backend Authentication | 0/3 | Planned | - |
| 7. Admin Forms & Lists | 0/3 | Not started | - | | 7. Admin Forms & Lists | 0/3 | Planned | - |
| 8. Admin Dashboard | 0/2 | Not started | - | | 8. Admin Dashboard | 0/2 | Not started | - |
| 9. Content Management | 0/5 | Not started | - | | 9. Content Management | 0/5 | Not started | - |
| 10. Core Plugins | 0/4 | Not started | - | | 10. Core Plugins | 0/4 | Not started | - |

View File

@@ -0,0 +1,301 @@
---
phase: 07-admin-forms-lists
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- summercms/build.mill
- summercms/src/admin/yaml/FieldsYamlSchema.scala
- summercms/src/admin/yaml/ColumnsYamlSchema.scala
- summercms/src/admin/yaml/YamlParser.scala
- summercms/src/admin/forms/FormField.scala
- summercms/src/admin/forms/WidgetRegistry.scala
- summercms/src/admin/forms/FormRenderer.scala
- summercms/src/admin/forms/FormContext.scala
autonomous: true
must_haves:
truths:
- "YAML fields.yaml content parses into typed FormFieldConfig ADT"
- "YAML columns.yaml content parses into typed ListColumnConfig ADT"
- "Widget registry resolves field type string to widget implementation"
- "Form container renders with proper structure (tabs, sections, field groups)"
artifacts:
- path: "summercms/src/admin/yaml/FieldsYamlSchema.scala"
provides: "FormFieldConfig, TriggerConfig, TabsConfig ADTs with circe decoders"
exports: ["FormFieldConfig", "FieldsYamlConfig"]
- path: "summercms/src/admin/yaml/ColumnsYamlSchema.scala"
provides: "ListColumnConfig ADT with circe decoders"
exports: ["ListColumnConfig", "ColumnsYamlConfig"]
- path: "summercms/src/admin/yaml/YamlParser.scala"
provides: "ZIO-wrapped YAML parsing service"
exports: ["YamlParser"]
- path: "summercms/src/admin/forms/WidgetRegistry.scala"
provides: "Widget type to renderer mapping"
exports: ["WidgetRegistry", "FormWidget"]
- path: "summercms/src/admin/forms/FormRenderer.scala"
provides: "Form container HTML generation"
exports: ["FormRenderer"]
key_links:
- from: "summercms/src/admin/yaml/YamlParser.scala"
to: "summercms/src/admin/yaml/FieldsYamlSchema.scala"
via: "circe-yaml parsing"
pattern: "parser\\.parse.*as\\[FieldsYamlConfig\\]"
- from: "summercms/src/admin/forms/FormRenderer.scala"
to: "summercms/src/admin/forms/WidgetRegistry.scala"
via: "widget resolution for field rendering"
pattern: "registry\\.resolve"
---
<objective>
Establish YAML parsing infrastructure and form rendering foundation for admin backend.
Purpose: This plan creates the core building blocks that all form and list functionality depends on. The YAML ADTs define the configuration language, the parser loads definitions, and the widget registry enables extensible field rendering.
Output: Typed YAML parsing for fields.yaml/columns.yaml, widget registry pattern, and basic form container rendering with ScalaTags.
</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
</context>
<tasks>
<task type="auto">
<name>Task 1: Add dependencies and create YAML schema ADTs</name>
<files>
summercms/build.mill
summercms/src/admin/yaml/FieldsYamlSchema.scala
summercms/src/admin/yaml/ColumnsYamlSchema.scala
</files>
<action>
Add circe-yaml and scalatags dependencies to build.mill:
```scala
ivy"io.circe::circe-yaml:1.0.0",
ivy"io.circe::circe-generic:0.14.9",
ivy"com.lihaoyi::scalatags:0.13.1"
```
Create FieldsYamlSchema.scala with case classes matching WinterCMS fields.yaml structure:
- TriggerConfig(action: String, field: String, condition: String)
- PresetConfig(field: String, type: String)
- FormFieldConfig with all WinterCMS-compatible properties:
- type, label, span, size, tab, placeholder, comment, commentAbove
- default (as Json for flexibility), required, disabled, hidden, readOnly
- context (String or List[String] via Json), trigger, preset, dependsOn
- options (Map or method name via Json), cssClass, attributes
- Repeater-specific: form, prompt, titleFrom, minItems, maxItems, sortable
- Relation-specific: select, nameFrom, emptyOption
- FileUpload-specific: imageWidth, imageHeight
- TabsConfig for tab groupings
- FieldsYamlConfig as root (fields, tabs, secondaryTabs)
Use circe semi-auto derivation (deriveDecoder) for all types. Handle Option fields properly with default None values.
Create ColumnsYamlSchema.scala with:
- ListColumnConfig: label, type, searchable, sortable, invisible, width, relation, select, format, valueFrom, path
- FilterConfig: scope, label, conditions
- ColumnsYamlConfig as root (columns, filters, defaultSort)
Include implicit decoders in companion objects.
</action>
<verify>
Run `mill summercms.compile` - should compile without errors.
Check that all case classes have proper circe decoders by reviewing generated code structure.
</verify>
<done>
Build compiles with new dependencies. YAML schema ADTs defined with all WinterCMS-compatible fields and circe decoders.
</done>
</task>
<task type="auto">
<name>Task 2: Create YamlParser service and Widget registry</name>
<files>
summercms/src/admin/yaml/YamlParser.scala
summercms/src/admin/forms/WidgetRegistry.scala
summercms/src/admin/forms/FormField.scala
summercms/src/admin/forms/FormContext.scala
</files>
<action>
Create YamlParser.scala as a ZIO service:
```scala
trait YamlParser:
def parseFields(yaml: String): IO[YamlParseError, FieldsYamlConfig]
def parseColumns(yaml: String): IO[YamlParseError, ColumnsYamlConfig]
def parseFieldsFromFile(path: Path): IO[YamlParseError, FieldsYamlConfig]
def parseColumnsFromFile(path: Path): IO[YamlParseError, ColumnsYamlConfig]
```
- Use io.circe.yaml.parser for parsing
- Wrap errors in YamlParseError sealed trait (ParseError, FileNotFound, DecodeError)
- Live implementation using ZIO.attemptBlocking for file reads
Create FormField.scala with processed field representation:
- FormField case class with resolved values (not raw config)
- Include computed properties: effectiveLabel (from label or field name), effectiveSpan, isRequired, etc.
- Method to convert FormFieldConfig to FormField with defaults applied
Create FormContext.scala:
- FormContext case class holding: model data (Map[String, Any]), session key, form mode (create/update), validation errors
- Methods: getValue(fieldName), getError(fieldName), isCreate, isUpdate
Create WidgetRegistry.scala:
- FormWidget trait with render method signature:
```scala
def render(field: FormField, name: String, value: Any, ctx: FormContext): scalatags.Text.Frag
```
- WidgetRegistry as ZIO service with Ref-based mutable registry:
```scala
def register(typeName: String, widget: FormWidget): UIO[Unit]
def resolve(typeName: String): UIO[Option[FormWidget]]
def resolveOrDefault(typeName: String): UIO[FormWidget] // Falls back to TextWidget
```
- Include DefaultTextWidget as fallback implementation
</action>
<verify>
Run `mill summercms.compile` - should compile without errors.
Write a simple test parsing a fields.yaml string to verify parsing works.
</verify>
<done>
YamlParser service parses YAML strings/files into typed configs. WidgetRegistry provides type-to-widget mapping with registration and resolution. FormContext provides form state access.
</done>
</task>
<task type="auto">
<name>Task 3: Create FormRenderer with tab and section support</name>
<files>
summercms/src/admin/forms/FormRenderer.scala
</files>
<action>
Create FormRenderer.scala as a ZIO service that generates form HTML:
```scala
trait FormRenderer:
def render(config: FieldsYamlConfig, data: Map[String, Any], errors: Map[String, String], mode: FormMode): Task[scalatags.Text.Frag]
```
Implementation details:
1. Parse FieldsYamlConfig and group fields by tab (using field.tab property)
2. If multiple tabs exist, render horizontal tab navigation (per CONTEXT.md)
3. Within each tab, group fields by section if cssClass indicates sections
4. For each field:
- Resolve widget from WidgetRegistry
- Apply span classes (span-left, span-right, span-full based on field.span)
- Add trigger data attributes if field.trigger defined
- Render label with required asterisk if needed
- Render comment/commentAbove help text
- Call widget.render for the actual input
- Show inline error if present in errors map
Use ScalaTags for all HTML generation:
```scala
import scalatags.Text.all._
```
Form structure following CONTEXT.md decisions:
- Labels above inputs (stacked layout)
- Sections collapsible, expanded by default
- Required fields: red asterisk after label
- Help text visible below fields
- Modern minimal aesthetic via CSS classes (actual CSS in theme, not inline)
Include HTMX attributes for form submission:
```scala
form(
attr("hx-post") := submitUrl,
attr("hx-target") := "#form-container",
attr("hx-swap") := "innerHTML",
// ... fields
div(cls := "form-buttons",
button(tpe := "submit", cls := "btn btn-primary", "Save"),
button(tpe := "submit", cls := "btn btn-primary",
attr("hx-post") := s"$submitUrl?close=true",
"Save and Close")
)
)
```
Trigger support via data attributes for client-side JavaScript:
```scala
field.trigger.map(t => Seq(
data("trigger-field") := t.field,
data("trigger-action") := t.action,
data("trigger-condition") := t.condition
))
```
</action>
<verify>
Run `mill summercms.compile` - should compile without errors.
Manually verify FormRenderer produces valid HTML structure by calling render with sample config.
</verify>
<done>
FormRenderer generates complete form HTML from FieldsYamlConfig. Supports tabs, sections, field grouping, error display, and HTMX submission. Integrates with WidgetRegistry for field rendering.
</done>
</task>
</tasks>
<verification>
After completing all tasks:
1. **Compile check:**
```bash
mill summercms.compile
```
Must succeed with no errors.
2. **YAML parsing verification:**
Create a test that parses sample fields.yaml:
```yaml
fields:
title:
label: Title
span: left
required: true
content:
type: textarea
tab: Content
comment: Enter the main content
tabs:
fields:
description:
type: textarea
```
Verify it produces FieldsYamlConfig with correct field structure.
3. **Widget registry verification:**
Register a test widget and verify resolution works.
4. **Form renderer verification:**
Call FormRenderer.render with sample config and verify:
- Returns valid ScalaTags Frag
- Contains form element with hx-post attribute
- Contains field containers with proper span classes
- Required fields have asterisk in label
</verification>
<success_criteria>
- [ ] circe-yaml, circe-generic, and scalatags dependencies added to build.mill
- [ ] FieldsYamlSchema.scala defines FormFieldConfig with all WinterCMS-compatible properties
- [ ] ColumnsYamlSchema.scala defines ListColumnConfig with all properties
- [ ] YamlParser service parses YAML strings and files with proper error handling
- [ ] FormField case class provides processed field representation with defaults
- [ ] FormContext provides form state (data, errors, mode) access
- [ ] WidgetRegistry trait and live implementation with register/resolve methods
- [ ] FormRenderer generates form HTML with tabs, sections, HTMX attributes
- [ ] Trigger data attributes included for conditional visibility support
- [ ] Project compiles successfully with `mill summercms.compile`
</success_criteria>
<output>
After completion, create `.planning/phases/07-admin-forms-lists/07-01-SUMMARY.md`
</output>

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>

View File

@@ -0,0 +1,782 @@
---
phase: 07-admin-forms-lists
plan: 03
type: execute
wave: 2
depends_on: ["07-01"]
files_modified:
- summercms/src/admin/lists/ListColumn.scala
- summercms/src/admin/lists/columns/TextColumn.scala
- summercms/src/admin/lists/columns/DateColumn.scala
- summercms/src/admin/lists/columns/RelationColumn.scala
- summercms/src/admin/lists/columns/SwitchColumn.scala
- summercms/src/admin/lists/Filter.scala
- summercms/src/admin/lists/Pagination.scala
- summercms/src/admin/lists/ListRenderer.scala
- summercms/src/admin/lists/ColumnRegistry.scala
autonomous: true
must_haves:
truths:
- "List renders as table with columns from columns.yaml"
- "Text columns display string values correctly"
- "Date columns format dates according to format spec"
- "Relation columns display related model's name attribute"
- "Switch columns display toggle for boolean values"
- "Filters narrow displayed records based on scope definitions"
- "Pagination shows page numbers with navigation controls"
- "Column headers are clickable for sorting (if sortable: true)"
- "Bulk actions toolbar appears with checkbox selection"
artifacts:
- path: "summercms/src/admin/lists/ListColumn.scala"
provides: "Processed column definition with rendering helpers"
exports: ["ListColumn", "ColumnType"]
- path: "summercms/src/admin/lists/columns/TextColumn.scala"
provides: "Text column value rendering"
exports: ["TextColumnRenderer"]
- path: "summercms/src/admin/lists/columns/DateColumn.scala"
provides: "Date column with format support"
exports: ["DateColumnRenderer"]
- path: "summercms/src/admin/lists/columns/RelationColumn.scala"
provides: "Relation column showing related model attribute"
exports: ["RelationColumnRenderer"]
- path: "summercms/src/admin/lists/columns/SwitchColumn.scala"
provides: "Boolean toggle column"
exports: ["SwitchColumnRenderer"]
- path: "summercms/src/admin/lists/Filter.scala"
provides: "Filter scope definitions and condition types"
exports: ["FilterScope", "FilterCondition"]
- path: "summercms/src/admin/lists/Pagination.scala"
provides: "Pagination state and rendering"
exports: ["PaginationState", "PaginationRenderer"]
- path: "summercms/src/admin/lists/ListRenderer.scala"
provides: "Complete list view rendering"
exports: ["ListRenderer"]
- path: "summercms/src/admin/lists/ColumnRegistry.scala"
provides: "Column type to renderer mapping"
exports: ["ColumnRegistry", "ColumnRenderer"]
key_links:
- from: "summercms/src/admin/lists/ListRenderer.scala"
to: "summercms/src/admin/lists/ColumnRegistry.scala"
via: "resolves column types for rendering"
pattern: "columnRegistry\\.resolve"
- from: "summercms/src/admin/lists/ListRenderer.scala"
to: "summercms/src/admin/yaml/ColumnsYamlSchema.scala"
via: "uses parsed column config"
pattern: "ColumnsYamlConfig"
- from: "summercms/src/admin/lists/ListRenderer.scala"
to: "summercms/src/admin/lists/Pagination.scala"
via: "renders pagination controls"
pattern: "PaginationRenderer\\.render"
---
<objective>
Implement list rendering with columns, filters, and pagination for admin backend.
Purpose: This plan creates the list view functionality that displays records in tabular format with sorting, filtering, and pagination. Lists are the primary way admins browse and manage records.
Output: Complete list rendering system with column types (text, date, relation, switch), filter scopes, pagination, bulk actions, and HTMX-powered sorting.
</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: Create ListColumn, ColumnRegistry, and column type renderers</name>
<files>
summercms/src/admin/lists/ListColumn.scala
summercms/src/admin/lists/ColumnRegistry.scala
summercms/src/admin/lists/columns/TextColumn.scala
summercms/src/admin/lists/columns/DateColumn.scala
summercms/src/admin/lists/columns/RelationColumn.scala
summercms/src/admin/lists/columns/SwitchColumn.scala
</files>
<action>
Create ListColumn.scala with processed column definition:
```scala
enum ColumnType:
case Text, Date, DateTime, TimeTense, Switch, Partial, Relation, Number
case class ListColumn(
name: String,
label: String,
columnType: ColumnType,
sortable: Boolean = true,
searchable: Boolean = false,
invisible: Boolean = false,
width: Option[String] = None,
format: Option[String] = None, // Date format string
relation: Option[String] = None, // Relation name for relation columns
select: Option[String] = None, // Attribute to select from relation
valueFrom: Option[String] = None, // Alternative value source
path: Option[String] = None, // Path for partial columns
cssClass: Option[String] = None
):
def effectiveLabel: String = label
def isVisible: Boolean = !invisible
object ListColumn:
def fromConfig(name: String, config: ListColumnConfig): ListColumn =
val columnType = config.`type`.getOrElse("text") match
case "date" => ColumnType.Date
case "datetime" => ColumnType.DateTime
case "timetense" => ColumnType.TimeTense
case "switch" => ColumnType.Switch
case "partial" => ColumnType.Partial
case "relation" => ColumnType.Relation
case "number" => ColumnType.Number
case _ => ColumnType.Text
ListColumn(
name = name,
label = config.label,
columnType = columnType,
sortable = config.sortable.getOrElse(true),
searchable = config.searchable.getOrElse(false),
invisible = config.invisible.getOrElse(false),
width = config.width,
format = config.format,
relation = config.relation,
select = config.select,
valueFrom = config.valueFrom,
path = config.path,
cssClass = config.cssClass
)
```
Create ColumnRegistry.scala similar to WidgetRegistry:
```scala
trait ColumnRenderer:
def render(column: ListColumn, record: Map[String, Any]): scalatags.Text.Frag
trait ColumnRegistry:
def register(typeName: ColumnType, renderer: ColumnRenderer): UIO[Unit]
def resolve(typeName: ColumnType): UIO[ColumnRenderer]
object ColumnRegistry:
val live: ZLayer[Any, Nothing, ColumnRegistry] =
ZLayer.fromZIO {
Ref.make(Map.empty[ColumnType, ColumnRenderer]).map { registry =>
new ColumnRegistry:
def register(typeName: ColumnType, renderer: ColumnRenderer): UIO[Unit] =
registry.update(_ + (typeName -> renderer))
def resolve(typeName: ColumnType): UIO[ColumnRenderer] =
registry.get.map(_.getOrElse(typeName, TextColumnRenderer))
}
}
```
Create TextColumn.scala:
```scala
object TextColumnRenderer extends ColumnRenderer:
def render(column: ListColumn, record: Map[String, Any]): Frag =
val value = extractValue(column, record)
span(cls := s"column-text ${column.cssClass.getOrElse("")}",
Option(value).map(_.toString).getOrElse("")
)
private def extractValue(column: ListColumn, record: Map[String, Any]): Any =
column.valueFrom match
case Some(path) => getNestedValue(record, path.split('.').toList)
case None => record.getOrElse(column.name, null)
private def getNestedValue(data: Map[String, Any], path: List[String]): Any =
path match
case Nil => null
case head :: Nil => data.getOrElse(head, null)
case head :: tail =>
data.get(head) match
case Some(nested: Map[_, _]) => getNestedValue(nested.asInstanceOf[Map[String, Any]], tail)
case _ => null
```
Create DateColumn.scala:
```scala
import java.time.format.DateTimeFormatter
import java.time.{LocalDate, LocalDateTime, Instant, ZoneId}
object DateColumnRenderer extends ColumnRenderer:
private val defaultDateFormat = "MMM d, yyyy"
private val defaultDateTimeFormat = "MMM d, yyyy HH:mm"
def render(column: ListColumn, record: Map[String, Any]): Frag =
val value = record.getOrElse(column.name, null)
val formatted = formatDate(value, column.format, column.columnType)
span(cls := s"column-date ${column.cssClass.getOrElse("")}", formatted)
private def formatDate(value: Any, format: Option[String], colType: ColumnType): String =
if value == null then return ""
val formatter = DateTimeFormatter.ofPattern(
format.getOrElse(if colType == ColumnType.DateTime then defaultDateTimeFormat else defaultDateFormat)
)
value match
case ld: LocalDate => ld.format(formatter)
case ldt: LocalDateTime => ldt.format(formatter)
case inst: Instant => inst.atZone(ZoneId.systemDefault()).format(formatter)
case ts: java.sql.Timestamp => ts.toLocalDateTime.format(formatter)
case s: String => s // Already formatted string
case _ => value.toString
object TimeTenseColumnRenderer extends ColumnRenderer:
def render(column: ListColumn, record: Map[String, Any]): Frag =
val value = record.getOrElse(column.name, null)
val tense = formatTimeTense(value)
span(cls := "column-timetense", attr("title") := Option(value).map(_.toString).getOrElse(""), tense)
private def formatTimeTense(value: Any): String =
// Simplified implementation - "2 hours ago", "3 days ago", etc.
if value == null then return ""
// ... time ago calculation
"Recently" // Placeholder - implement proper time-ago logic
```
Create RelationColumn.scala:
```scala
object RelationColumnRenderer extends ColumnRenderer:
def render(column: ListColumn, record: Map[String, Any]): Frag =
val relationData = record.get(column.relation.getOrElse(column.name))
val displayValue = relationData match
case Some(rel: Map[_, _]) =>
val relMap = rel.asInstanceOf[Map[String, Any]]
relMap.getOrElse(column.select.getOrElse("name"), "").toString
case Some(list: List[_]) if list.nonEmpty =>
// hasMany - show count or first few items
list.take(3).map {
case m: Map[_, _] => m.asInstanceOf[Map[String, Any]].getOrElse(column.select.getOrElse("name"), "")
case v => v.toString
}.mkString(", ") + (if list.size > 3 then s" (+${list.size - 3})" else "")
case _ => ""
span(cls := s"column-relation ${column.cssClass.getOrElse("")}", displayValue)
```
Create SwitchColumn.scala:
```scala
object SwitchColumnRenderer extends ColumnRenderer:
def render(column: ListColumn, record: Map[String, Any]): Frag =
val value = record.getOrElse(column.name, false)
val isActive = 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
// Read-only switch display (clickable would need HTMX for toggle)
div(cls := s"switch-display ${if isActive then "active" else ""} ${column.cssClass.getOrElse("")}",
span(cls := "switch-indicator")
)
```
</action>
<verify>
Run `mill summercms.compile` - should compile without errors.
Verify each column renderer produces appropriate HTML for its type.
</verify>
<done>
ListColumn case class with fromConfig conversion. ColumnRegistry with registration and resolution. Column renderers for Text, Date, DateTime, TimeTense, Relation, and Switch types.
</done>
</task>
<task type="auto">
<name>Task 2: Create Filter and Pagination components</name>
<files>
summercms/src/admin/lists/Filter.scala
summercms/src/admin/lists/Pagination.scala
</files>
<action>
Create Filter.scala with filter scope definitions:
```scala
enum FilterCondition:
case Equals(value: String)
case NotEquals(value: String)
case Like(pattern: String)
case GreaterThan(value: String)
case LessThan(value: String)
case Between(from: String, to: String)
case In(values: List[String])
case IsNull
case IsNotNull
enum FilterType:
case Dropdown // Select from predefined options
case Switch // Boolean toggle
case Date // Date range filter
case DateRange // From/to date
case Text // Free text search
case Number // Numeric comparison
case class FilterScope(
name: String,
label: String,
filterType: FilterType,
conditions: Map[String, FilterCondition], // key -> condition
default: Option[String] = None,
options: Map[String, String] = Map.empty // For dropdown type
)
object FilterScope:
def fromConfig(name: String, config: FilterConfig): FilterScope =
val filterType = config.`type`.getOrElse("dropdown") match
case "switch" => FilterType.Switch
case "date" => FilterType.Date
case "daterange" => FilterType.DateRange
case "text" => FilterType.Text
case "number" => FilterType.Number
case _ => FilterType.Dropdown
FilterScope(
name = name,
label = config.label,
filterType = filterType,
conditions = parseConditions(config.conditions),
default = config.default,
options = config.options.getOrElse(Map.empty)
)
trait FilterRenderer:
def render(filter: FilterScope, activeValue: Option[String], baseUrl: String): scalatags.Text.Frag
object FilterRenderer:
def render(filter: FilterScope, activeValue: Option[String], baseUrl: String): Frag =
filter.filterType match
case FilterType.Dropdown => renderDropdownFilter(filter, activeValue, baseUrl)
case FilterType.Switch => renderSwitchFilter(filter, activeValue, baseUrl)
case FilterType.Date => renderDateFilter(filter, activeValue, baseUrl)
case FilterType.Text => renderTextFilter(filter, activeValue, baseUrl)
case _ => renderDropdownFilter(filter, activeValue, baseUrl)
private def renderDropdownFilter(filter: FilterScope, activeValue: Option[String], baseUrl: String): Frag =
div(cls := "filter-scope filter-dropdown",
label(filter.label),
select(
cls := "form-control filter-select",
attr("hx-get") := baseUrl,
attr("hx-trigger") := "change",
attr("hx-target") := ".list-widget",
attr("name") := s"filter[${filter.name}]",
option(attr("value") := "", "-- All --"),
filter.options.map { case (value, label) =>
option(
attr("value") := value,
if activeValue.contains(value) then Some(attr("selected") := "selected") else None,
label
)
}
)
)
private def renderSwitchFilter(filter: FilterScope, activeValue: Option[String], baseUrl: String): Frag =
div(cls := "filter-scope filter-switch",
label(cls := "filter-switch-label",
input(
tpe := "checkbox",
attr("name") := s"filter[${filter.name}]",
attr("hx-get") := baseUrl,
attr("hx-trigger") := "change",
attr("hx-target") := ".list-widget",
if activeValue.contains("1") then Some(attr("checked") := "checked") else None
),
span(filter.label)
)
)
private def renderDateFilter(filter: FilterScope, activeValue: Option[String], baseUrl: String): Frag =
div(cls := "filter-scope filter-date",
label(filter.label),
input(
tpe := "text",
cls := "form-control flatpickr-input",
attr("name") := s"filter[${filter.name}]",
attr("value") := activeValue.getOrElse(""),
data("flatpickr") := "true",
attr("hx-get") := baseUrl,
attr("hx-trigger") := "change",
attr("hx-target") := ".list-widget"
)
)
private def renderTextFilter(filter: FilterScope, activeValue: Option[String], baseUrl: String): Frag =
div(cls := "filter-scope filter-text",
label(filter.label),
input(
tpe := "text",
cls := "form-control",
attr("name") := s"filter[${filter.name}]",
attr("value") := activeValue.getOrElse(""),
attr("hx-get") := baseUrl,
attr("hx-trigger") := "keyup changed delay:500ms",
attr("hx-target") := ".list-widget"
)
)
```
Create Pagination.scala:
```scala
case class PaginationState(
currentPage: Int,
totalPages: Int,
totalRecords: Int,
perPage: Int,
sortColumn: Option[String] = None,
sortDirection: SortDirection = SortDirection.Asc
):
def hasNextPage: Boolean = currentPage < totalPages
def hasPreviousPage: Boolean = currentPage > 1
def pageRange: Range =
val start = math.max(1, currentPage - 2)
val end = math.min(totalPages, currentPage + 2)
start to end
def offset: Int = (currentPage - 1) * perPage
enum SortDirection:
case Asc, Desc
def toggle: SortDirection = this match
case Asc => Desc
case Desc => Asc
override def toString: String = this match
case Asc => "asc"
case Desc => "desc"
object SortDirection:
def fromString(s: String): SortDirection = s.toLowerCase match
case "desc" => Desc
case _ => Asc
object PaginationRenderer:
def render(state: PaginationState, baseUrl: String): Frag =
if state.totalPages <= 1 then return frag() // No pagination needed
nav(cls := "pagination-nav",
ul(cls := "pagination",
// Previous button
li(cls := s"page-item ${if !state.hasPreviousPage then "disabled" else ""}",
if state.hasPreviousPage then
a(cls := "page-link",
attr("hx-get") := s"$baseUrl?page=${state.currentPage - 1}",
attr("hx-target") := ".list-widget",
"Previous"
)
else
span(cls := "page-link", "Previous")
),
// Page numbers
state.pageRange.map { page =>
li(cls := s"page-item ${if page == state.currentPage then "active" else ""}",
if page == state.currentPage then
span(cls := "page-link", page.toString)
else
a(cls := "page-link",
attr("hx-get") := s"$baseUrl?page=$page",
attr("hx-target") := ".list-widget",
page.toString
)
)
},
// Next button
li(cls := s"page-item ${if !state.hasNextPage then "disabled" else ""}",
if state.hasNextPage then
a(cls := "page-link",
attr("hx-get") := s"$baseUrl?page=${state.currentPage + 1}",
attr("hx-target") := ".list-widget",
"Next"
)
else
span(cls := "page-link", "Next")
)
),
// Record count info
div(cls := "pagination-info",
s"Showing ${state.offset + 1} to ${math.min(state.offset + state.perPage, state.totalRecords)} of ${state.totalRecords} records"
)
)
```
</action>
<verify>
Run `mill summercms.compile` - should compile without errors.
Verify FilterRenderer produces filter controls with HTMX attributes.
Verify PaginationRenderer produces pagination navigation.
</verify>
<done>
FilterScope and FilterCondition enums defined. FilterRenderer generates filter controls (dropdown, switch, date, text) with HTMX triggers. PaginationState tracks page state. PaginationRenderer generates page navigation with HTMX.
</done>
</task>
<task type="auto">
<name>Task 3: Create ListRenderer with complete list view</name>
<files>
summercms/src/admin/lists/ListRenderer.scala
</files>
<action>
Create ListRenderer.scala as a ZIO service for complete list view rendering:
```scala
import scalatags.Text.all._
case class ListContext(
baseUrl: String,
editUrl: String => String, // Function: recordId -> edit URL
pagination: PaginationState,
activeFilters: Map[String, String],
selectedIds: Set[String] = Set.empty
)
trait ListRenderer:
def render(
config: ColumnsYamlConfig,
records: Seq[Map[String, Any]],
ctx: ListContext
): Task[scalatags.Text.Frag]
object ListRenderer:
val live: ZLayer[ColumnRegistry, Nothing, ListRenderer] =
ZLayer.fromFunction { (columnRegistry: ColumnRegistry) =>
new ListRenderer:
def render(
config: ColumnsYamlConfig,
records: Seq[Map[String, Any]],
ctx: ListContext
): Task[Frag] = ZIO.succeed {
val columns = config.columns.map { case (name, cfg) =>
ListColumn.fromConfig(name, cfg)
}.filter(_.isVisible).toSeq
val filters = config.filters.map { case (name, cfg) =>
FilterScope.fromConfig(name, cfg)
}.toSeq
div(cls := "list-widget",
// Toolbar with bulk actions
renderToolbar(ctx),
// Filters
if filters.nonEmpty then renderFilters(filters, ctx) else frag(),
// Table
renderTable(columns, records, ctx),
// Pagination
PaginationRenderer.render(ctx.pagination, ctx.baseUrl)
)
}
private def renderToolbar(ctx: ListContext): Frag =
div(cls := "list-toolbar",
// Create button
a(
cls := "btn btn-primary",
href := s"${ctx.baseUrl}/create",
"Create"
),
// Bulk actions (shown when items selected)
div(cls := "bulk-actions",
button(
cls := "btn btn-danger",
attr("hx-post") := s"${ctx.baseUrl}/bulk-delete",
attr("hx-include") := "[name='checked[]']",
attr("hx-confirm") := "Delete selected records?",
attr("hx-target") := ".list-widget",
"Delete Selected"
)
)
)
private def renderFilters(filters: Seq[FilterScope], ctx: ListContext): Frag =
div(cls := "list-filters",
filters.map { filter =>
FilterRenderer.render(filter, ctx.activeFilters.get(filter.name), ctx.baseUrl)
}
)
private def renderTable(columns: Seq[ListColumn], records: Seq[Map[String, Any]], ctx: ListContext): Frag =
table(cls := "table list-table",
renderTableHead(columns, ctx),
renderTableBody(columns, records, ctx)
)
private def renderTableHead(columns: Seq[ListColumn], ctx: ListContext): Frag =
thead(
tr(
// Checkbox column
th(cls := "list-checkbox",
input(tpe := "checkbox", cls := "select-all",
attr("data-action") := "select-all"
)
),
// Data columns with sorting
columns.map { col =>
val isSorted = ctx.pagination.sortColumn.contains(col.name)
val sortDir = if isSorted then ctx.pagination.sortDirection else SortDirection.Asc
val newDir = if isSorted then sortDir.toggle else SortDirection.Asc
th(
cls := s"list-header ${if col.sortable then "sortable" else ""} ${if isSorted then s"sorted-$sortDir" else ""}",
col.width.map(w => attr("style") := s"width: $w"),
if col.sortable then
a(
attr("hx-get") := s"${ctx.baseUrl}?sort=${col.name}&dir=$newDir",
attr("hx-target") := ".list-widget",
col.effectiveLabel,
if isSorted then span(cls := s"sort-icon $sortDir") else frag()
)
else
span(col.effectiveLabel)
)
}
)
)
private def renderTableBody(columns: Seq[ListColumn], records: Seq[Map[String, Any]], ctx: ListContext): Frag =
tbody(
records.map { record =>
val recordId = record.getOrElse("id", "").toString
val isSelected = ctx.selectedIds.contains(recordId)
tr(
cls := s"list-row ${if isSelected then "selected" else ""}",
// Row click to edit (per CONTEXT.md)
attr("hx-get") := ctx.editUrl(recordId),
attr("hx-target") := "#content-area",
attr("hx-push-url") := "true",
// Checkbox cell
td(cls := "list-checkbox",
attr("hx-trigger") := "click consume", // Prevent row click
input(
tpe := "checkbox",
attr("name") := "checked[]",
attr("value") := recordId,
if isSelected then Some(attr("checked") := "checked") else None
)
),
// Data cells
columns.map { col =>
td(cls := s"list-cell column-${col.columnType.toString.toLowerCase}",
renderColumnValue(col, record)
)
}
)
}
)
private def renderColumnValue(column: ListColumn, record: Map[String, Any]): Frag =
// Would use ColumnRegistry in full implementation
column.columnType match
case ColumnType.Text => TextColumnRenderer.render(column, record)
case ColumnType.Date | ColumnType.DateTime => DateColumnRenderer.render(column, record)
case ColumnType.TimeTense => TimeTenseColumnRenderer.render(column, record)
case ColumnType.Relation => RelationColumnRenderer.render(column, record)
case ColumnType.Switch => SwitchColumnRenderer.render(column, record)
case _ => TextColumnRenderer.render(column, record)
}
```
Key features implemented per CONTEXT.md:
1. **Bulk actions toolbar** above list with Create, Delete Selected
2. **Row click navigates** to edit screen (configurable via ctx.editUrl)
3. **Checkbox selection** for bulk operations (hx-trigger="click consume" prevents row click)
4. **Column sorting** via clickable headers with HTMX
5. **Filters** render above table with HTMX-triggered updates
6. **Pagination** with page numbers (traditional, not infinite scroll)
7. **Column width** from YAML config or auto-size
Additional CSS classes for density toggle (comfortable/compact) would be applied via JavaScript toggle button that adds a class to .list-widget.
</action>
<verify>
Run `mill summercms.compile` - should compile without errors.
Verify ListRenderer produces complete list HTML with:
- Toolbar with Create and Delete Selected buttons
- Filter controls if filters defined
- Table with sortable headers
- Row checkboxes and click-to-edit behavior
- Pagination navigation
</verify>
<done>
ListRenderer generates complete admin list view with toolbar, filters, sortable table, bulk actions, row click navigation, and pagination. Uses ColumnRegistry for column value rendering.
</done>
</task>
</tasks>
<verification>
After completing all tasks:
1. **Compile check:**
```bash
mill summercms.compile
```
Must succeed with no errors.
2. **Column rendering verification:**
Create test records and verify:
- TextColumnRenderer shows string value
- DateColumnRenderer formats date according to format string
- RelationColumnRenderer extracts and displays related model attribute
- SwitchColumnRenderer shows toggle in correct state
3. **Filter rendering verification:**
Create FilterScope instances and verify:
- Dropdown filter produces select with options
- Switch filter produces checkbox
- All filters have hx-get and hx-target attributes
4. **Pagination verification:**
Create PaginationState with 10 pages, current page 5, verify:
- Previous/Next buttons present and enabled
- Page range shows 3-7 (current +/- 2)
- Current page is marked active
- All links have hx-get for HTMX
5. **ListRenderer integration verification:**
Call ListRenderer.render with sample config and records, verify:
- Output contains table with thead and tbody
- Toolbar has Create and Delete Selected buttons
- Headers are clickable with sort URLs
- Rows have checkbox and hx-get for edit navigation
- Pagination appears at bottom
</verification>
<success_criteria>
- [ ] ListColumn case class with fromConfig conversion from ListColumnConfig
- [ ] ColumnRegistry provides column type to renderer mapping
- [ ] TextColumnRenderer handles text/string values with nested path support
- [ ] DateColumnRenderer formats dates with configurable format strings
- [ ] RelationColumnRenderer extracts and displays related model attributes
- [ ] SwitchColumnRenderer displays boolean toggle state
- [ ] FilterScope and FilterCondition enums defined for all filter types
- [ ] FilterRenderer generates dropdown, switch, date, text filter controls
- [ ] All filters have HTMX attributes for live filtering
- [ ] PaginationState tracks page, total, sort information
- [ ] PaginationRenderer generates page navigation with HTMX
- [ ] ListRenderer produces complete list view with toolbar, table, pagination
- [ ] Table headers sortable via HTMX when sortable: true
- [ ] Row checkboxes support bulk selection
- [ ] Row click triggers edit navigation via HTMX
- [ ] Project compiles successfully with `mill summercms.compile`
</success_criteria>
<output>
After completion, create `.planning/phases/07-admin-forms-lists/07-03-SUMMARY.md`
</output>