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:
@@ -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 | - |
|
||||||
|
|||||||
301
.planning/phases/07-admin-forms-lists/07-01-PLAN.md
Normal file
301
.planning/phases/07-admin-forms-lists/07-01-PLAN.md
Normal 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>
|
||||||
526
.planning/phases/07-admin-forms-lists/07-02-PLAN.md
Normal file
526
.planning/phases/07-admin-forms-lists/07-02-PLAN.md
Normal 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>
|
||||||
782
.planning/phases/07-admin-forms-lists/07-03-PLAN.md
Normal file
782
.planning/phases/07-admin-forms-lists/07-03-PLAN.md
Normal 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>
|
||||||
Reference in New Issue
Block a user