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 | 01 | execute | 1 |
|
true |
|
Purpose: Enable plugins to register dashboard widgets and persist user customizations Output: DashboardWidget trait, WidgetRegistry, DashboardPreferenceService, 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 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:
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:
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:
-- 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:
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:
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<success_criteria>
- 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 </success_criteria>