+ {configuring + ? "Drag a widget here from the library." + : "Open Configure to add widgets provided by active modules."} +
+Personal workspace assembled from installed module widgets.
+Personal workspace assembled from installed module widgets.
+No installed module exposes dashboard widgets yet.
: -Open Configure and select at least one widget. Modules can add more widgets by exposing the dashboard.widgets capability.
-No WebUI modules are active.
; + const maxItems = clampNumber(configuration.maxItems, 1, 50, 8); + const showVersions = configuration.showVersions !== false; + const sortBy = configuration.sortBy === "label" ? "label" : "module"; + const visibleModules = [...modules] + .sort((left, right) => { + const leftValue = sortBy === "label" ? left.label : left.id; + const rightValue = sortBy === "label" ? right.label : right.id; + return String(leftValue).localeCompare(String(rightValue)); + }) + .slice(0, maxItems); + const remaining = Math.max(modules.length - visibleModules.length, 0); return ( -+{remaining} more active modules
} + >); } +function clampNumber( + value: DashboardWidgetConfiguration[string], + minimum: number, + maximum: number, + fallback: number +): number { + if (typeof value !== "number" || !Number.isFinite(value)) return fallback; + return Math.min(maximum, Math.max(minimum, Math.round(value))); +} diff --git a/webui/src/module.ts b/webui/src/module.ts index edac3e2..d92fc9d 100644 --- a/webui/src/module.ts +++ b/webui/src/module.ts @@ -17,7 +17,41 @@ const dashboardWidgets: DashboardWidgetsUiCapability = { category: "Platform", order: 10, defaultSize: "medium", - render: ({ modules }) => createElement(InstalledModulesWidget, { modules }) + supportedSizes: ["small", "medium", "wide"], + defaultConfiguration: { + maxItems: 8, + showVersions: true, + sortBy: "module" + }, + configurationFields: [ + { + id: "maxItems", + label: "Maximum modules", + description: "Limit how many active modules are listed.", + kind: "number", + min: 1, + max: 50, + step: 1, + required: true + }, + { + id: "sortBy", + label: "Sort modules by", + kind: "select", + required: true, + options: [ + { value: "module", label: "Module id" }, + { value: "label", label: "Display name" } + ] + }, + { + id: "showVersions", + label: "Show module versions", + kind: "boolean" + } + ], + render: ({ modules, configuration }) => + createElement(InstalledModulesWidget, { modules, configuration }) } ] }; diff --git a/webui/src/styles/dashboard.css b/webui/src/styles/dashboard.css index bee8c89..89f9574 100644 --- a/webui/src/styles/dashboard.css +++ b/webui/src/styles/dashboard.css @@ -11,12 +11,23 @@ .dashboard-widget-grid { display: grid; grid-template-columns: repeat(4, minmax(220px, 1fr)); - gap: 18px; + grid-auto-flow: row dense; + grid-auto-rows: 4px; + column-gap: 18px; + row-gap: 0; align-items: start; } .dashboard-widget { min-width: 0; + position: relative; + align-self: start; + margin-bottom: 18px; +} + +.dashboard-widget > .card { + height: auto; + margin-bottom: 0; } .dashboard-widget-small { @@ -41,10 +52,275 @@ margin-bottom: 0; } +.dashboard-config-workspace { + display: flex; + min-width: 0; + flex-direction: column; + gap: 18px; +} + +.dashboard-widget-library { + display: grid; + grid-template-columns: minmax(200px, 260px) minmax(0, 1fr) 36px; + align-items: center; + min-width: 0; + min-height: 84px; + overflow: hidden; + border: var(--border-line); + border-radius: var(--radius); + background: var(--surface); + box-shadow: var(--shadow-xs); +} + +.dashboard-widget-library-controls { + align-self: stretch; + display: grid; + align-content: center; + gap: 7px; + min-width: 0; + padding: 9px 12px; + border-right: var(--border-line); + background: var(--panel-header); +} + +.dashboard-widget-library-header { + display: flex; + align-items: center; +} + +.dashboard-widget-library-header h2 { + margin: 0; + font-size: 16px; +} + +.dashboard-widget-search { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: 8px; + min-height: 0; + margin: 0; + padding: 4px 8px; + border: var(--border-line); + border-radius: 5px; + background: var(--surface); +} + +.dashboard-widget-search:focus-within { + box-shadow: var(--accent-ring-soft); +} + +.dashboard-widget-search input[type="search"] { + height: auto; + min-height: 0; + min-width: 0; + padding: 1px 0; + border: 0; + box-shadow: none; + font: inherit; + font-size: 13px; + line-height: 1.35; +} + +.dashboard-widget-library-list { + display: flex; + min-width: 0; + align-self: stretch; + overflow-x: auto; + overflow-y: hidden; + border-right: var(--border-line); +} + +.dashboard-widget-library-item { + flex: 0 0 min(250px, 32vw); + display: grid; + grid-template-columns: 18px minmax(0, 1fr) 36px; + align-items: center; + gap: 10px; + min-height: 70px; + padding: 9px 10px 9px 12px; + border-right: var(--border-line); + cursor: grab; +} + +.dashboard-widget-library-item:hover { + background: var(--hover-tint-soft); +} + +.dashboard-widget-library-item:active { + cursor: grabbing; +} + +.dashboard-widget-library-item.is-disabled { + cursor: default; + opacity: .7; +} + +.dashboard-widget-library-item > div { + display: grid; + min-width: 0; + gap: 3px; +} + +.dashboard-widget-library-item strong, +.dashboard-widget-library-item span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dashboard-widget-library-item span { + color: var(--muted); + font-size: 12px; +} + +.dashboard-widget-library-empty { + flex: 1 0 auto; + margin: 0; + padding: 18px 16px; +} + +.dashboard-widget-grid.is-configuring .dashboard-widget { + cursor: default; +} + +.dashboard-widget-grid.is-configuring .card-header { + align-items: center; + flex-wrap: nowrap; + gap: 6px; + padding: 12px 16px; +} + +.dashboard-widget-grid.is-configuring .card-header > h2 { + order: 2; + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dashboard-widget-grid.is-configuring .card-actions { + display: contents; +} + +.dashboard-widget-actions { + display: contents; +} + +.dashboard-widget-actions > * { + order: 3; +} + +.dashboard-widget-drag-handle { + order: 1; + flex: 0 0 auto; + cursor: grab; +} + +.dashboard-widget-drag-handle:active { + cursor: grabbing; +} + +.dashboard-widget-drop-placeholder { + min-height: 142px; + border: 2px dashed var(--accent); + border-radius: var(--radius); + background: var(--accent-hover-bg); + color: var(--text); +} + +.dashboard-widget-drop-placeholder.preserves-widget-height { + border: 0; + background: transparent; +} + +.dashboard-widget-placeholder-content { + visibility: hidden; +} + +.dashboard-widget-placeholder-surface { + position: absolute; + inset: 0; + z-index: 2; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 142px; + padding: 16px; + border: 2px dashed var(--accent); + border-radius: var(--radius); + background: var(--accent-hover-bg); + color: var(--text); + text-align: center; + pointer-events: none; +} + +.dashboard-grid-end-drop-target, +.dashboard-empty-state.is-drop-target { + display: grid; + min-height: 74px; + place-items: center; + border: 2px dashed var(--line-dark); + border-radius: var(--radius); + color: var(--muted); + font-size: 13px; + font-weight: 700; +} + +.dashboard-grid-end-drop-target { + grid-column: 1 / -1; + grid-row-end: span 19; +} + +.dashboard-grid-end-drop-target:hover, +.dashboard-empty-state.is-drop-target:hover { + border-color: var(--accent); + background: var(--accent-hover-bg); + color: var(--text); +} + +.dashboard-empty-state { + min-height: 220px; + padding: 32px; + text-align: center; +} + +.dashboard-empty-state h2 { + margin: 0 0 8px; + font-size: 18px; +} + +.dashboard-empty-state p { + margin: 0; +} + +.dashboard-widget-config-dialog { + width: min(620px, 100%); +} + +.dashboard-widget-config-dialog .dialog-body { + display: grid; + gap: 18px; +} + +.dashboard-widget-config-dialog .dialog-footer-spacer { + flex: 1 1 auto; +} + +.dashboard-widget-overflow { + margin: 12px 0 0; +} + @media (max-width: 1120px) { .dashboard-widget-grid { grid-template-columns: repeat(2, minmax(220px, 1fr)); } + + .dashboard-widget-library { + grid-template-columns: minmax(200px, 240px) minmax(0, 1fr) 36px; + } } @media (max-width: 900px) { @@ -53,6 +329,29 @@ grid-template-columns: 1fr; } + .dashboard-widget-library { + grid-template-columns: minmax(0, 1fr) 36px; + } + + .dashboard-widget-library-controls { + grid-column: 1; + grid-row: 1; + border-right: 0; + } + + .dashboard-widget-library-list { + grid-column: 1 / -1; + grid-row: 2; + min-height: 70px; + border-top: var(--border-line); + border-right: 0; + border-left: 0; + } + + .dashboard-widget-library-item { + flex-basis: min(250px, 72vw); + } + .dashboard-widget-small, .dashboard-widget-medium, .dashboard-widget-wide, diff --git a/webui/tests/dashboard-layout.test.ts b/webui/tests/dashboard-layout.test.ts new file mode 100644 index 0000000..6b0b126 --- /dev/null +++ b/webui/tests/dashboard-layout.test.ts @@ -0,0 +1,170 @@ +import assert from "node:assert/strict"; +import { + appendPlacement, + dashboardMasonryRowSpan, + defaultDashboardLayout, + layoutUpdatePayload, + reconcileDashboardLayout, + removePlacement, + reorderPlacement +} from "../src/features/dashboard/dashboardLayout.ts"; +import { dashboardGridPreview } from "../src/features/dashboard/dashboardEditorTypes.ts"; + +const widgets = [ + { + id: "one", + surfaceId: "one.widget", + title: "One", + moduleId: "one", + defaultSize: "small" as const, + supportedSizes: ["small", "medium"] as const, + defaultConfiguration: { count: 3 }, + render: () => null + }, + { + id: "two", + surfaceId: "two.widget", + title: "Two", + moduleId: "two", + defaultVisible: false, + defaultSize: "medium" as const, + render: () => null + } +]; + +const defaults = defaultDashboardLayout(widgets); +assert.deepEqual(defaults.knownWidgetIds, ["one", "two"]); +assert.equal(defaults.placements.length, 1); +assert.equal(defaults.placements[0].widgetId, "one"); +assert.deepEqual(defaults.placements[0].configuration, { count: 3 }); + +const withTwo = appendPlacement(defaults, widgets[1]); +assert.deepEqual( + withTwo.placements.map((placement) => placement.widgetId), + ["one", "two"] +); +assert.equal( + appendPlacement(withTwo, widgets[1]).placements.length, + 2, + "single-instance widgets must not be duplicated" +); + +const reordered = reorderPlacement( + withTwo, + withTwo.placements[1].instanceId, + withTwo.placements[0].instanceId, + "before" +); +assert.deepEqual( + reordered.placements.map((placement) => placement.widgetId), + ["two", "one"] +); + +const removed = removePlacement(reordered, reordered.placements[0].instanceId); +assert.deepEqual( + removed.placements.map((placement) => placement.widgetId), + ["one"] +); + +const placementPreview = dashboardGridPreview( + withTwo.placements, + { + kind: "placement", + instanceId: withTwo.placements[0].instanceId + }, + { + kind: "placement", + instanceId: withTwo.placements[1].instanceId, + edge: "after" + } +); +assert.deepEqual( + placementPreview.map((item) => + item.kind === "placement" + ? `${item.placement.widgetId}:${item.placeholder}` + : item.widgetId + ), + ["two:false", "one:true"], + "placement previews must show the prospective order" +); + +const cataloguePreview = dashboardGridPreview( + withTwo.placements, + { kind: "catalogue", widgetId: "three" }, + { + kind: "placement", + instanceId: withTwo.placements[0].instanceId, + edge: "after" + }, + "wide" +); +assert.deepEqual( + cataloguePreview.map((item) => + item.kind === "placement" + ? item.placement.widgetId + : `${item.widgetId}:${item.size}` + ), + ["one", "three:wide", "two"], + "catalogue previews must reserve the prospective widget size" +); + +const unavailablePlacement = { + instanceId: "unavailable-instance", + widgetId: "temporarily-disabled", + size: "wide" as const, + configuration: { mode: "summary" } +}; +const reconciled = reconcileDashboardLayout( + { + layoutVersion: 1, + revision: 4, + knownWidgetIds: ["temporarily-disabled"], + placements: [unavailablePlacement] + }, + widgets +); +assert.equal( + reconciled.placements[0], + unavailablePlacement, + "temporarily unavailable contributions must retain their placement" +); +assert.equal( + reconciled.placements[1].widgetId, + "one", + "new default-visible widgets are announced into existing layouts" +); +assert.equal(reconciled.revision, 4); + +const payload = layoutUpdatePayload(reconciled); +assert.equal(payload.expected_revision, 4); +assert.equal(payload.placements[0].instance_id, "unavailable-instance"); +assert.equal(payload.placements[0].configuration.mode, "summary"); + +const repeatingWidget = { + ...widgets[0], + id: "repeating", + allowMultiple: true, + defaultVisible: false +}; +let bounded = defaultDashboardLayout([repeatingWidget]); +for (let index = 0; index < 105; index += 1) { + bounded = appendPlacement(bounded, repeatingWidget); +} +assert.equal( + bounded.placements.length, + 100, + "client layout helpers must enforce the server placement limit" +); + +assert.equal( + dashboardMasonryRowSpan(142, 4, 18), + 40, + "masonry spans must reserve the widget height and inter-widget gap" +); +assert.equal( + dashboardMasonryRowSpan(0, Number.NaN, Number.NaN), + 1, + "masonry spans must remain valid while a widget is mounting" +); + +console.log("Dashboard layout tests passed.");