Files
summercms-initial-research/.planning/phases/08-admin-dashboard/08-04-PLAN.md
Jakub Zych 5b0928f292 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
2026-02-05 15:16:30 +01:00

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
08-02
summercms/admin/src/settings/SettingsController.scala
summercms/admin/src/settings/SettingsIndex.scala
false
truths artifacts key_links
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
path provides exports
summercms/admin/src/settings/SettingsController.scala HTTP routes for settings CRUD
SettingsController
path provides contains
summercms/admin/src/settings/SettingsIndex.scala Settings index page with categories def renderSettingsIndex
from to via pattern
SettingsController SettingsManager looks up registered settings settingsManager.(listItems|findItem)
from to via pattern
SettingsController SettingsModelService loads and saves settings values settingsModelService.(get|set)
from to via pattern
SettingsController FormRenderer renders form from YAML using Phase 7 system 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

<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.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:

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:

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 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

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