feat(web): add widget picker drawer for dashboard customization (#498)
All checks were successful
ci/woodpecker/push/web Pipeline was successful

Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #498.
This commit is contained in:
2026-02-24 00:59:45 +00:00
committed by jason.woltje
parent cc56f2cbe1
commit f93fa60fff
3 changed files with 350 additions and 29 deletions

View File

@@ -4,6 +4,7 @@ import { useState, useEffect, useCallback, useRef } from "react";
import type { ReactElement } from "react";
import type { WidgetPlacement } from "@mosaic/shared";
import { WidgetGrid } from "@/components/widgets/WidgetGrid";
import { WidgetPicker } from "@/components/widgets/WidgetPicker";
import { DEFAULT_LAYOUT } from "@/components/widgets/defaultLayout";
import { fetchDefaultLayout, createLayout, updateLayout } from "@/lib/api/layouts";
import { useWorkspaceId } from "@/lib/hooks";
@@ -13,6 +14,7 @@ export default function DashboardPage(): ReactElement {
const [layout, setLayout] = useState<WidgetPlacement[]>(DEFAULT_LAYOUT);
const [layoutId, setLayoutId] = useState<string | null>(null);
const [isEditing, setIsEditing] = useState(false);
const [isPickerOpen, setIsPickerOpen] = useState(false);
const [isLoading, setIsLoading] = useState(true);
// Debounce timer for auto-saving layout changes
@@ -95,6 +97,15 @@ export default function DashboardPage(): ReactElement {
[layout, saveLayout]
);
const handleAddWidget = useCallback(
(placement: WidgetPlacement) => {
const updated = [...layout, placement];
setLayout(updated);
saveLayout(updated);
},
[layout, saveLayout]
);
if (isLoading) {
return (
<div className="flex items-center justify-center" style={{ minHeight: 400 }}>
@@ -125,9 +136,31 @@ export default function DashboardPage(): ReactElement {
>
Dashboard
</h1>
<div className="flex items-center gap-2">
{isEditing && (
<button
onClick={() => {
onClick={(): void => {
setIsPickerOpen(true);
}}
style={{
padding: "6px 14px",
borderRadius: "var(--r)",
border: "1px solid var(--border)",
background: "transparent",
color: "var(--text-2)",
fontSize: "0.83rem",
fontWeight: 500,
cursor: "pointer",
transition: "all 0.15s ease",
}}
>
+ Add Widget
</button>
)}
<button
onClick={(): void => {
setIsEditing((prev) => !prev);
if (isEditing) setIsPickerOpen(false);
}}
style={{
padding: "6px 14px",
@@ -144,6 +177,7 @@ export default function DashboardPage(): ReactElement {
{isEditing ? "Done" : "Edit Layout"}
</button>
</div>
</div>
{/* Widget grid */}
<WidgetGrid
@@ -152,6 +186,16 @@ export default function DashboardPage(): ReactElement {
{...(isEditing && { onRemoveWidget: handleRemoveWidget })}
isEditing={isEditing}
/>
{/* Widget picker drawer */}
<WidgetPicker
open={isPickerOpen}
onClose={(): void => {
setIsPickerOpen(false);
}}
onAddWidget={handleAddWidget}
currentLayout={layout}
/>
</div>
);
}

View File

@@ -0,0 +1,277 @@
/**
* WidgetPicker — Dialog to browse available widgets and add them to the dashboard.
*/
import { useState, useCallback } from "react";
import type { ReactElement } from "react";
import type { WidgetPlacement } from "@mosaic/shared";
import { getAllWidgets, type WidgetDefinition } from "./WidgetRegistry";
export interface WidgetPickerProps {
open: boolean;
onClose: () => void;
onAddWidget: (placement: WidgetPlacement) => void;
currentLayout: WidgetPlacement[];
}
/** Generate a unique widget ID: "WidgetType-<random>" */
function generateWidgetId(widgetName: string): string {
const suffix = Math.random().toString(36).slice(2, 8);
return `${widgetName}-${suffix}`;
}
/** Find the first open Y position at x=0 that doesn't overlap */
function findNextY(layout: WidgetPlacement[]): number {
if (layout.length === 0) return 0;
let maxBottom = 0;
for (const item of layout) {
const bottom = item.y + item.h;
if (bottom > maxBottom) maxBottom = bottom;
}
return maxBottom;
}
function WidgetPickerItem({
widget,
onAdd,
}: {
widget: WidgetDefinition;
onAdd: () => void;
}): ReactElement {
const [hovered, setHovered] = useState(false);
return (
<div
onMouseEnter={(): void => {
setHovered(true);
}}
onMouseLeave={(): void => {
setHovered(false);
}}
style={{
display: "flex",
alignItems: "center",
gap: 12,
padding: "12px 16px",
borderRadius: "var(--r)",
background: hovered ? "var(--surface-2)" : "transparent",
transition: "background 0.12s ease",
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: "0.9rem",
fontWeight: 600,
color: "var(--text)",
}}
>
{widget.displayName}
</div>
<div
style={{
fontSize: "0.78rem",
color: "var(--muted)",
marginTop: 2,
lineHeight: 1.4,
}}
>
{widget.description}
</div>
<div
style={{
fontSize: "0.7rem",
color: "var(--muted)",
marginTop: 4,
opacity: 0.7,
}}
>
Default size: {widget.defaultWidth}&times;{widget.defaultHeight}
</div>
</div>
<button
onClick={onAdd}
style={{
padding: "6px 12px",
borderRadius: "var(--r)",
border: "1px solid var(--primary)",
background: hovered ? "var(--primary)" : "transparent",
color: hovered ? "#fff" : "var(--primary-l)",
fontSize: "0.78rem",
fontWeight: 500,
cursor: "pointer",
transition: "all 0.12s ease",
whiteSpace: "nowrap",
}}
>
Add
</button>
</div>
);
}
export function WidgetPicker({
open,
onClose,
onAddWidget,
currentLayout,
}: WidgetPickerProps): ReactElement | null {
const allWidgets = getAllWidgets();
const [search, setSearch] = useState("");
const filtered = search
? allWidgets.filter(
(w) =>
w.displayName.toLowerCase().includes(search.toLowerCase()) ||
w.description.toLowerCase().includes(search.toLowerCase())
)
: allWidgets;
const handleAdd = useCallback(
(widget: WidgetDefinition) => {
const placement: WidgetPlacement = {
i: generateWidgetId(widget.name),
x: 0,
y: findNextY(currentLayout),
w: widget.defaultWidth,
h: widget.defaultHeight,
minW: widget.minWidth,
minH: widget.minHeight,
};
if (widget.maxWidth !== undefined) placement.maxW = widget.maxWidth;
if (widget.maxHeight !== undefined) placement.maxH = widget.maxHeight;
onAddWidget(placement);
},
[currentLayout, onAddWidget]
);
if (!open) return null;
return (
<>
{/* Backdrop */}
<div
onClick={onClose}
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.4)",
zIndex: 999,
}}
aria-hidden="true"
/>
{/* Panel */}
<div
role="dialog"
aria-label="Add Widget"
style={{
position: "fixed",
top: 0,
right: 0,
bottom: 0,
width: 380,
maxWidth: "90vw",
background: "var(--surface)",
borderLeft: "1px solid var(--border)",
boxShadow: "var(--shadow-lg)",
zIndex: 1000,
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}
>
{/* Header */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "16px 20px",
borderBottom: "1px solid var(--border)",
}}
>
<h2
style={{
fontSize: "1.1rem",
fontWeight: 700,
color: "var(--text)",
margin: 0,
}}
>
Add Widget
</h2>
<button
onClick={onClose}
aria-label="Close"
style={{
background: "none",
border: "none",
color: "var(--muted)",
cursor: "pointer",
padding: 4,
fontSize: "1.2rem",
lineHeight: 1,
}}
>
&times;
</button>
</div>
{/* Search */}
<div style={{ padding: "12px 20px 0" }}>
<input
type="text"
placeholder="Search widgets..."
value={search}
onChange={(e): void => {
setSearch(e.target.value);
}}
style={{
width: "100%",
padding: "8px 12px",
borderRadius: "var(--r)",
border: "1px solid var(--border)",
background: "var(--surface-2)",
color: "var(--text)",
fontSize: "0.85rem",
outline: "none",
}}
/>
</div>
{/* Widget list */}
<div
style={{
flex: 1,
overflowY: "auto",
padding: "8px 12px",
}}
>
{filtered.length === 0 ? (
<div
style={{
padding: 20,
textAlign: "center",
color: "var(--muted)",
fontSize: "0.85rem",
}}
>
No widgets found
</div>
) : (
filtered.map((widget) => (
<WidgetPickerItem
key={widget.name}
widget={widget}
onAdd={(): void => {
handleAdd(widget);
}}
/>
))
)}
</div>
</div>
</>
);
}

View File

@@ -9,8 +9,8 @@
| TW-THM-002 | done | ThemeProvider upgrade — Load themes dynamically from registry, apply CSS variables, support instant theme switching without page reload | #487 | web | feat/ms18-theme-provider-upgrade | TW-THM-001 | TW-THM-003,TW-VER-002 | worker | 2026-02-23 | 2026-02-23 | 25K | ~12K | PR #494 merged |
| TW-THM-003 | done | Theme selection UI — Settings page section with theme browser, live preview swatches, persist selection to UserPreference.theme via API | #487 | web | feat/ms18-theme-selection-ui | TW-THM-001,TW-THM-002 | TW-VER-002 | worker | 2026-02-23 | 2026-02-23 | 25K | ~10K | PR #495 merged |
| TW-WDG-001 | done | Widget definition seeding — Seed 7 existing widgets into widget_definitions table with correct sizing constraints and configSchema | #488 | api | feat/ms18-widget-seed | TW-PLAN-001 | TW-WDG-002 | worker | 2026-02-23 | 2026-02-23 | 15K | ~8K | PR #496 merged |
| TW-WDG-002 | in-progress | Dashboard → WidgetGrid migration — Replace hardcoded dashboard layout with WidgetGrid, load/save layout via UserLayout API, default layout on first visit | #488 | web | feat/ms18-widget-grid-migration | TW-WDG-001 | TW-WDG-003,TW-WDG-004,TW-WDG-005 | worker | 2026-02-23 | | 40K | — | |
| TW-WDG-003 | not-started | Widget picker UI — Drawer/dialog to browse available widgets from registry, preview size/description, add to dashboard | #488 | web | TBD | TW-WDG-002 | TW-VER-001 | worker | | — | 25K | — | |
| TW-WDG-002 | done | Dashboard → WidgetGrid migration — Replace hardcoded dashboard layout with WidgetGrid, load/save layout via UserLayout API, default layout on first visit | #488 | web | feat/ms18-widget-grid-migration | TW-WDG-001 | TW-WDG-003,TW-WDG-004,TW-WDG-005 | worker | 2026-02-23 | 2026-02-23 | 40K | ~20K | PR #497 merged |
| TW-WDG-003 | in-progress | Widget picker UI — Drawer/dialog to browse available widgets from registry, preview size/description, add to dashboard | #488 | web | feat/ms18-widget-picker | TW-WDG-002 | TW-VER-001 | worker | 2026-02-23 | — | 25K | — | |
| TW-WDG-004 | not-started | Widget configuration UI — Per-widget settings dialog using configSchema, configure data source/filters/colors/title | #488 | web | TBD | TW-WDG-002 | TW-VER-001 | worker | — | — | 30K | — | |
| TW-WDG-005 | not-started | Layout management UI — Save/rename/switch/delete layouts, reset to default. UI controls in dashboard header area | #488 | web | TBD | TW-WDG-002 | TW-VER-001 | worker | — | — | 20K | — | |
| TW-EDT-001 | not-started | Tiptap integration — Install @tiptap/react + extensions, build KnowledgeEditor component with toolbar (headings, bold, italic, lists, code, links, tables) | #489 | web | TBD | TW-PLAN-001 | TW-EDT-002 | worker | — | — | 35K | — | |
@@ -24,11 +24,11 @@
## Summary
| Metric | Value |
| ------------- | ------------------------- |
| ------------- | -------------------------------------- |
| Total tasks | 16 |
| Completed | 5 (PLAN-001, THM-001003, WDG-001) |
| Completed | 6 (PLAN-001, THM-001003, WDG-001002) |
| In Progress | 0 |
| Remaining | 11 |
| PRs merged | #493, #494, #495, #496 |
| Remaining | 10 |
| PRs merged | #493, #494, #495, #496, #497 |
| Issues closed | — |
| Milestone | MS18-ThemeWidgets |