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
583 lines
20 KiB
Markdown
583 lines
20 KiB
Markdown
---
|
|
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>
|