---
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"
---
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
@/home/jin/.claude/get-shit-done/workflows/execute-plan.md
@/home/jin/.claude/get-shit-done/templates/summary.md
@.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
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:
```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
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
Task 2: Settings Controller
summercms/admin/src/settings/SettingsController.scala
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"""
$title - SummerCMS
${content.render}
"""
// 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
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
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
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
Type "approved" or describe issues found
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
- 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