docs(07): research admin forms/lists domain
Phase 7: Admin Forms & Lists - Standard stack identified: circe-yaml, ScalaTags, HTMX - Architecture patterns documented: YAML ADT parsing, widget registry, form generation - Pitfalls catalogued: YAML anchors, trigger conditions, repeater state, relation modals
This commit is contained in:
602
.planning/phases/07-admin-forms-lists/07-RESEARCH.md
Normal file
602
.planning/phases/07-admin-forms-lists/07-RESEARCH.md
Normal file
@@ -0,0 +1,602 @@
|
||||
# Phase 7: Admin Forms & Lists - Research
|
||||
|
||||
**Researched:** 2026-02-05
|
||||
**Domain:** YAML-driven form and list generation for admin backend
|
||||
**Confidence:** HIGH (based on WinterCMS reference + official library documentation)
|
||||
|
||||
## Summary
|
||||
|
||||
This research covers the implementation of YAML-driven form and list generation for SummerCMS admin backend. The WinterCMS reference implementation provides a comprehensive blueprint with `fields.yaml` for forms and `columns.yaml` for lists, supported by a widget system for field rendering.
|
||||
|
||||
The standard approach involves:
|
||||
1. YAML parsing into typed Scala case classes representing form fields and list columns
|
||||
2. A widget registry system for mapping field types to rendering implementations
|
||||
3. ScalaTags-based HTML generation with HTMX for interactivity
|
||||
4. Type-safe validation defined in Scala models (not YAML)
|
||||
|
||||
**Primary recommendation:** Use circe-yaml for YAML parsing into strongly-typed ADTs, ScalaTags for HTML generation, and HTMX for AJAX-like form submission and partial updates.
|
||||
|
||||
## Standard Stack
|
||||
|
||||
### Core
|
||||
| Library | Version | Purpose | Why Standard |
|
||||
|---------|---------|---------|--------------|
|
||||
| circe-yaml | 1.x | YAML parsing | Bridges YAML to Circe JSON AST with type-safe ADT marshalling |
|
||||
| circe | 0.14.x | JSON/YAML codec derivation | Compile-time derivation of encoders/decoders |
|
||||
| ScalaTags | 0.13.x | HTML generation | Type-safe, composable HTML fragments |
|
||||
| HTMX | 2.x | Frontend interactivity | Declarative AJAX, form submission, partial updates |
|
||||
|
||||
### Supporting
|
||||
| Library | Version | Purpose | When to Use |
|
||||
|---------|---------|---------|-------------|
|
||||
| zio-config-yaml | 4.x | ZIO-native YAML config | Alternative if deeper ZIO integration needed |
|
||||
| Sortable.js | 1.15.x | Drag-and-drop reordering | Repeater item reorder, list row reorder |
|
||||
| Flatpickr | 4.6.x | Date/time picker | DatePicker widget implementation |
|
||||
|
||||
### Alternatives Considered
|
||||
| Instead of | Could Use | Tradeoff |
|
||||
|------------|-----------|----------|
|
||||
| circe-yaml | zio-config-yaml | Better ZIO integration but less flexible for ad-hoc YAML |
|
||||
| ScalaTags | Pebble templates | Pebble already in stack; ScalaTags better for programmatic generation |
|
||||
| HTMX | Full SPA (React) | HTMX simpler, server-rendered, aligns with WinterCMS approach |
|
||||
|
||||
**Mill dependencies:**
|
||||
```scala
|
||||
ivy"io.circe::circe-yaml:1.0.0",
|
||||
ivy"io.circe::circe-generic:0.14.9",
|
||||
ivy"com.lihaoyi::scalatags:0.13.1"
|
||||
```
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### Recommended Project Structure
|
||||
```
|
||||
summercms/
|
||||
├── admin/
|
||||
│ ├── forms/
|
||||
│ │ ├── Form.scala # Form widget container
|
||||
│ │ ├── FormField.scala # Field definition ADT
|
||||
│ │ ├── FormTabs.scala # Tab grouping
|
||||
│ │ ├── widgets/ # Widget implementations
|
||||
│ │ │ ├── TextWidget.scala
|
||||
│ │ │ ├── TextareaWidget.scala
|
||||
│ │ │ ├── DropdownWidget.scala
|
||||
│ │ │ ├── CheckboxWidget.scala
|
||||
│ │ │ ├── DatePickerWidget.scala
|
||||
│ │ │ ├── RepeaterWidget.scala
|
||||
│ │ │ └── RelationWidget.scala
|
||||
│ │ └── WidgetRegistry.scala # Widget type -> renderer mapping
|
||||
│ ├── lists/
|
||||
│ │ ├── ListView.scala # List widget container
|
||||
│ │ ├── ListColumn.scala # Column definition ADT
|
||||
│ │ ├── columns/ # Column type implementations
|
||||
│ │ │ ├── TextColumn.scala
|
||||
│ │ │ ├── DateColumn.scala
|
||||
│ │ │ ├── RelationColumn.scala
|
||||
│ │ │ └── SwitchColumn.scala
|
||||
│ │ ├── Filter.scala # Filter scopes
|
||||
│ │ └── Pagination.scala # Pagination handling
|
||||
│ └── yaml/
|
||||
│ ├── FieldsYamlParser.scala # fields.yaml parser
|
||||
│ ├── ColumnsYamlParser.scala # columns.yaml parser
|
||||
│ └── YamlSchema.scala # Shared YAML structures
|
||||
```
|
||||
|
||||
### Pattern 1: YAML to ADT Parsing
|
||||
**What:** Parse YAML configuration into strongly-typed Scala ADTs using circe-yaml
|
||||
**When to use:** Loading fields.yaml and columns.yaml definitions
|
||||
**Example:**
|
||||
```scala
|
||||
// Source: Circe-YAML documentation
|
||||
import io.circe.yaml.parser
|
||||
import io.circe.generic.auto._
|
||||
|
||||
sealed trait FieldType
|
||||
case object Text extends FieldType
|
||||
case object Textarea extends FieldType
|
||||
case object Dropdown extends FieldType
|
||||
case object Checkbox extends FieldType
|
||||
case object DatePicker extends FieldType
|
||||
case object Repeater extends FieldType
|
||||
case object Relation extends FieldType
|
||||
|
||||
case class TriggerConfig(
|
||||
action: String, // "show", "hide", "enable", "disable"
|
||||
field: String, // field name to watch
|
||||
condition: String // "checked", "unchecked", "value[x]"
|
||||
)
|
||||
|
||||
case class FormField(
|
||||
`type`: Option[String] = Some("text"),
|
||||
label: Option[String] = None,
|
||||
span: Option[String] = Some("full"), // "left", "right", "full", "auto"
|
||||
tab: Option[String] = None,
|
||||
placeholder: Option[String] = None,
|
||||
comment: Option[String] = None,
|
||||
commentAbove: Option[String] = None,
|
||||
default: Option[String] = None,
|
||||
required: Option[Boolean] = None,
|
||||
disabled: Option[Boolean] = None,
|
||||
hidden: Option[Boolean] = None,
|
||||
context: Option[List[String]] = None, // ["create", "update"]
|
||||
trigger: Option[TriggerConfig] = None,
|
||||
options: Option[Map[String, String]] = None,
|
||||
cssClass: Option[String] = None,
|
||||
// Repeater-specific
|
||||
form: Option[FormDefinition] = None,
|
||||
minItems: Option[Int] = None,
|
||||
maxItems: Option[Int] = None,
|
||||
// Relation-specific
|
||||
select: Option[String] = None,
|
||||
nameFrom: Option[String] = None,
|
||||
emptyOption: Option[String] = None
|
||||
)
|
||||
|
||||
case class FormDefinition(
|
||||
fields: Map[String, FormField],
|
||||
tabs: Option[TabsConfig] = None,
|
||||
secondaryTabs: Option[TabsConfig] = None
|
||||
)
|
||||
|
||||
// Parsing
|
||||
val yaml = """
|
||||
fields:
|
||||
title:
|
||||
label: Title
|
||||
span: left
|
||||
content:
|
||||
type: textarea
|
||||
tab: Content
|
||||
"""
|
||||
|
||||
parser.parse(yaml).flatMap(_.as[FormDefinition])
|
||||
```
|
||||
|
||||
### Pattern 2: Widget Registry Pattern
|
||||
**What:** Map field type strings to widget rendering implementations
|
||||
**When to use:** Dynamically rendering form fields based on YAML type
|
||||
**Example:**
|
||||
```scala
|
||||
trait FormWidget {
|
||||
def render(field: FormField, value: Any, context: FormContext): Frag
|
||||
def getSaveValue(field: FormField, postData: Map[String, Any]): Any
|
||||
}
|
||||
|
||||
class WidgetRegistry {
|
||||
private val widgets = mutable.Map[String, FormWidget]()
|
||||
|
||||
def register(typeName: String, widget: FormWidget): Unit =
|
||||
widgets(typeName) = widget
|
||||
|
||||
def resolve(typeName: String): Option[FormWidget] =
|
||||
widgets.get(typeName)
|
||||
}
|
||||
|
||||
// Registration (in plugin boot)
|
||||
registry.register("text", TextWidget)
|
||||
registry.register("textarea", TextareaWidget)
|
||||
registry.register("dropdown", DropdownWidget)
|
||||
registry.register("checkbox", CheckboxWidget)
|
||||
registry.register("datepicker", DatePickerWidget)
|
||||
registry.register("repeater", RepeaterWidget)
|
||||
registry.register("relation", RelationWidget)
|
||||
```
|
||||
|
||||
### Pattern 3: ScalaTags Form Generation
|
||||
**What:** Generate HTML forms from parsed YAML using ScalaTags
|
||||
**When to use:** Rendering forms and lists in admin controllers
|
||||
**Example:**
|
||||
```scala
|
||||
// Source: ScalaTags documentation
|
||||
import scalatags.Text.all._
|
||||
|
||||
def renderFormField(field: FormField, name: String, value: Any): Frag = {
|
||||
val fieldType = field.`type`.getOrElse("text")
|
||||
val widget = widgetRegistry.resolve(fieldType)
|
||||
|
||||
div(cls := s"form-group span-${field.span.getOrElse("full")}",
|
||||
field.trigger.map(t => Seq(
|
||||
data.trigger := s"[name='${t.field}']",
|
||||
data.triggerAction := t.action,
|
||||
data.triggerCondition := t.condition
|
||||
)).getOrElse(Nil),
|
||||
|
||||
field.label.map(l =>
|
||||
label(cls := "form-label",
|
||||
l,
|
||||
field.required.filter(identity).map(_ => span(cls := "required", "*"))
|
||||
)
|
||||
),
|
||||
|
||||
field.commentAbove.map(c => p(cls := "help-block before-field", c)),
|
||||
|
||||
widget.map(_.render(field, value, context)).getOrElse(
|
||||
input(cls := "form-control", `type` := "text", attr("name") := name)
|
||||
),
|
||||
|
||||
field.comment.map(c => p(cls := "help-block", c))
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 4: HTMX Form Submission with Validation Display
|
||||
**What:** Handle form submission and validation error display via HTMX
|
||||
**When to use:** Save forms with server-side validation, display inline errors
|
||||
**Example:**
|
||||
```scala
|
||||
// Form with HTMX attributes
|
||||
form(
|
||||
attr("hx-post") := "/admin/posts/update",
|
||||
attr("hx-target") := "#form-container",
|
||||
attr("hx-swap") := "innerHTML",
|
||||
|
||||
// Fields rendered here...
|
||||
|
||||
div(cls := "form-buttons",
|
||||
button(`type` := "submit", cls := "btn btn-primary",
|
||||
attr("hx-include") := "[name]",
|
||||
"Save"
|
||||
),
|
||||
button(`type` := "submit", cls := "btn btn-primary",
|
||||
attr("hx-post") := "/admin/posts/update?close=true",
|
||||
"Save and Close"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
// Server response on validation error (422 status)
|
||||
def renderFieldWithError(field: FormField, name: String, value: Any, error: String): Frag = {
|
||||
div(cls := "form-group has-error",
|
||||
// ... field rendering ...
|
||||
p(cls := "error-message", error)
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 5: List Column Rendering
|
||||
**What:** Render table columns with type-specific formatting
|
||||
**When to use:** Admin list views with searchable, sortable columns
|
||||
**Example:**
|
||||
```scala
|
||||
case class ListColumn(
|
||||
label: String,
|
||||
`type`: Option[String] = Some("text"),
|
||||
searchable: Option[Boolean] = None,
|
||||
sortable: Option[Boolean] = Some(true),
|
||||
invisible: Option[Boolean] = None,
|
||||
width: Option[String] = None,
|
||||
relation: Option[String] = None,
|
||||
select: Option[String] = None,
|
||||
format: Option[String] = None // for dates, numbers
|
||||
)
|
||||
|
||||
def renderColumnValue(column: ListColumn, record: Model): Frag = {
|
||||
val value = column.relation match {
|
||||
case Some(rel) => record.getRelation(rel).map(_.getAttribute(column.select.getOrElse("name")))
|
||||
case None => record.getAttribute(column.columnName)
|
||||
}
|
||||
|
||||
column.`type`.getOrElse("text") match {
|
||||
case "date" => formatDate(value, column.format)
|
||||
case "timetense" => formatTimeTense(value) // "2 hours ago"
|
||||
case "switch" => renderSwitch(value)
|
||||
case "partial" => renderPartial(column.path, record)
|
||||
case _ => span(value.toString)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
- **YAML in YAML strings:** Don't nest raw YAML strings; use proper nested structures
|
||||
- **Validation in YAML:** Keep validation rules in Scala models, not YAML definitions
|
||||
- **Widget type guessing:** Always require explicit `type` or use clear defaults
|
||||
- **Inline JavaScript:** Use HTMX data attributes, not inline event handlers
|
||||
- **Direct DOM manipulation:** Let HTMX handle DOM updates from server responses
|
||||
|
||||
## Don't Hand-Roll
|
||||
|
||||
Problems that look simple but have existing solutions:
|
||||
|
||||
| Problem | Don't Build | Use Instead | Why |
|
||||
|---------|-------------|-------------|-----|
|
||||
| YAML parsing | Custom parser | circe-yaml | Edge cases: anchors, aliases, multi-doc, escaping |
|
||||
| Date/time picker | Custom calendar | Flatpickr | i18n, timezones, range selection |
|
||||
| Drag-and-drop | Custom DnD | Sortable.js | Touch support, animations, nested lists |
|
||||
| Form validation display | Custom error handling | HTMX + 422 status | Standard pattern, proper error semantics |
|
||||
| Rich text editing | Custom editor | Existing RichEditor | Toolbar, formatting, sanitization |
|
||||
| Color picker | Custom picker | Existing ColorPicker | Presets, formats, accessibility |
|
||||
|
||||
**Key insight:** Form widgets are deceptively complex. Each widget has edge cases (empty states, validation, disabled mode, preview mode, nested forms). Reuse WinterCMS widget patterns rather than reinventing.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: YAML Anchor/Alias Complexity
|
||||
**What goes wrong:** YAML anchors (&) and aliases (*) create unexpected merge behavior
|
||||
**Why it happens:** Users expect intuitive override behavior but YAML merge has specific semantics
|
||||
**How to avoid:** Document merge behavior clearly; test anchor resolution; provide clear error messages for circular references
|
||||
**Warning signs:** Fields appearing with unexpected values; missing overrides
|
||||
|
||||
### Pitfall 2: Trigger Condition Evaluation
|
||||
**What goes wrong:** Conditional field visibility (`trigger`) doesn't work as expected
|
||||
**Why it happens:** Complex condition expressions, field name resolution in nested forms, timing of evaluation
|
||||
**How to avoid:**
|
||||
- Use simple condition types: `checked`, `unchecked`, `value[x]`, `empty`, `filled`
|
||||
- Resolve field names relative to form context (handle repeaters specially)
|
||||
- Trigger on client-side with JavaScript, not server round-trips
|
||||
**Warning signs:** Fields that don't show/hide correctly; triggers that work in forms but not in repeaters
|
||||
|
||||
### Pitfall 3: Repeater State Management
|
||||
**What goes wrong:** Repeater items lose state, duplicate incorrectly, or save wrong data
|
||||
**Why it happens:** Index-based naming collisions; AJAX add/remove doesn't sync with form state
|
||||
**How to avoid:**
|
||||
- Use unique item keys (UUIDs) not sequential indexes
|
||||
- Send full repeater state on form submit, not diffs
|
||||
- Re-render entire repeater on add/remove via HTMX partial
|
||||
**Warning signs:** "Add item" duplicates existing item's data; deleted items reappear
|
||||
|
||||
### Pitfall 4: Relation Widget Complexity
|
||||
**What goes wrong:** RelationController-style modals don't load, save, or dismiss correctly
|
||||
**Why it happens:** Modal forms need separate form context, deferred bindings, session keys
|
||||
**How to avoid:**
|
||||
- Each modal gets its own form session key
|
||||
- Use deferred bindings for unsaved parent models
|
||||
- Clear modal content on close to prevent stale data
|
||||
**Warning signs:** Related records not saving; modal data persisting across opens
|
||||
|
||||
### Pitfall 5: List Column Type Coercion
|
||||
**What goes wrong:** Column values display incorrectly (dates as timestamps, booleans as 0/1)
|
||||
**Why it happens:** No type coercion from database values to display values
|
||||
**How to avoid:**
|
||||
- Always apply column type formatting before display
|
||||
- Handle null values explicitly for each column type
|
||||
- Test with actual database values, not mocked data
|
||||
**Warning signs:** Raw database values appearing in list; "null" or "None" displayed
|
||||
|
||||
## Code Examples
|
||||
|
||||
### Complete fields.yaml Parsing
|
||||
```scala
|
||||
// Source: WinterCMS fields.yaml structure analysis
|
||||
import io.circe._
|
||||
import io.circe.yaml.parser
|
||||
import io.circe.generic.semiauto._
|
||||
|
||||
case class PresetConfig(field: String, `type`: String)
|
||||
|
||||
case class TriggerConfig(
|
||||
action: String,
|
||||
field: String,
|
||||
condition: String
|
||||
)
|
||||
|
||||
case class FormFieldConfig(
|
||||
`type`: Option[String] = None,
|
||||
label: Option[String] = None,
|
||||
span: Option[String] = None,
|
||||
size: Option[String] = None,
|
||||
tab: Option[String] = None,
|
||||
placeholder: Option[String] = None,
|
||||
comment: Option[String] = None,
|
||||
commentAbove: Option[String] = None,
|
||||
default: Option[Json] = None,
|
||||
required: Option[Boolean] = None,
|
||||
disabled: Option[Boolean] = None,
|
||||
hidden: Option[Boolean] = None,
|
||||
readOnly: Option[Boolean] = None,
|
||||
context: Option[Json] = None, // String or List[String]
|
||||
trigger: Option[TriggerConfig] = None,
|
||||
preset: Option[PresetConfig] = None,
|
||||
dependsOn: Option[List[String]] = None,
|
||||
options: Option[Json] = None, // Map or method name string
|
||||
cssClass: Option[String] = None,
|
||||
attributes: Option[Map[String, String]] = None,
|
||||
// Widget-specific
|
||||
form: Option[NestedFormConfig] = None,
|
||||
path: Option[String] = None,
|
||||
mode: Option[String] = None,
|
||||
// Repeater
|
||||
prompt: Option[String] = None,
|
||||
titleFrom: Option[String] = None,
|
||||
minItems: Option[Int] = None,
|
||||
maxItems: Option[Int] = None,
|
||||
sortable: Option[Boolean] = None,
|
||||
// Relation
|
||||
select: Option[String] = None,
|
||||
nameFrom: Option[String] = None,
|
||||
emptyOption: Option[String] = None,
|
||||
// FileUpload
|
||||
imageWidth: Option[Int] = None,
|
||||
imageHeight: Option[Int] = None
|
||||
)
|
||||
|
||||
case class TabsConfig(
|
||||
fields: Map[String, FormFieldConfig],
|
||||
stretch: Option[Boolean] = None,
|
||||
cssClass: Option[String] = None,
|
||||
paneCssClass: Option[Map[String, String]] = None,
|
||||
`lazy`: Option[List[String]] = None,
|
||||
icons: Option[Map[String, String]] = None
|
||||
)
|
||||
|
||||
case class NestedFormConfig(
|
||||
fields: Map[String, FormFieldConfig]
|
||||
)
|
||||
|
||||
case class FieldsYamlConfig(
|
||||
fields: Option[Map[String, FormFieldConfig]] = None,
|
||||
tabs: Option[TabsConfig] = None,
|
||||
secondaryTabs: Option[TabsConfig] = None
|
||||
)
|
||||
|
||||
object FieldsYamlConfig {
|
||||
implicit val presetDecoder: Decoder[PresetConfig] = deriveDecoder
|
||||
implicit val triggerDecoder: Decoder[TriggerConfig] = deriveDecoder
|
||||
implicit val nestedFormDecoder: Decoder[NestedFormConfig] = deriveDecoder
|
||||
implicit val fieldDecoder: Decoder[FormFieldConfig] = deriveDecoder
|
||||
implicit val tabsDecoder: Decoder[TabsConfig] = deriveDecoder
|
||||
implicit val configDecoder: Decoder[FieldsYamlConfig] = deriveDecoder
|
||||
|
||||
def parse(yaml: String): Either[Error, FieldsYamlConfig] =
|
||||
parser.parse(yaml).flatMap(_.as[FieldsYamlConfig])
|
||||
}
|
||||
```
|
||||
|
||||
### Text Widget Implementation
|
||||
```scala
|
||||
import scalatags.Text.all._
|
||||
|
||||
object TextWidget extends FormWidget {
|
||||
def render(field: FormFieldConfig, name: String, value: Any, ctx: FormContext): Frag = {
|
||||
val inputType = field.`type`.getOrElse("text") match {
|
||||
case "password" => "password"
|
||||
case "email" => "email"
|
||||
case "url" => "url"
|
||||
case "number" => "number"
|
||||
case _ => "text"
|
||||
}
|
||||
|
||||
input(
|
||||
cls := "form-control",
|
||||
`type` := inputType,
|
||||
attr("name") := name,
|
||||
attr("value") := Option(value).map(_.toString).getOrElse(""),
|
||||
field.placeholder.map(p => attr("placeholder") := p),
|
||||
field.required.filter(identity).map(_ => attr("required") := "required"),
|
||||
field.disabled.filter(identity).map(_ => attr("disabled") := "disabled"),
|
||||
field.readOnly.filter(identity).map(_ => attr("readonly") := "readonly"),
|
||||
field.attributes.map(_.toSeq.map { case (k, v) => attr(k) := v }).getOrElse(Nil)
|
||||
)
|
||||
}
|
||||
|
||||
def getSaveValue(field: FormFieldConfig, postData: Map[String, Any]): Any =
|
||||
postData.get(field.name).map(_.toString.trim).filter(_.nonEmpty)
|
||||
}
|
||||
```
|
||||
|
||||
### List View with HTMX Sorting and Pagination
|
||||
```scala
|
||||
def renderListView(config: ColumnsYamlConfig, records: Seq[Model], pagination: PaginationState): Frag = {
|
||||
div(cls := "list-widget",
|
||||
// Toolbar
|
||||
div(cls := "list-toolbar",
|
||||
button(cls := "btn btn-primary",
|
||||
attr("hx-get") := "/admin/posts/create",
|
||||
attr("hx-target") := "#form-modal .modal-content",
|
||||
"Create"
|
||||
),
|
||||
button(cls := "btn btn-danger",
|
||||
attr("hx-post") := "/admin/posts/bulk-delete",
|
||||
attr("hx-include") := "[name='checked[]']",
|
||||
attr("hx-confirm") := "Delete selected records?",
|
||||
"Delete Selected"
|
||||
)
|
||||
),
|
||||
|
||||
// Table
|
||||
table(cls := "table list-table",
|
||||
thead(
|
||||
tr(
|
||||
th(cls := "list-checkbox",
|
||||
input(`type` := "checkbox", cls := "select-all")
|
||||
),
|
||||
config.columns.filter(!_.invisible.getOrElse(false)).map { case (name, col) =>
|
||||
th(
|
||||
col.sortable.getOrElse(true).option(
|
||||
attr("hx-get") := s"/admin/posts?sort=$name&dir=${toggleDir(pagination.sortDir)}",
|
||||
attr("hx-target") := ".list-widget",
|
||||
cls := "sortable"
|
||||
),
|
||||
col.label,
|
||||
(pagination.sortColumn == name).option(
|
||||
i(cls := s"sort-icon ${pagination.sortDir}")
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
),
|
||||
tbody(
|
||||
records.map { record =>
|
||||
tr(
|
||||
attr("hx-get") := s"/admin/posts/${record.id}/update",
|
||||
attr("hx-target") := "#form-container",
|
||||
td(cls := "list-checkbox",
|
||||
input(`type` := "checkbox", attr("name") := "checked[]", attr("value") := record.id)
|
||||
),
|
||||
config.columns.filter(!_.invisible.getOrElse(false)).map { case (name, col) =>
|
||||
td(renderColumnValue(col, name, record))
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
),
|
||||
|
||||
// Pagination
|
||||
renderPagination(pagination)
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## State of the Art
|
||||
|
||||
| Old Approach | Current Approach | When Changed | Impact |
|
||||
|--------------|------------------|--------------|--------|
|
||||
| jQuery AJAX forms | HTMX declarative | 2020+ | Simpler, less JS, server-rendered |
|
||||
| PHP template YAML | Type-safe ADT parsing | N/A (new) | Compile-time validation |
|
||||
| Runtime widget resolution | Registry pattern | Standard | Explicit, testable |
|
||||
| Inline validation | Submit-only validation | Decision | Simpler UX, server authority |
|
||||
|
||||
**Deprecated/outdated:**
|
||||
- Bootstrap 3 styles: Use modern CSS Grid/Flexbox
|
||||
- jQuery plugins: Use vanilla JS or dedicated libraries
|
||||
- Inline event handlers: Use HTMX data attributes
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Lazy tab loading implementation**
|
||||
- What we know: WinterCMS supports lazy tab loading via `lazy` config
|
||||
- What's unclear: Best HTMX pattern for lazy content loading in tabs
|
||||
- Recommendation: Use `hx-trigger="revealed"` on tab content divs
|
||||
|
||||
2. **Custom widget packaging**
|
||||
- What we know: Plugins register widgets via boot lifecycle
|
||||
- What's unclear: How to package widget assets (CSS/JS) with plugin
|
||||
- Recommendation: Define asset bundling convention in plugin system phase
|
||||
|
||||
3. **RelationController modal form context**
|
||||
- What we know: Modals need separate session keys and form contexts
|
||||
- What's unclear: How to pass parent context to modal for deferred bindings
|
||||
- Recommendation: Include session key in modal URL params; store in hidden field
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence)
|
||||
- WinterCMS reference implementation (`golem15-wintercms-starter/modules/backend/`)
|
||||
- FormField.php - Field definition and trigger/preset handling
|
||||
- Form.php - Form widget container and field rendering
|
||||
- Lists.php - List widget with columns and pagination
|
||||
- ListColumn.php - Column definition and value extraction
|
||||
- FilterScope.php - Filter scope types and conditions
|
||||
- Repeater.php - Repeater widget implementation
|
||||
- RelationController.php - Relation management behavior
|
||||
- [Circe-YAML GitHub](https://github.com/circe/circe-yaml) - YAML parsing documentation
|
||||
- [ScalaTags Documentation](https://com-lihaoyi.github.io/scalatags/) - HTML generation
|
||||
|
||||
### Secondary (MEDIUM confidence)
|
||||
- [HTMX Examples - Inline Validation](https://htmx.org/examples/inline-validation/) - Form validation patterns
|
||||
- [Handling Form Errors in HTMX](https://dev.to/yawaramin/handling-form-errors-in-htmx-3ncg) - Error display patterns
|
||||
- [Baeldung - Handling YAML in Scala 3](https://www.baeldung.com/scala/yaml-scala-3) - YAML library comparison
|
||||
|
||||
### Tertiary (LOW confidence)
|
||||
- WebSearch results on schema-driven form generation patterns
|
||||
- Community discussions on Scala YAML libraries
|
||||
|
||||
## Metadata
|
||||
|
||||
**Confidence breakdown:**
|
||||
- Standard stack: HIGH - Based on official library docs and established patterns
|
||||
- Architecture: HIGH - Based on thorough WinterCMS reference analysis
|
||||
- Pitfalls: MEDIUM - Derived from WinterCMS patterns, may have Scala-specific issues
|
||||
- Code examples: MEDIUM - Adapted from WinterCMS PHP patterns to Scala
|
||||
|
||||
**Research date:** 2026-02-05
|
||||
**Valid until:** 2026-03-05 (30 days - stable domain)
|
||||
Reference in New Issue
Block a user