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
438 lines
14 KiB
Markdown
438 lines
14 KiB
Markdown
---
|
|
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"
|
|
---
|
|
|
|
<objective>
|
|
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
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@/home/jin/.claude/get-shit-done/workflows/execute-plan.md
|
|
@/home/jin/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@.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
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto">
|
|
<name>Task 1: Dashboard Widget Base Types</name>
|
|
<files>
|
|
summercms/admin/src/dashboard/DashboardWidget.scala
|
|
summercms/admin/src/dashboard/WidgetRegistry.scala
|
|
</files>
|
|
<action>
|
|
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.
|
|
</action>
|
|
<verify>
|
|
mill summercms.admin.compile succeeds
|
|
Types DashboardWidget, WidgetRegistry, PropertyDef exist and compile
|
|
</verify>
|
|
<done>
|
|
DashboardWidget trait defines render/updates/defineProperties contract
|
|
WidgetRegistry provides register/list/resolve operations with ZIO Ref
|
|
All supporting case classes (PropertyDef, WidgetConfig, etc.) defined
|
|
</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: Dashboard Preferences Service</name>
|
|
<files>
|
|
summercms/admin/src/dashboard/DashboardPreference.scala
|
|
summercms/admin/src/dashboard/DashboardPreferenceService.scala
|
|
summercms/db/src/main/resources/db/migration/V005__dashboard_preferences.sql
|
|
</files>
|
|
<action>
|
|
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.
|
|
</action>
|
|
<verify>
|
|
mill summercms.db.flywayMigrate runs migration V005 successfully
|
|
mill summercms.admin.compile succeeds
|
|
DashboardPreferenceService compiles with Quill queries
|
|
</verify>
|
|
<done>
|
|
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
|
|
</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<verification>
|
|
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
|
|
</verification>
|
|
|
|
<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>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/08-admin-dashboard/08-01-SUMMARY.md`
|
|
</output>
|