Files
Jakub Zych 8d00fe904b 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
2026-02-05 14:48:52 +01:00

22 KiB

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:

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

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:

// 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:

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:

// 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:

// 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:

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

// 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

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

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 - YAML parsing documentation
  • ScalaTags Documentation - HTML generation

Secondary (MEDIUM confidence)

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)