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
14 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 | 02 | execute | 1 |
|
true |
|
Purpose: Enable plugins to define settings pages using the same YAML-driven form system from Phase 7 Output: SettingsManager, SettingsCategory, SettingsModel, migration
<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.mdPhase 1 Quill patterns
@summercms/db/src/main/scala/summercms/db/QuillContext.scala
Task 1: Settings Manager and Categories summercms/admin/src/settings/SettingsManager.scala summercms/admin/src/settings/categories.scala Create the settings registration system:categories.scala - Standard settings categories with sort order:
package summercms.admin.settings
/** Standard settings categories following WinterCMS conventions */
object SettingsCategory:
val System = CategoryDef("system", "System", 100)
val CMS = CategoryDef("cms", "CMS", 200)
val Users = CategoryDef("users", "Users", 300)
val Mail = CategoryDef("mail", "Mail", 400)
val Misc = CategoryDef("misc", "Miscellaneous", 900)
val all: List[CategoryDef] = List(System, CMS, Users, Mail, Misc)
def byCode(code: String): Option[CategoryDef] =
all.find(_.code == code)
case class CategoryDef(
code: String,
label: String,
order: Int
)
SettingsManager.scala - Registry for plugin settings:
package summercms.admin.settings
import zio._
/** A registered settings page */
case class SettingsItem(
code: String,
owner: String, // Plugin ID: "golem15.blog"
label: String, // Display name or translation key
description: Option[String] = None,
category: String, // Category code (system, cms, users, etc.)
icon: Option[String] = None,
url: Option[String] = None, // Custom URL or auto-generated
settingsClass: Option[String] = None, // Settings model class name
permissions: List[String] = Nil,
order: Int = 500,
keywords: Option[String] = None // For search
)
/** Context tracking current settings page being edited */
case class SettingsContext(
owner: String,
code: String
)
/** Registry for plugin settings pages */
trait SettingsManager:
/** Register settings items from a plugin */
def registerSettings(owner: String, items: List[SettingsItem]): UIO[Unit]
/** List all items grouped by category */
def listItems: UIO[Map[String, List[SettingsItem]]]
/** Find a specific settings item */
def findItem(owner: String, code: String): UIO[Option[SettingsItem]]
/** Set current context for settings operations */
def setContext(owner: String, code: String): UIO[Unit]
/** Get current context */
def getContext: UIO[Option[SettingsContext]]
object SettingsManager:
val live: ZLayer[Any, Nothing, SettingsManager] =
ZLayer.fromZIO {
for
itemsRef <- Ref.make(Map.empty[String, SettingsItem])
contextRef <- Ref.make(Option.empty[SettingsContext])
yield new SettingsManager:
def registerSettings(owner: String, items: List[SettingsItem]): UIO[Unit] =
itemsRef.update { current =>
items.foldLeft(current) { case (acc, item) =>
val key = s"${owner.toUpperCase}.${item.code.toUpperCase}"
val itemWithOwner = item.copy(
owner = owner,
url = item.url.orElse(Some(generateSettingsUrl(owner, item.code)))
)
acc + (key -> itemWithOwner)
}
}
def listItems: UIO[Map[String, List[SettingsItem]]] =
itemsRef.get.map { items =>
items.values.toList
.groupBy(_.category)
.view
.mapValues(_.sortBy(_.order))
.toMap
}
def findItem(owner: String, code: String): UIO[Option[SettingsItem]] =
itemsRef.get.map(_.get(s"${owner.toUpperCase}.${code.toUpperCase}"))
def setContext(owner: String, code: String): UIO[Unit] =
contextRef.set(Some(SettingsContext(owner.toLowerCase, code.toLowerCase)))
def getContext: UIO[Option[SettingsContext]] =
contextRef.get
private def generateSettingsUrl(owner: String, code: String): String =
val parts = owner.split('.')
s"/admin/system/settings/update/${parts.mkString("/")}/$code"
}
// Accessor methods
def registerSettings(owner: String, items: List[SettingsItem]): ZIO[SettingsManager, Nothing, Unit] =
ZIO.serviceWithZIO[SettingsManager](_.registerSettings(owner, items))
def listItems: ZIO[SettingsManager, Nothing, Map[String, List[SettingsItem]]] =
ZIO.serviceWithZIO[SettingsManager](_.listItems)
def findItem(owner: String, code: String): ZIO[SettingsManager, Nothing, Option[SettingsItem]] =
ZIO.serviceWithZIO[SettingsManager](_.findItem(owner, code))
Categories follow WinterCMS pattern. Plugins can use existing categories or define custom ones (just use any string code). mill summercms.admin.compile succeeds SettingsCategory.all contains System, CMS, Users, Mail, Misc SettingsManager.live layer constructs SettingsCategory defines standard categories with order SettingsItem captures all metadata for a settings page SettingsManager provides registration and lookup with ZIO Ref Auto-generated URLs follow /admin/system/settings/update/{author}/{plugin}/{code} pattern
Task 2: Settings Model Behavior summercms/admin/src/settings/SettingsModel.scala summercms/db/src/main/resources/db/migration/V006__system_settings.sql Create the settings persistence layer:V006__system_settings.sql - Migration for settings storage:
-- System settings storage (key-value with JSON)
CREATE TABLE system_settings (
id BIGSERIAL PRIMARY KEY,
item VARCHAR(255) NOT NULL UNIQUE,
value JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_system_settings_item ON system_settings(item);
-- Trigger to update updated_at
CREATE TRIGGER update_system_settings_updated_at
BEFORE UPDATE ON system_settings
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
SettingsModel.scala - Trait for settings persistence:
package summercms.admin.settings
import io.circe._
import io.circe.syntax._
import zio._
import summercms.db.QuillContext
import java.time.OffsetDateTime
/** Database row for system_settings */
case class SystemSetting(
id: Long,
item: String,
value: Json,
createdAt: OffsetDateTime,
updatedAt: OffsetDateTime
)
/** Base trait for plugin settings models */
trait SettingsModel:
/** Unique code identifying this settings group */
def settingsCode: String
/** Path to fields.yaml defining the form */
def settingsFields: String
/** Initialize default values (override in subclass) */
def initSettingsData(): Map[String, Json] = Map.empty
/** Service for settings model operations */
trait SettingsModelService:
/** Get settings values, loading from DB or defaults */
def get(model: SettingsModel): Task[Map[String, Json]]
/** Get a single setting value */
def getValue[T: Decoder](model: SettingsModel, key: String, default: T): Task[T]
/** Set settings values */
def set(model: SettingsModel, values: Map[String, Json]): Task[Unit]
/** Reset to defaults by deleting from DB */
def resetDefault(model: SettingsModel): Task[Unit]
object SettingsModelService:
val live: ZLayer[QuillContext, Nothing, SettingsModelService] =
ZLayer.fromZIO {
ZIO.service[QuillContext].map { quill =>
import quill.ctx._
// Cache for loaded settings
val cache = new java.util.concurrent.ConcurrentHashMap[String, Map[String, Json]]()
new SettingsModelService:
def get(model: SettingsModel): Task[Map[String, Json]] =
// Check cache first
Option(cache.get(model.settingsCode)) match
case Some(values) => ZIO.succeed(values)
case None =>
for
rows <- run(
query[SystemSetting]
.filter(_.item == lift(model.settingsCode))
)
values <- rows.headOption match
case Some(row) =>
ZIO.fromEither(row.value.as[Map[String, Json]])
.mapError(e => new RuntimeException(s"Failed to decode settings: ${e.getMessage}"))
case None =>
ZIO.succeed(model.initSettingsData())
_ <- ZIO.succeed(cache.put(model.settingsCode, values))
yield values
def getValue[T: Decoder](model: SettingsModel, key: String, default: T): Task[T] =
get(model).map { values =>
values.get(key)
.flatMap(_.as[T].toOption)
.getOrElse(default)
}
def set(model: SettingsModel, values: Map[String, Json]): Task[Unit] =
val valuesJson = values.asJson
val now = OffsetDateTime.now()
for
existing <- run(
query[SystemSetting]
.filter(_.item == lift(model.settingsCode))
)
_ <- existing.headOption match
case Some(row) =>
run(
query[SystemSetting]
.filter(_.id == lift(row.id))
.update(
_.value -> lift(valuesJson),
_.updatedAt -> lift(now)
)
).unit
case None =>
run(
query[SystemSetting]
.insert(
_.item -> lift(model.settingsCode),
_.value -> lift(valuesJson),
_.createdAt -> lift(now),
_.updatedAt -> lift(now)
)
).unit
// Invalidate cache
_ <- ZIO.succeed(cache.remove(model.settingsCode))
yield ()
def resetDefault(model: SettingsModel): Task[Unit] =
for
_ <- run(
query[SystemSetting]
.filter(_.item == lift(model.settingsCode))
.delete
)
_ <- ZIO.succeed(cache.remove(model.settingsCode))
yield ()
}
}
// Accessor methods
def get(model: SettingsModel): ZIO[SettingsModelService, Throwable, Map[String, Json]] =
ZIO.serviceWithZIO[SettingsModelService](_.get(model))
def set(model: SettingsModel, values: Map[String, Json]): ZIO[SettingsModelService, Throwable, Unit] =
ZIO.serviceWithZIO[SettingsModelService](_.set(model, values))
The SettingsModelService provides caching to avoid repeated DB hits. Cache is invalidated on write. Plugin settings models implement SettingsModel trait with their settingsCode and fields path.
Example usage by a plugin:
// In a plugin
object BlogSettings extends SettingsModel:
val settingsCode = "golem15_blog_settings"
val settingsFields = "$/plugins/golem15/blog/models/settings/fields.yaml"
override def initSettingsData(): Map[String, Json] = Map(
"posts_per_page" -> Json.fromInt(10),
"show_author" -> Json.fromBoolean(true)
)
<success_criteria>
- SettingsCategory provides standard categories (System, CMS, Users, Mail, Misc)
- SettingsManager registers and groups settings by category
- SettingsModel trait established for plugin settings
- SettingsModelService persists to system_settings table with caching
- Migration creates system_settings with JSONB support </success_criteria>