Files
summercms-initial-research/.planning/phases/07-admin-forms-lists/07-03-PLAN.md
Jakub Zych 34806c1845 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
2026-02-05 14:55:56 +01:00

29 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, must_haves
phase plan type wave depends_on files_modified autonomous must_haves
07-admin-forms-lists 03 execute 2
07-01
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
true
truths artifacts key_links
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
path provides exports
summercms/src/admin/lists/ListColumn.scala Processed column definition with rendering helpers
ListColumn
ColumnType
path provides exports
summercms/src/admin/lists/columns/TextColumn.scala Text column value rendering
TextColumnRenderer
path provides exports
summercms/src/admin/lists/columns/DateColumn.scala Date column with format support
DateColumnRenderer
path provides exports
summercms/src/admin/lists/columns/RelationColumn.scala Relation column showing related model attribute
RelationColumnRenderer
path provides exports
summercms/src/admin/lists/columns/SwitchColumn.scala Boolean toggle column
SwitchColumnRenderer
path provides exports
summercms/src/admin/lists/Filter.scala Filter scope definitions and condition types
FilterScope
FilterCondition
path provides exports
summercms/src/admin/lists/Pagination.scala Pagination state and rendering
PaginationState
PaginationRenderer
path provides exports
summercms/src/admin/lists/ListRenderer.scala Complete list view rendering
ListRenderer
path provides exports
summercms/src/admin/lists/ColumnRegistry.scala Column type to renderer mapping
ColumnRegistry
ColumnRenderer
from to via pattern
summercms/src/admin/lists/ListRenderer.scala summercms/src/admin/lists/ColumnRegistry.scala resolves column types for rendering columnRegistry.resolve
from to via pattern
summercms/src/admin/lists/ListRenderer.scala summercms/src/admin/yaml/ColumnsYamlSchema.scala uses parsed column config ColumnsYamlConfig
from to via pattern
summercms/src/admin/lists/ListRenderer.scala summercms/src/admin/lists/Pagination.scala renders pagination controls PaginationRenderer.render
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.

<execution_context> @/home/jin/.claude/get-shit-done/workflows/execute-plan.md @/home/jin/.claude/get-shit-done/templates/summary.md </execution_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 Task 1: Create ListColumn, ColumnRegistry, and column type renderers 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 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:

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:

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:

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:

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")
    )
Run `mill summercms.compile` - should compile without errors. Verify each column renderer produces appropriate HTML for its type. ListColumn case class with fromConfig conversion. ColumnRegistry with registration and resolution. Column renderers for Text, Date, DateTime, TimeTense, Relation, and Switch types. Task 2: Create Filter and Pagination components summercms/src/admin/lists/Filter.scala summercms/src/admin/lists/Pagination.scala 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"
      )
    )
Run `mill summercms.compile` - should compile without errors. Verify FilterRenderer produces filter controls with HTMX attributes. Verify PaginationRenderer produces pagination navigation. 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. Task 3: Create ListRenderer with complete list view summercms/src/admin/lists/ListRenderer.scala Create ListRenderer.scala as a ZIO service for complete list view rendering:
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. 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 ListRenderer generates complete admin list view with toolbar, filters, sortable table, bulk actions, row click navigation, and pagination. Uses ColumnRegistry for column value rendering.
After completing all tasks:
  1. Compile check:

    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

<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>
After completion, create `.planning/phases/07-admin-forms-lists/07-03-SUMMARY.md`