docs(07): create phase plan
Phase 07: Admin Forms & Lists - 3 plan(s) in 2 wave(s) - Wave 1: 07-01 (YAML parsing, WidgetRegistry, FormRenderer) - Wave 2: 07-02 (form widgets), 07-03 (list rendering) - parallel - Ready for execution
This commit is contained in:
782
.planning/phases/07-admin-forms-lists/07-03-PLAN.md
Normal file
782
.planning/phases/07-admin-forms-lists/07-03-PLAN.md
Normal file
@@ -0,0 +1,782 @@
|
||||
---
|
||||
phase: 07-admin-forms-lists
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["07-01"]
|
||||
files_modified:
|
||||
- summercms/src/admin/lists/ListColumn.scala
|
||||
- summercms/src/admin/lists/columns/TextColumn.scala
|
||||
- summercms/src/admin/lists/columns/DateColumn.scala
|
||||
- summercms/src/admin/lists/columns/RelationColumn.scala
|
||||
- summercms/src/admin/lists/columns/SwitchColumn.scala
|
||||
- summercms/src/admin/lists/Filter.scala
|
||||
- summercms/src/admin/lists/Pagination.scala
|
||||
- summercms/src/admin/lists/ListRenderer.scala
|
||||
- summercms/src/admin/lists/ColumnRegistry.scala
|
||||
autonomous: true
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "List renders as table with columns from columns.yaml"
|
||||
- "Text columns display string values correctly"
|
||||
- "Date columns format dates according to format spec"
|
||||
- "Relation columns display related model's name attribute"
|
||||
- "Switch columns display toggle for boolean values"
|
||||
- "Filters narrow displayed records based on scope definitions"
|
||||
- "Pagination shows page numbers with navigation controls"
|
||||
- "Column headers are clickable for sorting (if sortable: true)"
|
||||
- "Bulk actions toolbar appears with checkbox selection"
|
||||
artifacts:
|
||||
- path: "summercms/src/admin/lists/ListColumn.scala"
|
||||
provides: "Processed column definition with rendering helpers"
|
||||
exports: ["ListColumn", "ColumnType"]
|
||||
- path: "summercms/src/admin/lists/columns/TextColumn.scala"
|
||||
provides: "Text column value rendering"
|
||||
exports: ["TextColumnRenderer"]
|
||||
- path: "summercms/src/admin/lists/columns/DateColumn.scala"
|
||||
provides: "Date column with format support"
|
||||
exports: ["DateColumnRenderer"]
|
||||
- path: "summercms/src/admin/lists/columns/RelationColumn.scala"
|
||||
provides: "Relation column showing related model attribute"
|
||||
exports: ["RelationColumnRenderer"]
|
||||
- path: "summercms/src/admin/lists/columns/SwitchColumn.scala"
|
||||
provides: "Boolean toggle column"
|
||||
exports: ["SwitchColumnRenderer"]
|
||||
- path: "summercms/src/admin/lists/Filter.scala"
|
||||
provides: "Filter scope definitions and condition types"
|
||||
exports: ["FilterScope", "FilterCondition"]
|
||||
- path: "summercms/src/admin/lists/Pagination.scala"
|
||||
provides: "Pagination state and rendering"
|
||||
exports: ["PaginationState", "PaginationRenderer"]
|
||||
- path: "summercms/src/admin/lists/ListRenderer.scala"
|
||||
provides: "Complete list view rendering"
|
||||
exports: ["ListRenderer"]
|
||||
- path: "summercms/src/admin/lists/ColumnRegistry.scala"
|
||||
provides: "Column type to renderer mapping"
|
||||
exports: ["ColumnRegistry", "ColumnRenderer"]
|
||||
key_links:
|
||||
- from: "summercms/src/admin/lists/ListRenderer.scala"
|
||||
to: "summercms/src/admin/lists/ColumnRegistry.scala"
|
||||
via: "resolves column types for rendering"
|
||||
pattern: "columnRegistry\\.resolve"
|
||||
- from: "summercms/src/admin/lists/ListRenderer.scala"
|
||||
to: "summercms/src/admin/yaml/ColumnsYamlSchema.scala"
|
||||
via: "uses parsed column config"
|
||||
pattern: "ColumnsYamlConfig"
|
||||
- from: "summercms/src/admin/lists/ListRenderer.scala"
|
||||
to: "summercms/src/admin/lists/Pagination.scala"
|
||||
via: "renders pagination controls"
|
||||
pattern: "PaginationRenderer\\.render"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Implement list rendering with columns, filters, and pagination for admin backend.
|
||||
|
||||
Purpose: This plan creates the list view functionality that displays records in tabular format with sorting, filtering, and pagination. Lists are the primary way admins browse and manage records.
|
||||
|
||||
Output: Complete list rendering system with column types (text, date, relation, switch), filter scopes, pagination, bulk actions, and HTMX-powered sorting.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/home/jin/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@/home/jin/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/07-admin-forms-lists/07-CONTEXT.md
|
||||
@.planning/phases/07-admin-forms-lists/07-RESEARCH.md
|
||||
@.planning/phases/07-admin-forms-lists/07-01-SUMMARY.md
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Create ListColumn, ColumnRegistry, and column type renderers</name>
|
||||
<files>
|
||||
summercms/src/admin/lists/ListColumn.scala
|
||||
summercms/src/admin/lists/ColumnRegistry.scala
|
||||
summercms/src/admin/lists/columns/TextColumn.scala
|
||||
summercms/src/admin/lists/columns/DateColumn.scala
|
||||
summercms/src/admin/lists/columns/RelationColumn.scala
|
||||
summercms/src/admin/lists/columns/SwitchColumn.scala
|
||||
</files>
|
||||
<action>
|
||||
Create ListColumn.scala with processed column definition:
|
||||
```scala
|
||||
enum ColumnType:
|
||||
case Text, Date, DateTime, TimeTense, Switch, Partial, Relation, Number
|
||||
|
||||
case class ListColumn(
|
||||
name: String,
|
||||
label: String,
|
||||
columnType: ColumnType,
|
||||
sortable: Boolean = true,
|
||||
searchable: Boolean = false,
|
||||
invisible: Boolean = false,
|
||||
width: Option[String] = None,
|
||||
format: Option[String] = None, // Date format string
|
||||
relation: Option[String] = None, // Relation name for relation columns
|
||||
select: Option[String] = None, // Attribute to select from relation
|
||||
valueFrom: Option[String] = None, // Alternative value source
|
||||
path: Option[String] = None, // Path for partial columns
|
||||
cssClass: Option[String] = None
|
||||
):
|
||||
def effectiveLabel: String = label
|
||||
def isVisible: Boolean = !invisible
|
||||
|
||||
object ListColumn:
|
||||
def fromConfig(name: String, config: ListColumnConfig): ListColumn =
|
||||
val columnType = config.`type`.getOrElse("text") match
|
||||
case "date" => ColumnType.Date
|
||||
case "datetime" => ColumnType.DateTime
|
||||
case "timetense" => ColumnType.TimeTense
|
||||
case "switch" => ColumnType.Switch
|
||||
case "partial" => ColumnType.Partial
|
||||
case "relation" => ColumnType.Relation
|
||||
case "number" => ColumnType.Number
|
||||
case _ => ColumnType.Text
|
||||
|
||||
ListColumn(
|
||||
name = name,
|
||||
label = config.label,
|
||||
columnType = columnType,
|
||||
sortable = config.sortable.getOrElse(true),
|
||||
searchable = config.searchable.getOrElse(false),
|
||||
invisible = config.invisible.getOrElse(false),
|
||||
width = config.width,
|
||||
format = config.format,
|
||||
relation = config.relation,
|
||||
select = config.select,
|
||||
valueFrom = config.valueFrom,
|
||||
path = config.path,
|
||||
cssClass = config.cssClass
|
||||
)
|
||||
```
|
||||
|
||||
Create ColumnRegistry.scala similar to WidgetRegistry:
|
||||
```scala
|
||||
trait ColumnRenderer:
|
||||
def render(column: ListColumn, record: Map[String, Any]): scalatags.Text.Frag
|
||||
|
||||
trait ColumnRegistry:
|
||||
def register(typeName: ColumnType, renderer: ColumnRenderer): UIO[Unit]
|
||||
def resolve(typeName: ColumnType): UIO[ColumnRenderer]
|
||||
|
||||
object ColumnRegistry:
|
||||
val live: ZLayer[Any, Nothing, ColumnRegistry] =
|
||||
ZLayer.fromZIO {
|
||||
Ref.make(Map.empty[ColumnType, ColumnRenderer]).map { registry =>
|
||||
new ColumnRegistry:
|
||||
def register(typeName: ColumnType, renderer: ColumnRenderer): UIO[Unit] =
|
||||
registry.update(_ + (typeName -> renderer))
|
||||
|
||||
def resolve(typeName: ColumnType): UIO[ColumnRenderer] =
|
||||
registry.get.map(_.getOrElse(typeName, TextColumnRenderer))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Create TextColumn.scala:
|
||||
```scala
|
||||
object TextColumnRenderer extends ColumnRenderer:
|
||||
def render(column: ListColumn, record: Map[String, Any]): Frag =
|
||||
val value = extractValue(column, record)
|
||||
span(cls := s"column-text ${column.cssClass.getOrElse("")}",
|
||||
Option(value).map(_.toString).getOrElse("")
|
||||
)
|
||||
|
||||
private def extractValue(column: ListColumn, record: Map[String, Any]): Any =
|
||||
column.valueFrom match
|
||||
case Some(path) => getNestedValue(record, path.split('.').toList)
|
||||
case None => record.getOrElse(column.name, null)
|
||||
|
||||
private def getNestedValue(data: Map[String, Any], path: List[String]): Any =
|
||||
path match
|
||||
case Nil => null
|
||||
case head :: Nil => data.getOrElse(head, null)
|
||||
case head :: tail =>
|
||||
data.get(head) match
|
||||
case Some(nested: Map[_, _]) => getNestedValue(nested.asInstanceOf[Map[String, Any]], tail)
|
||||
case _ => null
|
||||
```
|
||||
|
||||
Create DateColumn.scala:
|
||||
```scala
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.{LocalDate, LocalDateTime, Instant, ZoneId}
|
||||
|
||||
object DateColumnRenderer extends ColumnRenderer:
|
||||
private val defaultDateFormat = "MMM d, yyyy"
|
||||
private val defaultDateTimeFormat = "MMM d, yyyy HH:mm"
|
||||
|
||||
def render(column: ListColumn, record: Map[String, Any]): Frag =
|
||||
val value = record.getOrElse(column.name, null)
|
||||
val formatted = formatDate(value, column.format, column.columnType)
|
||||
span(cls := s"column-date ${column.cssClass.getOrElse("")}", formatted)
|
||||
|
||||
private def formatDate(value: Any, format: Option[String], colType: ColumnType): String =
|
||||
if value == null then return ""
|
||||
|
||||
val formatter = DateTimeFormatter.ofPattern(
|
||||
format.getOrElse(if colType == ColumnType.DateTime then defaultDateTimeFormat else defaultDateFormat)
|
||||
)
|
||||
|
||||
value match
|
||||
case ld: LocalDate => ld.format(formatter)
|
||||
case ldt: LocalDateTime => ldt.format(formatter)
|
||||
case inst: Instant => inst.atZone(ZoneId.systemDefault()).format(formatter)
|
||||
case ts: java.sql.Timestamp => ts.toLocalDateTime.format(formatter)
|
||||
case s: String => s // Already formatted string
|
||||
case _ => value.toString
|
||||
|
||||
object TimeTenseColumnRenderer extends ColumnRenderer:
|
||||
def render(column: ListColumn, record: Map[String, Any]): Frag =
|
||||
val value = record.getOrElse(column.name, null)
|
||||
val tense = formatTimeTense(value)
|
||||
span(cls := "column-timetense", attr("title") := Option(value).map(_.toString).getOrElse(""), tense)
|
||||
|
||||
private def formatTimeTense(value: Any): String =
|
||||
// Simplified implementation - "2 hours ago", "3 days ago", etc.
|
||||
if value == null then return ""
|
||||
// ... time ago calculation
|
||||
"Recently" // Placeholder - implement proper time-ago logic
|
||||
```
|
||||
|
||||
Create RelationColumn.scala:
|
||||
```scala
|
||||
object RelationColumnRenderer extends ColumnRenderer:
|
||||
def render(column: ListColumn, record: Map[String, Any]): Frag =
|
||||
val relationData = record.get(column.relation.getOrElse(column.name))
|
||||
val displayValue = relationData match
|
||||
case Some(rel: Map[_, _]) =>
|
||||
val relMap = rel.asInstanceOf[Map[String, Any]]
|
||||
relMap.getOrElse(column.select.getOrElse("name"), "").toString
|
||||
case Some(list: List[_]) if list.nonEmpty =>
|
||||
// hasMany - show count or first few items
|
||||
list.take(3).map {
|
||||
case m: Map[_, _] => m.asInstanceOf[Map[String, Any]].getOrElse(column.select.getOrElse("name"), "")
|
||||
case v => v.toString
|
||||
}.mkString(", ") + (if list.size > 3 then s" (+${list.size - 3})" else "")
|
||||
case _ => ""
|
||||
|
||||
span(cls := s"column-relation ${column.cssClass.getOrElse("")}", displayValue)
|
||||
```
|
||||
|
||||
Create SwitchColumn.scala:
|
||||
```scala
|
||||
object SwitchColumnRenderer extends ColumnRenderer:
|
||||
def render(column: ListColumn, record: Map[String, Any]): Frag =
|
||||
val value = record.getOrElse(column.name, false)
|
||||
val isActive = value match
|
||||
case b: Boolean => b
|
||||
case n: Number => n.intValue() != 0
|
||||
case s: String => Set("true", "1", "yes", "on").contains(s.toLowerCase)
|
||||
case _ => false
|
||||
|
||||
// Read-only switch display (clickable would need HTMX for toggle)
|
||||
div(cls := s"switch-display ${if isActive then "active" else ""} ${column.cssClass.getOrElse("")}",
|
||||
span(cls := "switch-indicator")
|
||||
)
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
Run `mill summercms.compile` - should compile without errors.
|
||||
Verify each column renderer produces appropriate HTML for its type.
|
||||
</verify>
|
||||
<done>
|
||||
ListColumn case class with fromConfig conversion. ColumnRegistry with registration and resolution. Column renderers for Text, Date, DateTime, TimeTense, Relation, and Switch types.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Create Filter and Pagination components</name>
|
||||
<files>
|
||||
summercms/src/admin/lists/Filter.scala
|
||||
summercms/src/admin/lists/Pagination.scala
|
||||
</files>
|
||||
<action>
|
||||
Create Filter.scala with filter scope definitions:
|
||||
```scala
|
||||
enum FilterCondition:
|
||||
case Equals(value: String)
|
||||
case NotEquals(value: String)
|
||||
case Like(pattern: String)
|
||||
case GreaterThan(value: String)
|
||||
case LessThan(value: String)
|
||||
case Between(from: String, to: String)
|
||||
case In(values: List[String])
|
||||
case IsNull
|
||||
case IsNotNull
|
||||
|
||||
enum FilterType:
|
||||
case Dropdown // Select from predefined options
|
||||
case Switch // Boolean toggle
|
||||
case Date // Date range filter
|
||||
case DateRange // From/to date
|
||||
case Text // Free text search
|
||||
case Number // Numeric comparison
|
||||
|
||||
case class FilterScope(
|
||||
name: String,
|
||||
label: String,
|
||||
filterType: FilterType,
|
||||
conditions: Map[String, FilterCondition], // key -> condition
|
||||
default: Option[String] = None,
|
||||
options: Map[String, String] = Map.empty // For dropdown type
|
||||
)
|
||||
|
||||
object FilterScope:
|
||||
def fromConfig(name: String, config: FilterConfig): FilterScope =
|
||||
val filterType = config.`type`.getOrElse("dropdown") match
|
||||
case "switch" => FilterType.Switch
|
||||
case "date" => FilterType.Date
|
||||
case "daterange" => FilterType.DateRange
|
||||
case "text" => FilterType.Text
|
||||
case "number" => FilterType.Number
|
||||
case _ => FilterType.Dropdown
|
||||
|
||||
FilterScope(
|
||||
name = name,
|
||||
label = config.label,
|
||||
filterType = filterType,
|
||||
conditions = parseConditions(config.conditions),
|
||||
default = config.default,
|
||||
options = config.options.getOrElse(Map.empty)
|
||||
)
|
||||
|
||||
trait FilterRenderer:
|
||||
def render(filter: FilterScope, activeValue: Option[String], baseUrl: String): scalatags.Text.Frag
|
||||
|
||||
object FilterRenderer:
|
||||
def render(filter: FilterScope, activeValue: Option[String], baseUrl: String): Frag =
|
||||
filter.filterType match
|
||||
case FilterType.Dropdown => renderDropdownFilter(filter, activeValue, baseUrl)
|
||||
case FilterType.Switch => renderSwitchFilter(filter, activeValue, baseUrl)
|
||||
case FilterType.Date => renderDateFilter(filter, activeValue, baseUrl)
|
||||
case FilterType.Text => renderTextFilter(filter, activeValue, baseUrl)
|
||||
case _ => renderDropdownFilter(filter, activeValue, baseUrl)
|
||||
|
||||
private def renderDropdownFilter(filter: FilterScope, activeValue: Option[String], baseUrl: String): Frag =
|
||||
div(cls := "filter-scope filter-dropdown",
|
||||
label(filter.label),
|
||||
select(
|
||||
cls := "form-control filter-select",
|
||||
attr("hx-get") := baseUrl,
|
||||
attr("hx-trigger") := "change",
|
||||
attr("hx-target") := ".list-widget",
|
||||
attr("name") := s"filter[${filter.name}]",
|
||||
|
||||
option(attr("value") := "", "-- All --"),
|
||||
filter.options.map { case (value, label) =>
|
||||
option(
|
||||
attr("value") := value,
|
||||
if activeValue.contains(value) then Some(attr("selected") := "selected") else None,
|
||||
label
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
private def renderSwitchFilter(filter: FilterScope, activeValue: Option[String], baseUrl: String): Frag =
|
||||
div(cls := "filter-scope filter-switch",
|
||||
label(cls := "filter-switch-label",
|
||||
input(
|
||||
tpe := "checkbox",
|
||||
attr("name") := s"filter[${filter.name}]",
|
||||
attr("hx-get") := baseUrl,
|
||||
attr("hx-trigger") := "change",
|
||||
attr("hx-target") := ".list-widget",
|
||||
if activeValue.contains("1") then Some(attr("checked") := "checked") else None
|
||||
),
|
||||
span(filter.label)
|
||||
)
|
||||
)
|
||||
|
||||
private def renderDateFilter(filter: FilterScope, activeValue: Option[String], baseUrl: String): Frag =
|
||||
div(cls := "filter-scope filter-date",
|
||||
label(filter.label),
|
||||
input(
|
||||
tpe := "text",
|
||||
cls := "form-control flatpickr-input",
|
||||
attr("name") := s"filter[${filter.name}]",
|
||||
attr("value") := activeValue.getOrElse(""),
|
||||
data("flatpickr") := "true",
|
||||
attr("hx-get") := baseUrl,
|
||||
attr("hx-trigger") := "change",
|
||||
attr("hx-target") := ".list-widget"
|
||||
)
|
||||
)
|
||||
|
||||
private def renderTextFilter(filter: FilterScope, activeValue: Option[String], baseUrl: String): Frag =
|
||||
div(cls := "filter-scope filter-text",
|
||||
label(filter.label),
|
||||
input(
|
||||
tpe := "text",
|
||||
cls := "form-control",
|
||||
attr("name") := s"filter[${filter.name}]",
|
||||
attr("value") := activeValue.getOrElse(""),
|
||||
attr("hx-get") := baseUrl,
|
||||
attr("hx-trigger") := "keyup changed delay:500ms",
|
||||
attr("hx-target") := ".list-widget"
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
Create Pagination.scala:
|
||||
```scala
|
||||
case class PaginationState(
|
||||
currentPage: Int,
|
||||
totalPages: Int,
|
||||
totalRecords: Int,
|
||||
perPage: Int,
|
||||
sortColumn: Option[String] = None,
|
||||
sortDirection: SortDirection = SortDirection.Asc
|
||||
):
|
||||
def hasNextPage: Boolean = currentPage < totalPages
|
||||
def hasPreviousPage: Boolean = currentPage > 1
|
||||
def pageRange: Range =
|
||||
val start = math.max(1, currentPage - 2)
|
||||
val end = math.min(totalPages, currentPage + 2)
|
||||
start to end
|
||||
def offset: Int = (currentPage - 1) * perPage
|
||||
|
||||
enum SortDirection:
|
||||
case Asc, Desc
|
||||
def toggle: SortDirection = this match
|
||||
case Asc => Desc
|
||||
case Desc => Asc
|
||||
override def toString: String = this match
|
||||
case Asc => "asc"
|
||||
case Desc => "desc"
|
||||
|
||||
object SortDirection:
|
||||
def fromString(s: String): SortDirection = s.toLowerCase match
|
||||
case "desc" => Desc
|
||||
case _ => Asc
|
||||
|
||||
object PaginationRenderer:
|
||||
def render(state: PaginationState, baseUrl: String): Frag =
|
||||
if state.totalPages <= 1 then return frag() // No pagination needed
|
||||
|
||||
nav(cls := "pagination-nav",
|
||||
ul(cls := "pagination",
|
||||
// Previous button
|
||||
li(cls := s"page-item ${if !state.hasPreviousPage then "disabled" else ""}",
|
||||
if state.hasPreviousPage then
|
||||
a(cls := "page-link",
|
||||
attr("hx-get") := s"$baseUrl?page=${state.currentPage - 1}",
|
||||
attr("hx-target") := ".list-widget",
|
||||
"Previous"
|
||||
)
|
||||
else
|
||||
span(cls := "page-link", "Previous")
|
||||
),
|
||||
|
||||
// Page numbers
|
||||
state.pageRange.map { page =>
|
||||
li(cls := s"page-item ${if page == state.currentPage then "active" else ""}",
|
||||
if page == state.currentPage then
|
||||
span(cls := "page-link", page.toString)
|
||||
else
|
||||
a(cls := "page-link",
|
||||
attr("hx-get") := s"$baseUrl?page=$page",
|
||||
attr("hx-target") := ".list-widget",
|
||||
page.toString
|
||||
)
|
||||
)
|
||||
},
|
||||
|
||||
// Next button
|
||||
li(cls := s"page-item ${if !state.hasNextPage then "disabled" else ""}",
|
||||
if state.hasNextPage then
|
||||
a(cls := "page-link",
|
||||
attr("hx-get") := s"$baseUrl?page=${state.currentPage + 1}",
|
||||
attr("hx-target") := ".list-widget",
|
||||
"Next"
|
||||
)
|
||||
else
|
||||
span(cls := "page-link", "Next")
|
||||
)
|
||||
),
|
||||
|
||||
// Record count info
|
||||
div(cls := "pagination-info",
|
||||
s"Showing ${state.offset + 1} to ${math.min(state.offset + state.perPage, state.totalRecords)} of ${state.totalRecords} records"
|
||||
)
|
||||
)
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
Run `mill summercms.compile` - should compile without errors.
|
||||
Verify FilterRenderer produces filter controls with HTMX attributes.
|
||||
Verify PaginationRenderer produces pagination navigation.
|
||||
</verify>
|
||||
<done>
|
||||
FilterScope and FilterCondition enums defined. FilterRenderer generates filter controls (dropdown, switch, date, text) with HTMX triggers. PaginationState tracks page state. PaginationRenderer generates page navigation with HTMX.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Create ListRenderer with complete list view</name>
|
||||
<files>
|
||||
summercms/src/admin/lists/ListRenderer.scala
|
||||
</files>
|
||||
<action>
|
||||
Create ListRenderer.scala as a ZIO service for complete list view rendering:
|
||||
|
||||
```scala
|
||||
import scalatags.Text.all._
|
||||
|
||||
case class ListContext(
|
||||
baseUrl: String,
|
||||
editUrl: String => String, // Function: recordId -> edit URL
|
||||
pagination: PaginationState,
|
||||
activeFilters: Map[String, String],
|
||||
selectedIds: Set[String] = Set.empty
|
||||
)
|
||||
|
||||
trait ListRenderer:
|
||||
def render(
|
||||
config: ColumnsYamlConfig,
|
||||
records: Seq[Map[String, Any]],
|
||||
ctx: ListContext
|
||||
): Task[scalatags.Text.Frag]
|
||||
|
||||
object ListRenderer:
|
||||
val live: ZLayer[ColumnRegistry, Nothing, ListRenderer] =
|
||||
ZLayer.fromFunction { (columnRegistry: ColumnRegistry) =>
|
||||
new ListRenderer:
|
||||
def render(
|
||||
config: ColumnsYamlConfig,
|
||||
records: Seq[Map[String, Any]],
|
||||
ctx: ListContext
|
||||
): Task[Frag] = ZIO.succeed {
|
||||
val columns = config.columns.map { case (name, cfg) =>
|
||||
ListColumn.fromConfig(name, cfg)
|
||||
}.filter(_.isVisible).toSeq
|
||||
|
||||
val filters = config.filters.map { case (name, cfg) =>
|
||||
FilterScope.fromConfig(name, cfg)
|
||||
}.toSeq
|
||||
|
||||
div(cls := "list-widget",
|
||||
// Toolbar with bulk actions
|
||||
renderToolbar(ctx),
|
||||
|
||||
// Filters
|
||||
if filters.nonEmpty then renderFilters(filters, ctx) else frag(),
|
||||
|
||||
// Table
|
||||
renderTable(columns, records, ctx),
|
||||
|
||||
// Pagination
|
||||
PaginationRenderer.render(ctx.pagination, ctx.baseUrl)
|
||||
)
|
||||
}
|
||||
|
||||
private def renderToolbar(ctx: ListContext): Frag =
|
||||
div(cls := "list-toolbar",
|
||||
// Create button
|
||||
a(
|
||||
cls := "btn btn-primary",
|
||||
href := s"${ctx.baseUrl}/create",
|
||||
"Create"
|
||||
),
|
||||
|
||||
// Bulk actions (shown when items selected)
|
||||
div(cls := "bulk-actions",
|
||||
button(
|
||||
cls := "btn btn-danger",
|
||||
attr("hx-post") := s"${ctx.baseUrl}/bulk-delete",
|
||||
attr("hx-include") := "[name='checked[]']",
|
||||
attr("hx-confirm") := "Delete selected records?",
|
||||
attr("hx-target") := ".list-widget",
|
||||
"Delete Selected"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
private def renderFilters(filters: Seq[FilterScope], ctx: ListContext): Frag =
|
||||
div(cls := "list-filters",
|
||||
filters.map { filter =>
|
||||
FilterRenderer.render(filter, ctx.activeFilters.get(filter.name), ctx.baseUrl)
|
||||
}
|
||||
)
|
||||
|
||||
private def renderTable(columns: Seq[ListColumn], records: Seq[Map[String, Any]], ctx: ListContext): Frag =
|
||||
table(cls := "table list-table",
|
||||
renderTableHead(columns, ctx),
|
||||
renderTableBody(columns, records, ctx)
|
||||
)
|
||||
|
||||
private def renderTableHead(columns: Seq[ListColumn], ctx: ListContext): Frag =
|
||||
thead(
|
||||
tr(
|
||||
// Checkbox column
|
||||
th(cls := "list-checkbox",
|
||||
input(tpe := "checkbox", cls := "select-all",
|
||||
attr("data-action") := "select-all"
|
||||
)
|
||||
),
|
||||
|
||||
// Data columns with sorting
|
||||
columns.map { col =>
|
||||
val isSorted = ctx.pagination.sortColumn.contains(col.name)
|
||||
val sortDir = if isSorted then ctx.pagination.sortDirection else SortDirection.Asc
|
||||
val newDir = if isSorted then sortDir.toggle else SortDirection.Asc
|
||||
|
||||
th(
|
||||
cls := s"list-header ${if col.sortable then "sortable" else ""} ${if isSorted then s"sorted-$sortDir" else ""}",
|
||||
col.width.map(w => attr("style") := s"width: $w"),
|
||||
|
||||
if col.sortable then
|
||||
a(
|
||||
attr("hx-get") := s"${ctx.baseUrl}?sort=${col.name}&dir=$newDir",
|
||||
attr("hx-target") := ".list-widget",
|
||||
col.effectiveLabel,
|
||||
if isSorted then span(cls := s"sort-icon $sortDir") else frag()
|
||||
)
|
||||
else
|
||||
span(col.effectiveLabel)
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
private def renderTableBody(columns: Seq[ListColumn], records: Seq[Map[String, Any]], ctx: ListContext): Frag =
|
||||
tbody(
|
||||
records.map { record =>
|
||||
val recordId = record.getOrElse("id", "").toString
|
||||
val isSelected = ctx.selectedIds.contains(recordId)
|
||||
|
||||
tr(
|
||||
cls := s"list-row ${if isSelected then "selected" else ""}",
|
||||
// Row click to edit (per CONTEXT.md)
|
||||
attr("hx-get") := ctx.editUrl(recordId),
|
||||
attr("hx-target") := "#content-area",
|
||||
attr("hx-push-url") := "true",
|
||||
|
||||
// Checkbox cell
|
||||
td(cls := "list-checkbox",
|
||||
attr("hx-trigger") := "click consume", // Prevent row click
|
||||
input(
|
||||
tpe := "checkbox",
|
||||
attr("name") := "checked[]",
|
||||
attr("value") := recordId,
|
||||
if isSelected then Some(attr("checked") := "checked") else None
|
||||
)
|
||||
),
|
||||
|
||||
// Data cells
|
||||
columns.map { col =>
|
||||
td(cls := s"list-cell column-${col.columnType.toString.toLowerCase}",
|
||||
renderColumnValue(col, record)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
private def renderColumnValue(column: ListColumn, record: Map[String, Any]): Frag =
|
||||
// Would use ColumnRegistry in full implementation
|
||||
column.columnType match
|
||||
case ColumnType.Text => TextColumnRenderer.render(column, record)
|
||||
case ColumnType.Date | ColumnType.DateTime => DateColumnRenderer.render(column, record)
|
||||
case ColumnType.TimeTense => TimeTenseColumnRenderer.render(column, record)
|
||||
case ColumnType.Relation => RelationColumnRenderer.render(column, record)
|
||||
case ColumnType.Switch => SwitchColumnRenderer.render(column, record)
|
||||
case _ => TextColumnRenderer.render(column, record)
|
||||
}
|
||||
```
|
||||
|
||||
Key features implemented per CONTEXT.md:
|
||||
1. **Bulk actions toolbar** above list with Create, Delete Selected
|
||||
2. **Row click navigates** to edit screen (configurable via ctx.editUrl)
|
||||
3. **Checkbox selection** for bulk operations (hx-trigger="click consume" prevents row click)
|
||||
4. **Column sorting** via clickable headers with HTMX
|
||||
5. **Filters** render above table with HTMX-triggered updates
|
||||
6. **Pagination** with page numbers (traditional, not infinite scroll)
|
||||
7. **Column width** from YAML config or auto-size
|
||||
|
||||
Additional CSS classes for density toggle (comfortable/compact) would be applied via JavaScript toggle button that adds a class to .list-widget.
|
||||
</action>
|
||||
<verify>
|
||||
Run `mill summercms.compile` - should compile without errors.
|
||||
Verify ListRenderer produces complete list HTML with:
|
||||
- Toolbar with Create and Delete Selected buttons
|
||||
- Filter controls if filters defined
|
||||
- Table with sortable headers
|
||||
- Row checkboxes and click-to-edit behavior
|
||||
- Pagination navigation
|
||||
</verify>
|
||||
<done>
|
||||
ListRenderer generates complete admin list view with toolbar, filters, sortable table, bulk actions, row click navigation, and pagination. Uses ColumnRegistry for column value rendering.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
After completing all tasks:
|
||||
|
||||
1. **Compile check:**
|
||||
```bash
|
||||
mill summercms.compile
|
||||
```
|
||||
Must succeed with no errors.
|
||||
|
||||
2. **Column rendering verification:**
|
||||
Create test records and verify:
|
||||
- TextColumnRenderer shows string value
|
||||
- DateColumnRenderer formats date according to format string
|
||||
- RelationColumnRenderer extracts and displays related model attribute
|
||||
- SwitchColumnRenderer shows toggle in correct state
|
||||
|
||||
3. **Filter rendering verification:**
|
||||
Create FilterScope instances and verify:
|
||||
- Dropdown filter produces select with options
|
||||
- Switch filter produces checkbox
|
||||
- All filters have hx-get and hx-target attributes
|
||||
|
||||
4. **Pagination verification:**
|
||||
Create PaginationState with 10 pages, current page 5, verify:
|
||||
- Previous/Next buttons present and enabled
|
||||
- Page range shows 3-7 (current +/- 2)
|
||||
- Current page is marked active
|
||||
- All links have hx-get for HTMX
|
||||
|
||||
5. **ListRenderer integration verification:**
|
||||
Call ListRenderer.render with sample config and records, verify:
|
||||
- Output contains table with thead and tbody
|
||||
- Toolbar has Create and Delete Selected buttons
|
||||
- Headers are clickable with sort URLs
|
||||
- Rows have checkbox and hx-get for edit navigation
|
||||
- Pagination appears at bottom
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- [ ] ListColumn case class with fromConfig conversion from ListColumnConfig
|
||||
- [ ] ColumnRegistry provides column type to renderer mapping
|
||||
- [ ] TextColumnRenderer handles text/string values with nested path support
|
||||
- [ ] DateColumnRenderer formats dates with configurable format strings
|
||||
- [ ] RelationColumnRenderer extracts and displays related model attributes
|
||||
- [ ] SwitchColumnRenderer displays boolean toggle state
|
||||
- [ ] FilterScope and FilterCondition enums defined for all filter types
|
||||
- [ ] FilterRenderer generates dropdown, switch, date, text filter controls
|
||||
- [ ] All filters have HTMX attributes for live filtering
|
||||
- [ ] PaginationState tracks page, total, sort information
|
||||
- [ ] PaginationRenderer generates page navigation with HTMX
|
||||
- [ ] ListRenderer produces complete list view with toolbar, table, pagination
|
||||
- [ ] Table headers sortable via HTMX when sortable: true
|
||||
- [ ] Row checkboxes support bulk selection
|
||||
- [ ] Row click triggers edit navigation via HTMX
|
||||
- [ ] Project compiles successfully with `mill summercms.compile`
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/07-admin-forms-lists/07-03-SUMMARY.md`
|
||||
</output>
|
||||
Reference in New Issue
Block a user