---
phase: 08-admin-dashboard
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- summercms/admin/src/dashboard/DashboardWidget.scala
- summercms/admin/src/dashboard/WidgetRegistry.scala
- summercms/admin/src/dashboard/DashboardPreference.scala
- summercms/admin/src/dashboard/DashboardPreferenceService.scala
- summercms/db/src/main/resources/db/migration/V005__dashboard_preferences.sql
autonomous: true
must_haves:
truths:
- "Widget classes can be registered with metadata during plugin boot"
- "Dashboard layouts are stored per-user in database"
- "Default widget layout loads when user has no preferences"
artifacts:
- path: "summercms/admin/src/dashboard/DashboardWidget.scala"
provides: "Base trait and types for dashboard widgets"
contains: "trait DashboardWidget"
- path: "summercms/admin/src/dashboard/WidgetRegistry.scala"
provides: "Widget registration and lookup service"
exports: ["WidgetRegistry"]
- path: "summercms/admin/src/dashboard/DashboardPreferenceService.scala"
provides: "Per-user layout persistence"
exports: ["DashboardPreferenceService"]
- path: "summercms/db/src/main/resources/db/migration/V005__dashboard_preferences.sql"
provides: "Dashboard preferences table"
contains: "CREATE TABLE dashboard_preferences"
key_links:
- from: "WidgetRegistry"
to: "DashboardWidget"
via: "registerWidget method accepts widget class"
pattern: "def registerWidget.*DashboardWidget"
- from: "DashboardPreferenceService"
to: "Quill"
via: "database queries"
pattern: "run\\(query"
---
Create the foundation for dashboard widgets: the base trait defining widget contracts, a registry service for collecting widgets from plugins, and a per-user preferences service for storing dashboard layouts.
Purpose: Enable plugins to register dashboard widgets and persist user customizations
Output: DashboardWidget trait, WidgetRegistry, DashboardPreferenceService, migration
@/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
# Phase 1 established Quill patterns
@summercms/db/src/main/scala/summercms/db/QuillContext.scala
Task 1: Dashboard Widget Base Types
summercms/admin/src/dashboard/DashboardWidget.scala
summercms/admin/src/dashboard/WidgetRegistry.scala
Create the dashboard widget system foundation:
**DashboardWidget.scala** - Define the base trait and supporting types:
```scala
package summercms.admin.dashboard
import scalatags.Text.all._
import zio._
import zio.stream._
/** Property type for widget configuration */
enum PropertyType:
case String, Number, Checkbox, Dropdown, DateRange
/** Definition of a configurable widget property */
case class PropertyDef(
title: String,
`type`: PropertyType,
description: Option[String] = None,
default: Option[String] = None,
options: Map[String, String] = Map.empty,
required: Boolean = false
)
/** Position and size of widget in Gridstack grid */
case class WidgetPosition(
x: Int,
y: Int,
w: Int,
h: Int
)
/** Runtime configuration for a widget instance */
case class WidgetConfig(
alias: String,
properties: Map[String, Any],
position: WidgetPosition
)
/** Update message for real-time widget refresh */
case class WidgetUpdate(
widgetAlias: String,
content: Frag
)
/** Context passed to widget render method */
case class DashboardContext(
userId: Long,
userName: String,
permissions: Set[String]
)
/** Base trait for all dashboard widgets */
trait DashboardWidget:
/** Unique widget identifier (class name or custom) */
def widgetId: String
/** Default alias when adding widget */
def defaultAlias: String = widgetId
/** Widget display name (translation key) */
def name: String
/** Widget description (translation key) */
def description: String
/** Define configurable properties with defaults */
def defineProperties: Map[String, PropertyDef] = Map(
"title" -> PropertyDef(
title = "Widget Title",
`type` = PropertyType.String,
default = Some(name)
),
"ocWidgetWidth" -> PropertyDef(
title = "Widget Width (columns)",
`type` = PropertyType.Dropdown,
options = (1 to 12).map(i => i.toString -> s"$i columns").toMap,
default = Some("6")
)
)
/** Default grid position and size */
def defaultPosition: WidgetPosition = WidgetPosition(0, 0, 6, 2)
/** Render widget content */
def render(config: WidgetConfig, ctx: DashboardContext): Task[Frag]
/** Optional stream of updates for real-time widgets */
def updates(config: WidgetConfig): Option[ZStream[Any, Nothing, WidgetUpdate]] = None
```
**WidgetRegistry.scala** - Service for collecting and resolving widgets:
```scala
package summercms.admin.dashboard
import zio._
/** Metadata about a registered widget */
case class WidgetInfo(
name: String,
description: String,
context: List[String] = List("dashboard"),
permissions: List[String] = Nil
)
/** Registry for dashboard widgets */
trait WidgetRegistry:
def registerWidget(widget: DashboardWidget, info: WidgetInfo): UIO[Unit]
def listWidgets: UIO[Map[String, (WidgetInfo, DashboardWidget)]]
def listWidgetsForContext(context: String): UIO[Map[String, (WidgetInfo, DashboardWidget)]]
def resolveWidget(widgetId: String): UIO[Option[DashboardWidget]]
object WidgetRegistry:
val live: ZLayer[Any, Nothing, WidgetRegistry] =
ZLayer.fromZIO {
Ref.make(Map.empty[String, (WidgetInfo, DashboardWidget)]).map { widgetsRef =>
new WidgetRegistry:
def registerWidget(widget: DashboardWidget, info: WidgetInfo): UIO[Unit] =
widgetsRef.update(_ + (widget.widgetId -> (info, widget)))
def listWidgets: UIO[Map[String, (WidgetInfo, DashboardWidget)]] =
widgetsRef.get
def listWidgetsForContext(context: String): UIO[Map[String, (WidgetInfo, DashboardWidget)]] =
listWidgets.map(_.filter(_._2._1.context.contains(context)))
def resolveWidget(widgetId: String): UIO[Option[DashboardWidget]] =
widgetsRef.get.map(_.get(widgetId).map(_._2))
}
}
def registerWidget(widget: DashboardWidget, info: WidgetInfo): ZIO[WidgetRegistry, Nothing, Unit] =
ZIO.serviceWithZIO[WidgetRegistry](_.registerWidget(widget, info))
def listWidgets: ZIO[WidgetRegistry, Nothing, Map[String, (WidgetInfo, DashboardWidget)]] =
ZIO.serviceWithZIO[WidgetRegistry](_.listWidgets)
def resolveWidget(widgetId: String): ZIO[WidgetRegistry, Nothing, Option[DashboardWidget]] =
ZIO.serviceWithZIO[WidgetRegistry](_.resolveWidget(widgetId))
```
Use ZIO Ref for thread-safe mutable state. Widget IDs are the key for lookup.
mill summercms.admin.compile succeeds
Types DashboardWidget, WidgetRegistry, PropertyDef exist and compile
DashboardWidget trait defines render/updates/defineProperties contract
WidgetRegistry provides register/list/resolve operations with ZIO Ref
All supporting case classes (PropertyDef, WidgetConfig, etc.) defined
Task 2: Dashboard Preferences Service
summercms/admin/src/dashboard/DashboardPreference.scala
summercms/admin/src/dashboard/DashboardPreferenceService.scala
summercms/db/src/main/resources/db/migration/V005__dashboard_preferences.sql
Create the preferences persistence layer for per-user dashboard layouts:
**V005__dashboard_preferences.sql** - Migration for preferences table:
```sql
-- Dashboard preferences per user
CREATE TABLE dashboard_preferences (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
context VARCHAR(50) NOT NULL DEFAULT 'dashboard',
widgets 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(),
CONSTRAINT uq_dashboard_prefs_user_context UNIQUE (user_id, context)
);
CREATE INDEX idx_dashboard_prefs_user ON dashboard_preferences(user_id);
-- Trigger to update updated_at
CREATE TRIGGER update_dashboard_prefs_updated_at
BEFORE UPDATE ON dashboard_preferences
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
```
**DashboardPreference.scala** - Model and stored config types:
```scala
package summercms.admin.dashboard
import io.circe._
import io.circe.generic.semiauto._
import java.time.OffsetDateTime
/** Stored widget configuration in preferences JSON */
case class StoredWidgetConfig(
widgetId: String,
alias: String,
sortOrder: Int,
configuration: Map[String, Json] // Using Json for flexibility
)
object StoredWidgetConfig:
given Encoder[StoredWidgetConfig] = deriveEncoder
given Decoder[StoredWidgetConfig] = deriveDecoder
/** Database row for dashboard_preferences */
case class DashboardPreference(
id: Long,
userId: Long,
context: String,
widgets: Json, // JSONB stored as circe Json
createdAt: OffsetDateTime,
updatedAt: OffsetDateTime
)
```
**DashboardPreferenceService.scala** - Service for CRUD operations:
```scala
package summercms.admin.dashboard
import io.circe._
import io.circe.syntax._
import io.circe.parser._
import zio._
import summercms.db.QuillContext
trait DashboardPreferenceService:
def getPreferences(userId: Long, context: String): Task[List[StoredWidgetConfig]]
def setPreferences(userId: Long, context: String, widgets: List[StoredWidgetConfig]): Task[Unit]
def resetToDefault(userId: Long, context: String): Task[Unit]
def getDefaultLayout(context: String): Task[List[StoredWidgetConfig]]
object DashboardPreferenceService:
val live: ZLayer[QuillContext, Nothing, DashboardPreferenceService] =
ZLayer.fromZIO {
ZIO.service[QuillContext].map { quill =>
import quill.ctx._
new DashboardPreferenceService:
def getPreferences(userId: Long, context: String): Task[List[StoredWidgetConfig]] =
for
prefs <- run(
query[DashboardPreference]
.filter(p => p.userId == lift(userId) && p.context == lift(context))
)
result <- prefs.headOption match
case Some(pref) => ZIO.fromEither(
pref.widgets.as[List[StoredWidgetConfig]]
).orElse(getDefaultLayout(context))
case None => getDefaultLayout(context)
yield result
def setPreferences(userId: Long, context: String, widgets: List[StoredWidgetConfig]): Task[Unit] =
val widgetsJson = widgets.asJson
val now = java.time.OffsetDateTime.now()
for
existing <- run(
query[DashboardPreference]
.filter(p => p.userId == lift(userId) && p.context == lift(context))
)
_ <- existing.headOption match
case Some(pref) =>
run(
query[DashboardPreference]
.filter(_.id == lift(pref.id))
.update(
_.widgets -> lift(widgetsJson),
_.updatedAt -> lift(now)
)
).unit
case None =>
run(
query[DashboardPreference]
.insert(
_.userId -> lift(userId),
_.context -> lift(context),
_.widgets -> lift(widgetsJson),
_.createdAt -> lift(now),
_.updatedAt -> lift(now)
)
).unit
yield ()
def resetToDefault(userId: Long, context: String): Task[Unit] =
run(
query[DashboardPreference]
.filter(p => p.userId == lift(userId) && p.context == lift(context))
.delete
).unit
def getDefaultLayout(context: String): Task[List[StoredWidgetConfig]] =
// Default layout - will be populated when core widgets registered
ZIO.succeed(List(
StoredWidgetConfig(
widgetId = "welcome",
alias = "welcome_default",
sortOrder = 10,
configuration = Map(
"x" -> Json.fromInt(0),
"y" -> Json.fromInt(0),
"w" -> Json.fromInt(7),
"h" -> Json.fromInt(3)
)
),
StoredWidgetConfig(
widgetId = "system-status",
alias = "status_default",
sortOrder = 20,
configuration = Map(
"x" -> Json.fromInt(7),
"y" -> Json.fromInt(0),
"w" -> Json.fromInt(5),
"h" -> Json.fromInt(3)
)
)
))
}
}
```
Ensure Quill can handle JSONB columns - may need to add circe-json encoder/decoder for Quill if not already present. Check existing QuillContext for custom encoders.
mill summercms.db.flywayMigrate runs migration V005 successfully
mill summercms.admin.compile succeeds
DashboardPreferenceService compiles with Quill queries
Migration V005 creates dashboard_preferences table with JSONB widgets column
DashboardPreference model maps to table
StoredWidgetConfig has circe codecs for JSON serialization
DashboardPreferenceService provides get/set/reset operations
Default layout returns welcome + system-status widgets
1. `mill summercms.admin.compile` succeeds
2. `mill summercms.db.flywayMigrate` applies V005 migration
3. Database has dashboard_preferences table with correct schema
4. All types in DashboardWidget.scala compile
5. WidgetRegistry.live layer constructs successfully
- DashboardWidget trait established with render/updates/defineProperties
- WidgetRegistry collects and resolves widgets by ID
- DashboardPreferenceService persists per-user layouts to PostgreSQL
- Migration creates dashboard_preferences table with JSONB support
- Default layout defined for fresh installations