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