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
20 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 08-admin-dashboard | 04 | execute | 2 |
|
|
false |
|
Purpose: Enable plugins to have settings pages accessible from admin menu Output: SettingsController, SettingsIndex rendering, integration with Phase 7 forms
<execution_context> @/home/jin/.claude/get-shit-done/workflows/execute-plan.md @/home/jin/.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/phases/08-admin-dashboard/08-RESEARCH.mdDepends 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
Task 1: Settings Index Page summercms/admin/src/settings/SettingsIndex.scala Create the settings index page that displays all registered settings grouped by category:SettingsIndex.scala - Settings landing page:
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 mill summercms.admin.compile succeeds SettingsIndex.renderSettingsIndex compiles Categories sorted by order when rendered 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
SettingsController.scala - HTTP routes for settings:
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:
- Replace placeholder
FormRendererwith actual Phase 7FormRenderer - Replace placeholder
FormDefinition/FormFieldwith Phase 7 YAML types - Replace
loadFieldsYamlwith actual YAML parsing from Phase 7 - Replace
extractUserPermissionswith Phase 6 auth integration 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 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
<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>