feat(web): add widget picker drawer for dashboard customization
All checks were successful
ci/woodpecker/push/web Pipeline was successful
All checks were successful
ci/woodpecker/push/web Pipeline was successful
Add a slide-out drawer UI to browse available widgets from the registry, search by name/description, and add them to the dashboard. The picker is accessible via "Add Widget" button in edit mode. - WidgetPicker component with search, widget list, and one-click add - Generates unique widget IDs and places new widgets below existing ones - Integrated into dashboard page header (edit mode only) Ref: #488 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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,24 +136,47 @@ export default function DashboardPage(): ReactElement {
|
||||
>
|
||||
Dashboard
|
||||
</h1>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsEditing((prev) => !prev);
|
||||
}}
|
||||
style={{
|
||||
padding: "6px 14px",
|
||||
borderRadius: "var(--r)",
|
||||
border: isEditing ? "1px solid var(--primary)" : "1px solid var(--border)",
|
||||
background: isEditing ? "var(--primary)" : "transparent",
|
||||
color: isEditing ? "#fff" : "var(--text-2)",
|
||||
fontSize: "0.83rem",
|
||||
fontWeight: 500,
|
||||
cursor: "pointer",
|
||||
transition: "all 0.15s ease",
|
||||
}}
|
||||
>
|
||||
{isEditing ? "Done" : "Edit Layout"}
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
{isEditing && (
|
||||
<button
|
||||
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",
|
||||
borderRadius: "var(--r)",
|
||||
border: isEditing ? "1px solid var(--primary)" : "1px solid var(--border)",
|
||||
background: isEditing ? "var(--primary)" : "transparent",
|
||||
color: isEditing ? "#fff" : "var(--text-2)",
|
||||
fontSize: "0.83rem",
|
||||
fontWeight: 500,
|
||||
cursor: "pointer",
|
||||
transition: "all 0.15s ease",
|
||||
}}
|
||||
>
|
||||
{isEditing ? "Done" : "Edit Layout"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Widget grid */}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
277
apps/web/src/components/widgets/WidgetPicker.tsx
Normal file
277
apps/web/src/components/widgets/WidgetPicker.tsx
Normal 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}×{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,
|
||||
}}
|
||||
>
|
||||
×
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 | — | |
|
||||
@@ -23,12 +23,12 @@
|
||||
|
||||
## Summary
|
||||
|
||||
| Metric | Value |
|
||||
| ------------- | ------------------------- |
|
||||
| Total tasks | 16 |
|
||||
| Completed | 5 (PLAN-001, THM-001–003, WDG-001) |
|
||||
| In Progress | 0 |
|
||||
| Remaining | 11 |
|
||||
| PRs merged | #493, #494, #495, #496 |
|
||||
| Issues closed | — |
|
||||
| Milestone | MS18-ThemeWidgets |
|
||||
| Metric | Value |
|
||||
| ------------- | -------------------------------------- |
|
||||
| Total tasks | 16 |
|
||||
| Completed | 6 (PLAN-001, THM-001–003, WDG-001–002) |
|
||||
| In Progress | 0 |
|
||||
| Remaining | 10 |
|
||||
| PRs merged | #493, #494, #495, #496, #497 |
|
||||
| Issues closed | — |
|
||||
| Milestone | MS18-ThemeWidgets |
|
||||
|
||||
Reference in New Issue
Block a user