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
783 lines
29 KiB
Markdown
783 lines
29 KiB
Markdown
---
|
|
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>
|