Files
summercms-initial-research/.planning/phases/08-admin-dashboard/08-RESEARCH.md
Jakub Zych e0acf7d93a docs(08): research admin dashboard domain
Phase 8: Admin Dashboard
- Standard stack: Gridstack.js for layout, ZIO HTTP SSE for real-time
- Architecture patterns: Widget registry, per-user preferences, SSE streaming
- Settings system: Centralized manager, YAML-driven forms
- Pitfalls: SSE connection limits, layout save races, cache invalidation
2026-02-05 15:07:51 +01:00

41 KiB

Phase 8: Admin Dashboard - Research

Researched: 2026-02-05 Domain: Admin dashboard with customizable widgets, real-time updates, and plugin settings pages Confidence: HIGH (based on WinterCMS reference + official library documentation + CONTEXT.md decisions)

Summary

This research covers the implementation of a customizable admin dashboard for SummerCMS. The dashboard provides a container for plugin-registered widgets (report widgets) with per-user layout customization, real-time updates via SSE/WebSocket, and a centralized settings system for plugin configuration.

The WinterCMS reference implementation provides a comprehensive blueprint:

  1. ReportContainer widget hosts multiple ReportWidgetBase instances with YAML-configured defaults
  2. Per-user preferences stored in a dedicated table with JSON-serialized layouts
  3. WidgetManager singleton collects widgets from plugin registrations
  4. SettingsManager centralizes plugin settings with categories and permissions
  5. SettingsModel behavior provides YAML-driven forms for plugin configuration

The CONTEXT.md decisions specify:

  • Masonry/fluid layout with drag-and-drop customization
  • Real-time updates via WebSocket/SSE for live data
  • Widget configuration (title, sizing, custom parameters)
  • WinterCMS-style centralized Settings area grouped by category

Primary recommendation: Use Gridstack.js for dashboard grid layout (masonry, drag-and-drop, resize), ZIO HTTP SSE for real-time widget updates, HTMX SSE extension for frontend integration, and the Phase 7 YAML-driven form system for widget configuration and settings pages.

Standard Stack

Core

Library Version Purpose Why Standard
Gridstack.js 10.x Dashboard grid layout Native drag-drop, resize, save/restore, TypeScript, no deps
ZIO HTTP 3.4.x SSE/WebSocket endpoints Already in stack, native streaming support
HTMX SSE Extension 2.2.x Frontend SSE integration Declarative, pairs with existing HTMX usage
circe-yaml 1.x Widget/settings config Already in stack from Phase 7
ScalaTags 0.13.x HTML generation Already in stack from Phase 7

Supporting

Library Version Purpose When to Use
Chart.js 4.x Widget charts/graphs Stats widgets with visualizations
Flatpickr 4.6.x Date range picker Widget date configuration

Alternatives Considered

Instead of Could Use Tradeoff
Gridstack.js Muuri Muuri more flexible but Gridstack specialized for dashboards
Gridstack.js React Grid Layout React Grid requires React; we use HTMX/ScalaTags
SSE Full WebSocket SSE simpler, unidirectional is sufficient for dashboard updates
Gridstack.js Packery/Isotope Packery older, Gridstack actively maintained with TypeScript

Mill dependencies:

// Phase 7 deps already included (circe-yaml, scalatags, etc.)
// ZIO HTTP already in stack

Frontend (included via CDN or bundled):

<link href="https://cdn.jsdelivr.net/npm/gridstack@10/dist/gridstack.min.css" rel="stylesheet"/>
<script src="https://cdn.jsdelivr.net/npm/gridstack@10/dist/gridstack-all.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/htmx-ext-sse@2.2.4"></script>

Architecture Patterns

summercms/
├── admin/
│   ├── dashboard/
│   │   ├── Dashboard.scala            # Dashboard container
│   │   ├── DashboardWidget.scala      # Widget base trait/class
│   │   ├── WidgetRegistry.scala       # Collects registered widgets
│   │   ├── UserDashboardPrefs.scala   # Per-user layout storage
│   │   ├── widgets/                   # Core widgets
│   │   │   ├── WelcomeWidget.scala
│   │   │   ├── SystemStatusWidget.scala
│   │   │   └── QuickActionsWidget.scala
│   │   └── sse/
│   │       └── WidgetEventStream.scala  # SSE endpoint for real-time updates
│   ├── settings/
│   │   ├── SettingsManager.scala      # Collects registered settings
│   │   ├── SettingsController.scala   # Generic settings CRUD
│   │   ├── SettingsModel.scala        # Base trait for settings models
│   │   └── categories.scala           # Standard category constants
│   └── controllers/
│       └── IndexController.scala      # Dashboard route handler

Pattern 1: Dashboard Widget Base Trait

What: Define a base trait for all dashboard (report) widgets When to use: All plugins extending dashboard with widgets Example:

// Source: WinterCMS ReportWidgetBase pattern + CONTEXT.md
import scalatags.Text.all._
import zio._

trait DashboardWidget:
  /** Unique widget identifier */
  def widgetId: String

  /** Default alias for this widget */
  def defaultAlias: String = widgetId

  /** Widget display name (translation key) */
  def name: String

  /** Widget description (translation key) */
  def description: String

  /** Define configurable properties */
  def defineProperties: Map[String, PropertyDef] = Map(
    "title" -> PropertyDef(
      title = "backend::lang.dashboard.widget_title_label",
      `type` = PropertyType.String,
      default = Some(name)
    ),
    "ocWidgetWidth" -> PropertyDef(
      title = "backend::lang.dashboard.widget_columns_label",
      `type` = PropertyType.Dropdown,
      options = (1 to 12).map(i => i.toString -> s"$i columns").toMap,
      default = Some("6")
    )
  )

  /** Render widget content (can be ZIO effect for async data loading) */
  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

case class PropertyDef(
  title: String,
  `type`: PropertyType,
  description: Option[String] = None,
  default: Option[String] = None,
  options: Map[String, String] = Map.empty,
  required: Boolean = false
)

enum PropertyType:
  case String, Number, Checkbox, Dropdown, DateRange

case class WidgetConfig(
  alias: String,
  properties: Map[String, Any],
  position: WidgetPosition
)

case class WidgetPosition(
  x: Int, y: Int,    // Grid position
  w: Int, h: Int     // Width and height in grid units
)

case class WidgetUpdate(
  widgetAlias: String,
  content: Frag
)

Pattern 2: Widget Registry with Plugin Registration

What: Singleton registry collecting widgets from plugins during boot When to use: Plugin system collects all available dashboard widgets Example:

// Source: WinterCMS WidgetManager pattern
import zio._

trait WidgetRegistry:
  def registerWidget(widgetClass: Class[? <: DashboardWidget], info: WidgetInfo): UIO[Unit]
  def listWidgets: UIO[Map[String, WidgetInfo]]
  def listWidgetsForContext(context: String): UIO[Map[String, WidgetInfo]]
  def resolveWidget(className: String): UIO[Option[DashboardWidget]]

case class WidgetInfo(
  name: String,
  description: String,
  context: List[String] = List("dashboard"),  // Where widget can appear
  permissions: List[String] = Nil              // Required permissions
)

object WidgetRegistry:
  val live: ZLayer[Any, Nothing, WidgetRegistry] =
    ZLayer.fromZIO {
      Ref.make(Map.empty[String, (WidgetInfo, DashboardWidget)]).map { widgetsRef =>
        new WidgetRegistry:
          def registerWidget(widgetClass: Class[? <: DashboardWidget], info: WidgetInfo): UIO[Unit] =
            for
              instance <- ZIO.succeed(widgetClass.getDeclaredConstructor().newInstance())
              _        <- widgetsRef.update(_ + (widgetClass.getName -> (info, instance)))
            yield ()

          def listWidgets: UIO[Map[String, WidgetInfo]] =
            widgetsRef.get.map(_.view.mapValues(_._1).toMap)

          def listWidgetsForContext(context: String): UIO[Map[String, WidgetInfo]] =
            listWidgets.map(_.filter(_._2.context.contains(context)))

          def resolveWidget(className: String): UIO[Option[DashboardWidget]] =
            widgetsRef.get.map(_.get(className).map(_._2))
      }
    }

// Plugin registration in boot
def registerReportWidgets: Map[Class[? <: DashboardWidget], WidgetInfo] = Map(
  classOf[SystemStatusWidget] -> WidgetInfo(
    name = "system::lang.dashboard.status.name",
    description = "system::lang.dashboard.status.description",
    permissions = List("system.access_dashboard")
  ),
  classOf[WelcomeWidget] -> WidgetInfo(
    name = "backend::lang.dashboard.welcome.name",
    description = "backend::lang.dashboard.welcome.description"
  )
)

Pattern 3: Per-User Dashboard Preferences

What: Store and retrieve per-user widget layouts using database When to use: Dashboard layout customization per admin user Example:

// Source: WinterCMS UserPreference + ReportContainer patterns
import io.getquill._
import zio._

case class DashboardPreference(
  userId: Long,
  context: String,         // "dashboard", "analytics", etc.
  widgets: Json             // JSON array of widget configurations
)

case class StoredWidgetConfig(
  className: String,
  alias: String,
  sortOrder: Int,
  configuration: Map[String, Any],  // Including position: {x, y, w, h}
)

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:
  def live(quill: Quill.Postgres[SnakeCase]): ZLayer[Any, Nothing, DashboardPreferenceService] =
    ZLayer.succeed {
      import quill._
      new DashboardPreferenceService:
        def getPreferences(userId: Long, context: String): Task[List[StoredWidgetConfig]] =
          // First try user preferences, fall back to system defaults
          run(
            query[DashboardPreference]
              .filter(p => p.userId == lift(userId) && p.context == lift(context))
          ).flatMap {
            case Nil => getDefaultLayout(context)
            case prefs => ZIO.succeed(parseWidgets(prefs.head.widgets))
          }

        def setPreferences(userId: Long, context: String, widgets: List[StoredWidgetConfig]): Task[Unit] =
          run(
            query[DashboardPreference]
              .filter(p => p.userId == lift(userId) && p.context == lift(context))
              .updateValue(lift(DashboardPreference(userId, context, encodeWidgets(widgets))))
          ).unit.catchAll { _ =>
            run(
              query[DashboardPreference]
                .insertValue(lift(DashboardPreference(userId, context, encodeWidgets(widgets))))
            ).unit
          }

        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]] =
          // Load from YAML config or system parameter
          ZIO.attempt {
            val config = loadYamlConfig(s"config_dashboard_$context.yaml")
            parseDefaultWidgets(config)
          }
    }

Pattern 4: Dashboard Container with Gridstack Integration

What: Render dashboard with Gridstack.js grid layout When to use: Main dashboard view rendering Example:

// Source: WinterCMS ReportContainer + Gridstack.js patterns
import scalatags.Text.all._

def renderDashboard(
  widgets: List[(String, DashboardWidget, WidgetConfig)],
  canAddAndDelete: Boolean
)(using ctx: DashboardContext): Frag = {
  div(cls := "dashboard-container",
    // Toolbar
    div(cls := "dashboard-toolbar",
      canAddAndDelete.option(
        button(cls := "btn btn-primary",
          attr("hx-get") := "/admin/dashboard/add-widget-popup",
          attr("hx-target") := "#modal-container",
          "Add Widget"
        )
      ),
      button(cls := "btn btn-secondary",
        attr("hx-post") := "/admin/dashboard/reset",
        attr("hx-confirm") := "Reset dashboard to default layout?",
        "Reset to Default"
      )
    ),

    // Grid container - Gridstack.js will initialize this
    div(cls := "grid-stack",
      id := "dashboard-grid",
      widgets.map { case (alias, widget, config) =>
        div(cls := "grid-stack-item",
          attr("gs-id") := alias,
          attr("gs-x") := config.position.x.toString,
          attr("gs-y") := config.position.y.toString,
          attr("gs-w") := config.position.w.toString,
          attr("gs-h") := config.position.h.toString,
          attr("gs-min-w") := "2",
          attr("gs-min-h") := "2",

          div(cls := "grid-stack-item-content widget-container",
            // SSE connection for real-time updates
            widget.updates(config).map { _ =>
              Seq(
                attr("hx-ext") := "sse",
                attr("sse-connect") := s"/admin/dashboard/widget-stream/$alias",
                attr("sse-swap") := "content"
              )
            }.getOrElse(Nil),

            // Widget header
            div(cls := "widget-header",
              span(cls := "widget-title", config.properties.getOrElse("title", widget.name).toString),
              div(cls := "widget-controls",
                button(cls := "btn-widget-config",
                  attr("hx-get") := s"/admin/dashboard/widget-config/$alias",
                  attr("hx-target") := "#modal-container",
                  i(cls := "icon-cog")
                ),
                canAddAndDelete.option(
                  button(cls := "btn-widget-remove",
                    attr("hx-post") := s"/admin/dashboard/remove-widget/$alias",
                    attr("hx-confirm") := "Remove this widget?",
                    i(cls := "icon-trash")
                  )
                )
              )
            ),

            // Widget body (rendered content)
            div(cls := "widget-body", id := s"widget-content-$alias",
              // Initial render - will be swapped via SSE for real-time widgets
              renderWidgetContent(widget, config, ctx)
            )
          )
        )
      }
    ),

    // Gridstack initialization script
    script(raw("""
      document.addEventListener('DOMContentLoaded', function() {
        var grid = GridStack.init({
          cellHeight: 80,
          margin: 10,
          float: true,
          removable: false
        });

        // Save layout on change
        grid.on('change', function(event, items) {
          var layout = items.map(function(item) {
            return {
              id: item.id,
              x: item.x, y: item.y,
              w: item.w, h: item.h
            };
          });

          htmx.ajax('POST', '/admin/dashboard/save-layout', {
            values: { layout: JSON.stringify(layout) }
          });
        });
      });
    """))
  )
}

Pattern 5: SSE Endpoint for Real-Time Widget Updates

What: ZIO HTTP SSE endpoint streaming widget updates When to use: Widgets requiring live data (stats, activity feeds) Example:

// Source: ZIO HTTP SSE documentation + HTMX SSE extension
import zio._
import zio.http._
import zio.stream._

object WidgetStreamEndpoint:
  def routes: Routes[WidgetRegistry & DashboardPreferenceService, Nothing] =
    Routes(
      Method.GET / "admin" / "dashboard" / "widget-stream" / string("alias") ->
        handler { (alias: String, req: Request) =>
          for
            registry <- ZIO.service[WidgetRegistry]
            prefs    <- ZIO.service[DashboardPreferenceService]
            userId   <- extractUserId(req)
            configs  <- prefs.getPreferences(userId, "dashboard")
            config   <- ZIO.fromOption(configs.find(_.alias == alias))
                          .orElseFail(Response.notFound)
            widget   <- registry.resolveWidget(config.className)
                          .someOrFail(Response.notFound)

            stream = widget.updates(toWidgetConfig(config)) match
              case Some(updateStream) =>
                updateStream.map { update =>
                  ServerSentEvent(
                    data = Some(update.content.render),
                    eventType = Some("content"),
                    id = Some(java.util.UUID.randomUUID().toString)
                  )
                }
              case None =>
                // No updates - just keep connection alive with heartbeat
                ZStream.tick(30.seconds).map { _ =>
                  ServerSentEvent(
                    data = Some(":heartbeat"),
                    eventType = Some("heartbeat")
                  )
                }
          yield Response(
            status = Status.Ok,
            headers = Headers(
              Header.ContentType(MediaType.text.`event-stream`),
              Header.CacheControl.NoCache
            ),
            body = Body.fromCharSequenceStreamChunked(
              stream.map(sse => sse.encode)
            )
          )
        }
    )

// Example widget with real-time updates
class ActiveUsersWidget extends DashboardWidget:
  def widgetId = "active-users"
  def name = "backend::lang.dashboard.active_users"
  def description = "Shows currently active users"

  def render(config: WidgetConfig, ctx: DashboardContext): Task[Frag] =
    getActiveUserCount.map { count =>
      div(cls := "stat-widget",
        span(cls := "stat-value", count.toString),
        span(cls := "stat-label", "Active Users")
      )
    }

  override def updates(config: WidgetConfig): Option[ZStream[Any, Nothing, WidgetUpdate]] =
    Some(
      ZStream.repeatZIOWithSchedule(
        getActiveUserCount.map { count =>
          WidgetUpdate(
            config.alias,
            div(cls := "stat-widget",
              span(cls := "stat-value", count.toString),
              span(cls := "stat-label", "Active Users")
            )
          )
        }.orDie,
        Schedule.fixed(5.seconds)
      )
    )

Pattern 6: Settings Manager for Plugin Settings

What: Centralized registry of plugin settings pages with categories When to use: Plugins registering configuration pages Example:

// Source: WinterCMS SettingsManager pattern
import zio._

// Standard categories (translation keys)
object SettingsCategory:
  val System = "system::lang.system.categories.system"
  val CMS = "system::lang.system.categories.cms"
  val Users = "system::lang.system.categories.users"
  val Mail = "system::lang.system.categories.mail"
  val Misc = "system::lang.system.categories.misc"

case class SettingsItem(
  code: String,
  owner: String,              // Plugin ID: "golem15.blog"
  label: String,              // Translation key
  description: Option[String],
  category: String,           // SettingsCategory constant
  icon: Option[String],
  url: Option[String],        // Custom URL, or auto-generated
  settingsClass: Option[String],  // Settings model class name
  permissions: List[String],
  order: Int = 500,
  keywords: Option[String] = None  // For search
)

trait SettingsManager:
  def registerSettings(owner: String, items: Map[String, SettingsItem]): UIO[Unit]
  def listItems(context: Option[String] = None): UIO[Map[String, List[SettingsItem]]]
  def findItem(owner: String, code: String): UIO[Option[SettingsItem]]
  def setContext(owner: String, code: String): UIO[Unit]
  def getContext: UIO[(String, String)]

object SettingsManager:
  val live: ZLayer[Any, Nothing, SettingsManager] =
    ZLayer.fromZIO {
      for
        itemsRef   <- Ref.make(Map.empty[String, SettingsItem])
        contextRef <- Ref.make(("", ""))
      yield new SettingsManager:
        def registerSettings(owner: String, items: Map[String, SettingsItem]): UIO[Unit] =
          itemsRef.update { current =>
            items.foldLeft(current) { case (acc, (code, item)) =>
              val key = s"${owner.toUpperCase}.$${code.toUpperCase}"
              val itemWithOwner = item.copy(
                code = code,
                owner = owner,
                url = item.url.orElse(Some(generateSettingsUrl(owner, code)))
              )
              acc + (key -> itemWithOwner)
            }
          }

        def listItems(context: Option[String]): UIO[Map[String, List[SettingsItem]]] =
          itemsRef.get.map { items =>
            items.values
              .groupBy(_.category)
              .view.mapValues(_.toList.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((owner.toLowerCase, code.toLowerCase))

        def getContext: UIO[(String, String)] =
          contextRef.get

        private def generateSettingsUrl(owner: String, code: String): String =
          val parts = owner.split('.')
          s"/admin/system/settings/update/${parts.mkString("/")}/$code"
    }

// Plugin registration
def registerSettings: Map[String, SettingsItem] = Map(
  "blog" -> SettingsItem(
    code = "blog",
    owner = "golem15.blog",
    label = "golem15.blog::lang.settings.label",
    description = Some("golem15.blog::lang.settings.description"),
    category = SettingsCategory.CMS,
    icon = Some("icon-pencil"),
    settingsClass = Some("golem15.blog.models.Settings"),
    permissions = List("golem15.blog.manage_settings"),
    order = 500,
    keywords = Some("blog post category")
  )
)

Pattern 7: Settings Model Behavior

What: Model behavior for YAML-driven settings storage When to use: Plugin settings models stored in system_settings table Example:

// Source: WinterCMS SettingsModel behavior
import zio._
import io.circe.Json

trait SettingsModel:
  def settingsCode: String
  def settingsFields: String   // Path to fields.yaml

  // Mixin implementation
  private var fieldValues: Map[String, Any] = Map.empty
  private var instance: Option[SettingsModel] = None

  /** Get singleton instance (loads from DB or creates default) */
  def getInstance: Task[SettingsModel] =
    instance match
      case Some(inst) => ZIO.succeed(inst)
      case None =>
        loadFromDatabase.flatMap {
          case Some(record) =>
            ZIO.succeed {
              fieldValues = record.values
              instance = Some(this)
              this
            }
          case None =>
            ZIO.succeed {
              initSettingsData()
              instance = Some(this)
              this
            }
        }

  /** Override to set default values */
  def initSettingsData(): Unit = ()

  /** Get a setting value */
  def get[T](key: String, default: T): T =
    fieldValues.get(key).map(_.asInstanceOf[T]).getOrElse(default)

  /** Set setting values */
  def set(values: Map[String, Any]): Task[Unit] =
    ZIO.attempt {
      fieldValues = fieldValues ++ values
    } *> saveToDatabase

  /** Reset to defaults */
  def resetDefault: Task[Unit] =
    deleteFromDatabase *> ZIO.succeed {
      fieldValues = Map.empty
      instance = None
    }

  // Database operations (using system_settings table)
  private def loadFromDatabase: Task[Option[SettingsRecord]] = ???
  private def saveToDatabase: Task[Unit] = ???
  private def deleteFromDatabase: Task[Unit] = ???

// Example settings model
class BlogSettings extends SettingsModel:
  val settingsCode = "golem15_blog_settings"
  val settingsFields = "fields.yaml"

  override def initSettingsData(): Unit =
    fieldValues = Map(
      "posts_per_page" -> 10,
      "show_author" -> true,
      "excerpt_length" -> 200
    )

// Usage
val postsPerPage = BlogSettings.getInstance.flatMap { settings =>
  ZIO.succeed(settings.get("posts_per_page", 10))
}

Anti-Patterns to Avoid

  • Polling for updates: Use SSE streams instead of periodic AJAX polling
  • Global layout storage: Each user must have their own layout preferences
  • Inline widget configuration: Use YAML-driven property definitions for consistency
  • Blocking widget renders: Widget render should be ZIO Task, not blocking
  • Shared mutable state: Use ZIO Ref for widget registry and settings state
  • Direct database queries in widgets: Use service layer for data access

Don't Hand-Roll

Problems that look simple but have existing solutions:

Problem Don't Build Use Instead Why
Dashboard grid layout Custom CSS Grid + JS Gridstack.js Drag-drop, resize, save/restore, mobile support
Real-time updates Custom WebSocket protocol ZIO HTTP SSE + HTMX ext Standard protocol, automatic reconnection
User preferences storage Session/cookie storage Database per-user table Persists across sessions, multi-device
Widget property forms Custom form generation Phase 7 form system Consistency, YAML-driven, validation
Settings form generation Custom forms per plugin SettingsModel behavior Standardized, YAML-driven, cached

Key insight: Dashboard widgets are deceptively complex. Each widget needs configuration UI, persistence, permissions, and potentially real-time updates. Reuse WinterCMS patterns for the container/widget relationship rather than building ad-hoc.

Common Pitfalls

Pitfall 1: SSE Connection Limits

What goes wrong: Browser limits concurrent SSE connections (typically 6 per domain) Why it happens: Each widget opens its own SSE connection How to avoid:

  • Single SSE connection per dashboard page with multiplexed events
  • Use event names to route updates to correct widgets
  • Send widget alias in event type: event: widget-$alias Warning signs: Later widgets don't receive updates; "ERR_CONNECTION_LIMIT" in browser console

Pitfall 2: Layout Save Race Conditions

What goes wrong: Rapid drag/resize operations cause lost updates Why it happens: Multiple save requests overlap, last one wins How to avoid:

  • Debounce save operations (300-500ms after last change)
  • Include full layout state in each save (not incremental updates)
  • Server acknowledges save, client can retry on failure Warning signs: Layout reverts unexpectedly; saved layout doesn't match visual

Pitfall 3: Widget Configuration State Loss

What goes wrong: Widget config changes lost on page reload Why it happens: Config saved to layout but not persisted to database How to avoid:

  • Widget config stored in preferences alongside position
  • Config changes trigger preferences save immediately
  • Default config comes from widget definition, overrides from preferences Warning signs: "Reset" doesn't restore expected defaults; config resets on browser refresh

Pitfall 4: Memory Leaks in Real-Time Widgets

What goes wrong: Dashboard becomes slow/unresponsive over time Why it happens: SSE event handlers accumulate; old widget content not garbage collected How to avoid:

  • HTMX's SSE extension manages this automatically via sse-swap
  • Avoid manual JavaScript event listeners without cleanup
  • Use sse-close attribute for explicit cleanup on navigation Warning signs: Growing memory usage; event handlers stack on widget reconfigure

Pitfall 5: Settings Cache Invalidation

What goes wrong: Changed settings not reflected immediately Why it happens: Settings cached at application level for performance How to avoid:

  • Clear cache on save (WinterCMS calls queue:restart)
  • Use short TTL for frequently-changed settings
  • Provide "clear cache" admin action Warning signs: Changes require restart; inconsistent behavior across requests

Pitfall 6: Category Translation and Ordering

What goes wrong: Settings categories display incorrectly or in wrong order Why it happens: Categories are translation keys, not display strings; no explicit ordering How to avoid:

  • Define standard categories with explicit sort order
  • Translate category keys at render time, not storage
  • Allow plugins to register new categories with order hints Warning signs: Untranslated category keys visible; random category order

Code Examples

Complete Dashboard Controller

// Source: WinterCMS backend/controllers/Index.php + Gridstack patterns
import zio._
import zio.http._
import scalatags.Text.all._

class DashboardController(
  widgetRegistry: WidgetRegistry,
  prefsService: DashboardPreferenceService,
  auth: AuthService
):

  def index: Handler[Any, Nothing, Request, Response] =
    handler { (req: Request) =>
      for
        user    <- auth.getUser(req)
        widgets <- loadUserDashboard(user.id, "dashboard")
        html    <- renderDashboard(widgets, canAddAndDelete = true)
      yield Response.html(html)
    }

  def saveLayout: Handler[Any, Nothing, Request, Response] =
    handler { (req: Request) =>
      for
        user    <- auth.getUser(req)
        layout  <- req.body.asString.flatMap(parseLayoutJson)
        _       <- prefsService.setPreferences(user.id, "dashboard", layout)
      yield Response.ok
    }

  def addWidgetPopup: Handler[Any, Nothing, Request, Response] =
    handler { (req: Request) =>
      for
        user    <- auth.getUser(req)
        widgets <- widgetRegistry.listWidgetsForContext("dashboard")
        // Filter by user permissions
        available = widgets.filter { case (_, info) =>
          info.permissions.isEmpty || user.hasAnyAccess(info.permissions)
        }
      yield Response.html(renderAddWidgetPopup(available))
    }

  def addWidget: Handler[Any, Nothing, Request, Response] =
    handler { (req: Request) =>
      for
        user      <- auth.getUser(req)
        form      <- req.body.asURLEncodedForm
        className <- ZIO.fromOption(form.get("className"))
        size      <- ZIO.fromOption(form.get("size").flatMap(_.toIntOption))
        widget    <- widgetRegistry.resolveWidget(className)
                       .someOrFail(Response.badRequest("Invalid widget class"))

        // Get current layout to determine new widget position
        current <- prefsService.getPreferences(user.id, "dashboard")
        nextOrder = current.map(_.sortOrder).maxOption.getOrElse(0) + 1
        alias = s"widget_${user.id}_$nextOrder"

        newWidget = StoredWidgetConfig(
          className = className,
          alias = alias,
          sortOrder = nextOrder,
          configuration = Map(
            "ocWidgetWidth" -> size,
            "x" -> 0, "y" -> current.size,  // Add at bottom
            "w" -> size, "h" -> 2
          )
        )

        _ <- prefsService.setPreferences(user.id, "dashboard", current :+ newWidget)

        // Return partial HTML for the new widget
        config = toWidgetConfig(newWidget)
        html <- widget.render(config, DashboardContext.forUser(user))
      yield Response.html(renderGridstackItem(alias, widget, config, html))
    }

  def removeWidget: Handler[Any, Nothing, Request, Response] =
    handler { (req: Request) =>
      for
        user   <- auth.getUser(req)
        alias  <- req.url.queryParams.get("alias").flatMap(_.headOption)
                    .fold(ZIO.fail(Response.badRequest("Missing alias")))(ZIO.succeed)
        current <- prefsService.getPreferences(user.id, "dashboard")
        updated = current.filterNot(_.alias == alias)
        _       <- prefsService.setPreferences(user.id, "dashboard", updated)
      yield Response.ok
    }

  def resetDashboard: Handler[Any, Nothing, Request, Response] =
    handler { (req: Request) =>
      for
        user <- auth.getUser(req)
        _    <- prefsService.resetToDefault(user.id, "dashboard")
      yield Response.redirect("/admin")
    }

Settings Controller with Generic CRUD

// Source: WinterCMS system/controllers/Settings.php
import zio._
import zio.http._

class SettingsController(
  settingsManager: SettingsManager,
  formRenderer: FormRenderer,
  auth: AuthService
):

  def index: Handler[Any, Nothing, Request, Response] =
    handler { (req: Request) =>
      for
        user      <- auth.getUser(req)
        items     <- settingsManager.listItems()
        // Filter by permissions
        filtered = items.view.mapValues(_.filter { item =>
          item.permissions.isEmpty || user.hasAnyAccess(item.permissions)
        }).filterNot(_._2.isEmpty).toMap
      yield Response.html(renderSettingsIndex(filtered))
    }

  def update(author: String, plugin: String, code: String): Handler[Any, Nothing, Request, Response] =
    handler { (req: Request) =>
      val owner = s"$author.$plugin"
      for
        user <- auth.getUser(req)
        item <- settingsManager.findItem(owner, code)
                  .someOrFail(Response.notFound)
        _    <- ZIO.when(item.permissions.nonEmpty && !user.hasAnyAccess(item.permissions)) {
                  ZIO.fail(Response.forbidden)
                }
        _    <- settingsManager.setContext(owner, code)

        // Load settings model
        model <- loadSettingsModel(item.settingsClass.get)
        instance <- model.getInstance

        // Parse YAML fields definition
        fieldsConfig <- parseFieldsYaml(model.settingsFields)

        // Render form
        form <- formRenderer.render(fieldsConfig, instance.getValues)
      yield Response.html(renderSettingsForm(item, form))
    }

  def save(author: String, plugin: String, code: String): Handler[Any, Nothing, Request, Response] =
    handler { (req: Request) =>
      val owner = s"$author.$plugin"
      for
        user  <- auth.getUser(req)
        item  <- settingsManager.findItem(owner, code)
                   .someOrFail(Response.notFound)
        model <- loadSettingsModel(item.settingsClass.get)
        instance <- model.getInstance

        // Parse form data
        formData <- req.body.asURLEncodedForm
        values = formData.view.mapValues(_.head).toMap

        // Validate (optional - use model validation rules)
        _ <- validateSettings(model, values)

        // Save
        _ <- instance.set(values)

        // Flash success message
      yield Response.redirect(s"/admin/system/settings/update/$author/$plugin/$code")
        .addHeader(Header.Custom("X-Flash", "Settings saved successfully"))
    }

Default Dashboard Configuration YAML

# config_dashboard.yaml
# Source: WinterCMS backend/controllers/index/config_dashboard.yaml

defaultWidgets:
  welcome:
    class: summercms.admin.widgets.WelcomeWidget
    sortOrder: 50
    configuration:
      ocWidgetWidth: 7
      x: 0
      y: 0
      w: 7
      h: 3

  systemStatus:
    class: summercms.admin.widgets.SystemStatusWidget
    sortOrder: 60
    configuration:
      ocWidgetWidth: 5
      x: 7
      y: 0
      w: 5
      h: 3

  recentActivity:
    class: summercms.admin.widgets.RecentActivityWidget
    sortOrder: 70
    configuration:
      ocWidgetWidth: 12
      x: 0
      y: 3
      w: 12
      h: 4
      itemCount: 10

Widget Property Configuration Popup

// Source: WinterCMS ReportContainer property config patterns
def renderWidgetConfigPopup(
  alias: String,
  widget: DashboardWidget,
  currentConfig: WidgetConfig
): Frag = {
  val properties = widget.defineProperties

  div(cls := "modal-dialog",
    div(cls := "modal-content",
      div(cls := "modal-header",
        h5(cls := "modal-title", "Widget Configuration"),
        button(`type` := "button", cls := "btn-close",
          attr("data-bs-dismiss") := "modal")
      ),
      form(
        attr("hx-post") := s"/admin/dashboard/widget-config/$alias",
        attr("hx-target") := s"#widget-content-$alias",
        attr("hx-swap") := "innerHTML",

        div(cls := "modal-body",
          properties.map { case (propName, propDef) =>
            div(cls := "form-group",
              label(cls := "form-label", propDef.title),
              propDef.description.map(d => p(cls := "help-block", d)),
              renderPropertyInput(propName, propDef, currentConfig.properties.get(propName))
            )
          }
        ),

        div(cls := "modal-footer",
          button(`type` := "button", cls := "btn btn-secondary",
            attr("data-bs-dismiss") := "modal", "Cancel"),
          button(`type` := "submit", cls := "btn btn-primary", "Save")
        )
      )
    )
  )
}

def renderPropertyInput(name: String, propDef: PropertyDef, value: Option[Any]): Frag = {
  val currentValue = value.orElse(propDef.default).map(_.toString).getOrElse("")

  propDef.`type` match
    case PropertyType.String =>
      input(cls := "form-control", `type` := "text",
        attr("name") := name, attr("value") := currentValue)

    case PropertyType.Number =>
      input(cls := "form-control", `type` := "number",
        attr("name") := name, attr("value") := currentValue)

    case PropertyType.Checkbox =>
      div(cls := "form-check",
        input(cls := "form-check-input", `type` := "checkbox",
          attr("name") := name,
          (currentValue == "true").option(attr("checked") := "checked"))
      )

    case PropertyType.Dropdown =>
      select(cls := "form-select", attr("name") := name,
        propDef.options.map { case (optValue, optLabel) =>
          option(attr("value") := optValue,
            (currentValue == optValue).option(attr("selected") := "selected"),
            optLabel)
        }
      )

    case PropertyType.DateRange =>
      input(cls := "form-control flatpickr",
        `type` := "text", attr("name") := name, attr("value") := currentValue,
        attr("data-mode") := "range")
}

State of the Art

Old Approach Current Approach When Changed Impact
jQuery UI sortable Gridstack.js 2020+ Better mobile support, TypeScript, maintained
Polling for updates SSE/WebSocket 2018+ Real-time, lower overhead
Session-based prefs Database per-user Standard Persists, multi-device
Manual AJAX widget refresh HTMX SSE extension 2023+ Declarative, automatic reconnection
PHP serialized layout JSON layout storage Standard Interoperable, queryable

Deprecated/outdated:

  • jQuery UI Sortable: Use Gridstack.js for dashboard grids
  • Isotope/Packery: Gridstack specifically designed for dashboards
  • Long-polling: Use SSE for server-push updates
  • Cookie-based preferences: Use database storage for persistence

Open Questions

  1. Multiplexed SSE vs Multiple Connections

    • What we know: Browser limits concurrent connections (6 per domain)
    • What's unclear: Best pattern for many real-time widgets
    • Recommendation: Single SSE connection with event routing by widget alias
  2. Widget Caching Strategy

    • What we know: Some widgets expensive to render (database queries)
    • What's unclear: How to cache widget HTML while maintaining real-time updates
    • Recommendation: Cache initial render, SSE updates bypass cache
  3. Admin-Set Default Layout

    • What we know: WinterCMS allows admins to "make layout default"
    • What's unclear: Should super-admin override affect existing user layouts?
    • Recommendation: System default separate from user prefs; "reset" restores system default
  4. Widget Asset Loading

    • What we know: Some widgets need additional JS/CSS (Chart.js)
    • What's unclear: How to lazy-load widget assets only when widget is used
    • Recommendation: Define asset dependencies in widget class, inject on dashboard load

Sources

Primary (HIGH confidence)

  • WinterCMS reference implementation:
    • modules/backend/widgets/ReportContainer.php - Dashboard container pattern
    • modules/backend/classes/ReportWidgetBase.php - Widget base class
    • modules/backend/classes/WidgetManager.php - Widget registration
    • modules/system/classes/SettingsManager.php - Settings registration
    • modules/system/behaviors/SettingsModel.php - Settings persistence
    • modules/backend/models/UserPreference.php - Per-user storage
  • HTMX SSE Extension - SSE frontend integration
  • Gridstack.js - Dashboard grid layout library
  • ZIO HTTP SSE Examples - SSE endpoint implementation

Secondary (MEDIUM confidence)

Tertiary (LOW confidence)

  • WebSearch results on dashboard widget best practices 2025-2026
  • Community discussions on SSE vs WebSocket tradeoffs

Metadata

Confidence breakdown:

  • Standard stack: HIGH - Gridstack.js docs + ZIO HTTP SSE verified
  • Architecture: HIGH - Based on thorough WinterCMS reference analysis
  • Pitfalls: MEDIUM - Mix of WinterCMS patterns and web development experience
  • Real-time patterns: MEDIUM - ZIO SSE documented, HTMX integration well-established

Research date: 2026-02-05 Valid until: 2026-03-05 (30 days - stable domain)