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
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 |
|
|
true |
|
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, Numbercase 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")
)
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"
)
)
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:
- Bulk actions toolbar above list with Create, Delete Selected
- Row click navigates to edit screen (configurable via ctx.editUrl)
- Checkbox selection for bulk operations (hx-trigger="click consume" prevents row click)
- Column sorting via clickable headers with HTMX
- Filters render above table with HTMX-triggered updates
- Pagination with page numbers (traditional, not infinite scroll)
- 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.
-
Compile check:
mill summercms.compileMust succeed with no errors.
-
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
-
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
-
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
-
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>