docs(08): create phase plan

Phase 08: Admin Dashboard
- 4 plan(s) in 2 wave(s)
- 2 parallel (08-01, 08-02), 2 sequential (08-03, 08-04)
- Ready for execution
This commit is contained in:
Jakub Zych
2026-02-05 15:16:30 +01:00
parent e0acf7d93a
commit 5b0928f292
5 changed files with 2313 additions and 4 deletions

View File

@@ -0,0 +1,582 @@
---
phase: 08-admin-dashboard
plan: 04
type: execute
wave: 2
depends_on: ["08-02"]
files_modified:
- summercms/admin/src/settings/SettingsController.scala
- summercms/admin/src/settings/SettingsIndex.scala
autonomous: false
must_haves:
truths:
- "Admin can navigate to settings index and see categories"
- "Admin can open a plugin's settings page"
- "Settings form renders from YAML using Phase 7 form system"
- "Saving settings persists values to system_settings table"
artifacts:
- path: "summercms/admin/src/settings/SettingsController.scala"
provides: "HTTP routes for settings CRUD"
exports: ["SettingsController"]
- path: "summercms/admin/src/settings/SettingsIndex.scala"
provides: "Settings index page with categories"
contains: "def renderSettingsIndex"
key_links:
- from: "SettingsController"
to: "SettingsManager"
via: "looks up registered settings"
pattern: "settingsManager\\.(listItems|findItem)"
- from: "SettingsController"
to: "SettingsModelService"
via: "loads and saves settings values"
pattern: "settingsModelService\\.(get|set)"
- from: "SettingsController"
to: "FormRenderer"
via: "renders form from YAML using Phase 7 system"
pattern: "formRenderer\\.render"
---
<objective>
Create the settings controller with generic CRUD operations and settings index page. Settings pages use the Phase 7 YAML-driven form system.
Purpose: Enable plugins to have settings pages accessible from admin menu
Output: SettingsController, SettingsIndex rendering, integration with Phase 7 forms
</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/phases/08-admin-dashboard/08-RESEARCH.md
# Depends on 08-02 output
@summercms/admin/src/settings/SettingsManager.scala
@summercms/admin/src/settings/SettingsModel.scala
# Phase 7 form system (assumed available)
# @summercms/admin/src/forms/FormRenderer.scala
</context>
<tasks>
<task type="auto">
<name>Task 1: Settings Index Page</name>
<files>
summercms/admin/src/settings/SettingsIndex.scala
</files>
<action>
Create the settings index page that displays all registered settings grouped by category:
**SettingsIndex.scala** - Settings landing page:
```scala
package summercms.admin.settings
import scalatags.Text.all._
object SettingsIndex:
/** Render settings index with categories and items */
def renderSettingsIndex(
itemsByCategory: Map[String, List[SettingsItem]],
userPermissions: Set[String]
): Frag =
div(cls := "settings-index",
h2(cls := "mb-4", "System Settings"),
// Category navigation tabs
ul(cls := "nav nav-tabs mb-4", id := "settings-tabs", role := "tablist",
sortedCategories(itemsByCategory.keys).zipWithIndex.map { case (catCode, idx) =>
val category = SettingsCategory.byCode(catCode).getOrElse(
CategoryDef(catCode, catCode.capitalize, 999)
)
li(cls := "nav-item", role := "presentation",
button(
cls := s"nav-link ${if idx == 0 then "active" else ""}",
id := s"$catCode-tab",
attr("data-bs-toggle") := "tab",
attr("data-bs-target") := s"#$catCode-pane",
`type` := "button",
role := "tab",
category.label
)
)
}
),
// Category content panes
div(cls := "tab-content", id := "settings-content",
sortedCategories(itemsByCategory.keys).zipWithIndex.map { case (catCode, idx) =>
val items = itemsByCategory.getOrElse(catCode, Nil)
val filteredItems = items.filter { item =>
item.permissions.isEmpty || item.permissions.exists(userPermissions.contains)
}
div(
cls := s"tab-pane fade ${if idx == 0 then "show active" else ""}",
id := s"$catCode-pane",
role := "tabpanel",
attr("aria-labelledby") := s"$catCode-tab",
if filteredItems.isEmpty then
p(cls := "text-muted", "No settings available in this category.")
else
div(cls := "row g-3",
filteredItems.map(renderSettingsCard)
)
)
}
)
)
/** Render a single settings item card */
private def renderSettingsCard(item: SettingsItem): Frag =
div(cls := "col-md-6 col-lg-4",
div(cls := "card h-100 settings-card",
div(cls := "card-body",
div(cls := "d-flex align-items-start",
item.icon.map(iconClass =>
div(cls := "settings-icon me-3",
i(cls := s"$iconClass fs-3 text-muted")
)
).getOrElse(frag()),
div(cls := "flex-grow-1",
h5(cls := "card-title mb-1",
a(href := item.url.getOrElse("#"), item.label)
),
item.description.map(desc =>
p(cls := "card-text text-muted small mb-0", desc)
).getOrElse(frag())
)
)
),
div(cls := "card-footer bg-transparent",
a(href := item.url.getOrElse("#"), cls := "btn btn-sm btn-outline-primary",
"Configure"
)
)
)
)
/** Sort categories by their defined order */
private def sortedCategories(codes: Iterable[String]): List[String] =
codes.toList.sortBy { code =>
SettingsCategory.byCode(code).map(_.order).getOrElse(999)
}
/** Render settings form page */
def renderSettingsForm(
item: SettingsItem,
formHtml: Frag,
backUrl: String = "/admin/system/settings"
): Frag =
div(cls := "settings-form",
// Breadcrumb
nav(attr("aria-label") := "breadcrumb",
ol(cls := "breadcrumb",
li(cls := "breadcrumb-item", a(href := "/admin", "Dashboard")),
li(cls := "breadcrumb-item", a(href := backUrl, "Settings")),
li(cls := "breadcrumb-item active", attr("aria-current") := "page", item.label)
)
),
// Header
div(cls := "d-flex justify-content-between align-items-center mb-4",
div(
h2(cls := "mb-1", item.label),
item.description.map(desc =>
p(cls := "text-muted mb-0", desc)
).getOrElse(frag())
),
div(
a(href := backUrl, cls := "btn btn-secondary me-2",
i(cls := "icon-arrow-left me-1"), "Back"
)
)
),
// Form
form(
method := "POST",
attr("hx-post") := item.url.getOrElse("#"),
attr("hx-target") := "#form-container",
attr("hx-swap") := "innerHTML",
div(id := "form-container",
formHtml
),
// Actions
div(cls := "form-actions mt-4 pt-3 border-top",
button(`type` := "submit", cls := "btn btn-primary",
i(cls := "icon-check me-1"), "Save Settings"
),
button(`type` := "button", cls := "btn btn-outline-secondary ms-2",
attr("hx-post") := s"${item.url.getOrElse("#")}/reset",
attr("hx-confirm") := "Reset settings to defaults?",
i(cls := "icon-refresh me-1"), "Reset to Default"
)
)
)
)
/** Render success message after save */
def renderSaveSuccess(item: SettingsItem, formHtml: Frag): Frag =
frag(
div(cls := "alert alert-success alert-dismissible fade show", role := "alert",
"Settings saved successfully.",
button(`type` := "button", cls := "btn-close", attr("data-bs-dismiss") := "alert")
),
formHtml
)
```
The index page:
- Groups settings by category using tabs
- Sorts categories by their defined order
- Filters items by user permissions
- Each item shows as a card with icon, label, description
- Clicking "Configure" navigates to the settings form
</action>
<verify>
mill summercms.admin.compile succeeds
SettingsIndex.renderSettingsIndex compiles
Categories sorted by order when rendered
</verify>
<done>
SettingsIndex renders tabbed view of settings grouped by category
Items filtered by user permissions
Settings cards show icon, label, description, configure link
Form page has breadcrumb, header, form container, save/reset buttons
</done>
</task>
<task type="auto">
<name>Task 2: Settings Controller</name>
<files>
summercms/admin/src/settings/SettingsController.scala
</files>
<action>
Create the controller for settings CRUD operations:
**SettingsController.scala** - HTTP routes for settings:
```scala
package summercms.admin.settings
import zio._
import zio.http._
import io.circe._
import io.circe.syntax._
import scalatags.Text.all._
class SettingsController(
settingsManager: SettingsManager,
settingsModelService: SettingsModelService,
formRenderer: FormRenderer // From Phase 7
):
def routes: Routes[Any, Nothing] =
Routes(
// Settings index
Method.GET / "admin" / "system" / "settings" -> handler { (req: Request) =>
renderIndex(req)
},
// Settings update page (GET)
Method.GET / "admin" / "system" / "settings" / "update" / string("author") / string("plugin") / string("code") ->
handler { (author: String, plugin: String, code: String, req: Request) =>
showUpdateForm(author, plugin, code, req)
},
// Settings save (POST)
Method.POST / "admin" / "system" / "settings" / "update" / string("author") / string("plugin") / string("code") ->
handler { (author: String, plugin: String, code: String, req: Request) =>
saveSettings(author, plugin, code, req)
},
// Settings reset (POST)
Method.POST / "admin" / "system" / "settings" / "update" / string("author") / string("plugin") / string("code") / "reset" ->
handler { (author: String, plugin: String, code: String, req: Request) =>
resetSettings(author, plugin, code, req)
}
)
private def renderIndex(req: Request): ZIO[Any, Nothing, Response] =
(for
userPerms <- extractUserPermissions(req)
items <- settingsManager.listItems
// Filter by permissions
filteredItems = items.view.mapValues { categoryItems =>
categoryItems.filter { item =>
item.permissions.isEmpty || item.permissions.exists(userPerms.contains)
}
}.filterNot(_._2.isEmpty).toMap
html = SettingsIndex.renderSettingsIndex(filteredItems, userPerms)
yield Response.html(wrapInLayout(html, "Settings"))).catchAll { e =>
ZIO.succeed(Response.text(s"Error: ${e.getMessage}").status(Status.InternalServerError))
}
private def showUpdateForm(
author: String,
plugin: String,
code: String,
req: Request
): ZIO[Any, Nothing, Response] =
val owner = s"$author.$plugin"
(for
userPerms <- extractUserPermissions(req)
item <- settingsManager.findItem(owner, code)
.someOrFail(new RuntimeException(s"Settings not found: $owner.$code"))
// Check permissions
_ <- ZIO.when(item.permissions.nonEmpty && !item.permissions.exists(userPerms.contains)) {
ZIO.fail(new RuntimeException("Access denied"))
}
// Set context for settings manager
_ <- settingsManager.setContext(owner, code)
// Load settings model
model <- loadSettingsModel(item)
// Get current values
values <- settingsModelService.get(model)
// Parse YAML fields definition and render form
fieldsYaml <- loadFieldsYaml(model.settingsFields)
formHtml <- formRenderer.render(fieldsYaml, jsonMapToFormValues(values))
html = SettingsIndex.renderSettingsForm(item, formHtml)
yield Response.html(wrapInLayout(html, item.label))).catchAll { e =>
ZIO.succeed(Response.text(s"Error: ${e.getMessage}").status(Status.InternalServerError))
}
private def saveSettings(
author: String,
plugin: String,
code: String,
req: Request
): ZIO[Any, Nothing, Response] =
val owner = s"$author.$plugin"
(for
userPerms <- extractUserPermissions(req)
item <- settingsManager.findItem(owner, code)
.someOrFail(new RuntimeException(s"Settings not found: $owner.$code"))
// Check permissions
_ <- ZIO.when(item.permissions.nonEmpty && !item.permissions.exists(userPerms.contains)) {
ZIO.fail(new RuntimeException("Access denied"))
}
// Load model
model <- loadSettingsModel(item)
// Parse form data
body <- req.body.asString
formData = parseFormData(body)
// Convert to JSON values
jsonValues = formData.view.mapValues(stringToJson).toMap
// Save settings
_ <- settingsModelService.set(model, jsonValues)
// Re-render form with success message
fieldsYaml <- loadFieldsYaml(model.settingsFields)
currentValues <- settingsModelService.get(model)
formHtml <- formRenderer.render(fieldsYaml, jsonMapToFormValues(currentValues))
// Return updated form with success message (HTMX swap)
html = SettingsIndex.renderSaveSuccess(item, formHtml)
yield Response.html(html.render)).catchAll { e =>
ZIO.succeed(
Response.html(
div(cls := "alert alert-danger", s"Error saving settings: ${e.getMessage}").render
).status(Status.BadRequest)
)
}
private def resetSettings(
author: String,
plugin: String,
code: String,
req: Request
): ZIO[Any, Nothing, Response] =
val owner = s"$author.$plugin"
(for
item <- settingsManager.findItem(owner, code)
.someOrFail(new RuntimeException(s"Settings not found: $owner.$code"))
model <- loadSettingsModel(item)
_ <- settingsModelService.resetDefault(model)
yield Response.redirect(URL.decode(item.url.getOrElse("/admin/system/settings")).toOption.get)
).catchAll { e =>
ZIO.succeed(Response.text(s"Error: ${e.getMessage}").status(Status.InternalServerError))
}
// Helper methods
private def extractUserPermissions(req: Request): Task[Set[String]] =
// TODO: Extract from session/JWT via Phase 6 auth
ZIO.succeed(Set("system.access_settings", "system.manage_settings"))
private def loadSettingsModel(item: SettingsItem): Task[SettingsModel] =
item.settingsClass match
case Some(className) =>
ZIO.attempt {
val clazz = Class.forName(className)
clazz.getDeclaredConstructor().newInstance().asInstanceOf[SettingsModel]
}.mapError(e => new RuntimeException(s"Failed to load settings model: $className", e))
case None =>
// Create anonymous settings model from item
ZIO.succeed(new SettingsModel {
val settingsCode = s"${item.owner}_${item.code}".replace(".", "_").toLowerCase
val settingsFields = s"$$/plugins/${item.owner.replace(".", "/")}/${item.code}/fields.yaml"
})
private def loadFieldsYaml(path: String): Task[FormDefinition] =
// TODO: Use actual YAML loading from Phase 7
// For now, return minimal form
ZIO.succeed(FormDefinition(
fields = Map(
"placeholder" -> FormField(
label = Some("Settings"),
`type` = Some("text"),
comment = Some(s"Fields loaded from: $path")
)
)
))
private def parseFormData(body: String): Map[String, String] =
body.split("&").flatMap { pair =>
pair.split("=", 2) match
case Array(k, v) =>
Some(java.net.URLDecoder.decode(k, "UTF-8") -> java.net.URLDecoder.decode(v, "UTF-8"))
case _ => None
}.toMap
private def stringToJson(s: String): Json =
// Try to parse as number or boolean, fall back to string
s.toBooleanOption.map(Json.fromBoolean)
.orElse(s.toIntOption.map(Json.fromInt))
.orElse(s.toDoubleOption.map(Json.fromDouble).flatten)
.getOrElse(Json.fromString(s))
private def jsonMapToFormValues(m: Map[String, Json]): Map[String, Any] =
m.view.mapValues { json =>
json.fold(
jsonNull = "",
jsonBoolean = _.toString,
jsonNumber = _.toString,
jsonString = identity,
jsonArray = _.map(_.noSpaces).mkString(","),
jsonObject = _.noSpaces
)
}.toMap
private def wrapInLayout(content: Frag, title: String): String =
// TODO: Use admin layout from Phase 7
s"""<!DOCTYPE html>
<html>
<head>
<title>$title - SummerCMS</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div class="container-fluid py-4">
${content.render}
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://unpkg.com/htmx.org@2.0.0"></script>
</body>
</html>"""
// Temporary placeholder for Phase 7 form types
case class FormDefinition(fields: Map[String, FormField])
case class FormField(
label: Option[String] = None,
`type`: Option[String] = Some("text"),
comment: Option[String] = None
)
// Temporary placeholder for Phase 7 form renderer
trait FormRenderer:
def render(definition: FormDefinition, values: Map[String, Any]): Task[Frag]
```
Note: This controller has placeholder code for Phase 7 form rendering (`FormRenderer`, `FormDefinition`). The actual integration will use the real types from Phase 7 when both phases are complete.
**Integration points to wire up:**
1. Replace placeholder `FormRenderer` with actual Phase 7 `FormRenderer`
2. Replace placeholder `FormDefinition`/`FormField` with Phase 7 YAML types
3. Replace `loadFieldsYaml` with actual YAML parsing from Phase 7
4. Replace `extractUserPermissions` with Phase 6 auth integration
</action>
<verify>
mill summercms.admin.compile succeeds
SettingsController routes compile
GET /admin/system/settings returns index page
GET /admin/system/settings/update/author/plugin/code returns form
</verify>
<done>
SettingsController provides index, update (GET/POST), reset routes
Index filters items by user permissions
Form page loads settings model and renders form
Save parses form data and persists to system_settings
Reset deletes settings and redirects
Placeholder form types ready for Phase 7 integration
</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<what-built>
Settings management system with:
- Settings index page with tabbed categories
- Plugin settings registration and lookup
- Settings form page using YAML-driven forms (placeholder for Phase 7)
- Settings persistence to system_settings table
- Reset to defaults functionality
</what-built>
<how-to-verify>
1. Start the application: `mill summercms.run`
2. Navigate to http://localhost:8080/admin/system/settings
3. Verify: Settings index page shows with category tabs
4. Register a test settings item (manually add to SettingsManager in code)
5. Verify: Test settings item appears in appropriate category
6. Click "Configure" on settings item
7. Verify: Settings form page loads
8. Save the form
9. Verify: Success message displays, values persisted
10. Click "Reset to Default"
11. Verify: Settings reset, redirects to index
</how-to-verify>
<resume-signal>Type "approved" or describe issues found</resume-signal>
</task>
</tasks>
<verification>
1. `mill summercms.admin.compile` succeeds
2. Settings index page renders with category tabs
3. Settings form page loads for registered items
4. Form submission saves to system_settings table
5. Reset clears settings and returns to index
6. Permission filtering works on index page
</verification>
<success_criteria>
- Settings index displays all registered settings grouped by category
- Category tabs sorted by defined order
- Settings filtered by user permissions
- Form page renders with breadcrumb and save/reset actions
- Form submission persists values via SettingsModelService
- Reset clears settings from database
- Ready for integration with Phase 7 form system
</success_criteria>
<output>
After completion, create `.planning/phases/08-admin-dashboard/08-04-SUMMARY.md`
</output>