docs(07): create phase plan

Phase 07: Admin Forms & Lists
- 3 plan(s) in 2 wave(s)
- Wave 1: 07-01 (YAML parsing, WidgetRegistry, FormRenderer)
- Wave 2: 07-02 (form widgets), 07-03 (list rendering) - parallel
- Ready for execution
This commit is contained in:
Jakub Zych
2026-02-05 14:55:56 +01:00
parent 8d00fe904b
commit 34806c1845
4 changed files with 1614 additions and 5 deletions

View File

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