--- 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 After completion, create `.planning/phases/08-admin-dashboard/08-04-SUMMARY.md`