---
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"
---
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.
@/home/jin/.claude/get-shit-done/workflows/execute-plan.md
@/home/jin/.claude/get-shit-done/templates/summary.md
@.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:
```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")
)
```
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:
```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.
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:**
```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
- [ ] 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`