feat(web): add widget config dialog and layout management controls (#499)
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 #499.
This commit is contained in:
2026-02-24 01:11:47 +00:00
committed by jason.woltje
parent f93fa60fff
commit ff5a09c3fb
4 changed files with 253 additions and 21 deletions

View File

@@ -5,6 +5,7 @@ import type { ReactElement } from "react";
import type { WidgetPlacement } from "@mosaic/shared";
import { WidgetGrid } from "@/components/widgets/WidgetGrid";
import { WidgetPicker } from "@/components/widgets/WidgetPicker";
import { WidgetConfigDialog } from "@/components/widgets/WidgetConfigDialog";
import { DEFAULT_LAYOUT } from "@/components/widgets/defaultLayout";
import { fetchDefaultLayout, createLayout, updateLayout } from "@/lib/api/layouts";
import { useWorkspaceId } from "@/lib/hooks";
@@ -15,6 +16,7 @@ export default function DashboardPage(): ReactElement {
const [layoutId, setLayoutId] = useState<string | null>(null);
const [isEditing, setIsEditing] = useState(false);
const [isPickerOpen, setIsPickerOpen] = useState(false);
const [configWidgetId, setConfigWidgetId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
// Debounce timer for auto-saving layout changes
@@ -106,6 +108,15 @@ export default function DashboardPage(): ReactElement {
[layout, saveLayout]
);
const handleResetLayout = useCallback((): void => {
setLayout(DEFAULT_LAYOUT);
saveLayout(DEFAULT_LAYOUT);
}, [saveLayout]);
const handleEditWidget = useCallback((widgetId: string): void => {
setConfigWidgetId(widgetId);
}, []);
if (isLoading) {
return (
<div className="flex items-center justify-center" style={{ minHeight: 400 }}>
@@ -138,24 +149,42 @@ export default function DashboardPage(): ReactElement {
</h1>
<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={handleResetLayout}
style={{
padding: "6px 14px",
borderRadius: "var(--r)",
border: "1px solid var(--border)",
background: "transparent",
color: "var(--muted)",
fontSize: "0.83rem",
fontWeight: 500,
cursor: "pointer",
transition: "all 0.15s ease",
}}
>
Reset
</button>
<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 => {
@@ -184,9 +213,21 @@ export default function DashboardPage(): ReactElement {
layout={layout}
onLayoutChange={handleLayoutChange}
{...(isEditing && { onRemoveWidget: handleRemoveWidget })}
{...(isEditing && { onEditWidget: handleEditWidget })}
isEditing={isEditing}
/>
{/* Widget config dialog */}
{configWidgetId && (
<WidgetConfigDialog
widgetId={configWidgetId}
open
onClose={(): void => {
setConfigWidgetId(null);
}}
/>
)}
{/* Widget picker drawer */}
<WidgetPicker
open={isPickerOpen}

View File

@@ -0,0 +1,183 @@
/**
* WidgetConfigDialog — Per-widget settings dialog.
*
* Reads configSchema from the widget definition. When the schema is empty
* (current state for all 7 widgets), shows a placeholder message.
* As widgets gain configSchema definitions, this dialog will render
* appropriate form controls.
*/
import { useState } from "react";
import type { ReactElement } from "react";
import { getWidgetByName } from "./WidgetRegistry";
export interface WidgetConfigDialogProps {
widgetId: string;
open: boolean;
onClose: () => void;
}
export function WidgetConfigDialog({
widgetId,
open,
onClose,
}: WidgetConfigDialogProps): ReactElement | null {
const [hoverClose, setHoverClose] = useState(false);
if (!open) return null;
// Extract widget type from ID (format: "WidgetType-suffix")
const widgetType = widgetId.split("-")[0] ?? "";
const widgetDef = getWidgetByName(widgetType);
return (
<>
{/* Backdrop */}
<div
onClick={onClose}
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.4)",
zIndex: 999,
}}
aria-hidden="true"
/>
{/* Dialog */}
<div
role="dialog"
aria-label="Widget Settings"
style={{
position: "fixed",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: 420,
maxWidth: "90vw",
background: "var(--surface)",
border: "1px solid var(--border)",
borderRadius: "var(--r-lg)",
boxShadow: "var(--shadow-lg)",
zIndex: 1000,
overflow: "hidden",
}}
>
{/* Header */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "16px 20px",
borderBottom: "1px solid var(--border)",
}}
>
<div>
<h2
style={{
fontSize: "1rem",
fontWeight: 700,
color: "var(--text)",
margin: 0,
}}
>
{widgetDef?.displayName ?? "Widget"} Settings
</h2>
{widgetDef?.description && (
<p
style={{
fontSize: "0.78rem",
color: "var(--muted)",
margin: "4px 0 0",
}}
>
{widgetDef.description}
</p>
)}
</div>
<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>
{/* Content */}
<div style={{ padding: "20px" }}>
<div
style={{
padding: "24px 16px",
textAlign: "center",
borderRadius: "var(--r)",
background: "var(--surface-2)",
}}
>
<p
style={{
fontSize: "0.85rem",
color: "var(--muted)",
margin: 0,
}}
>
No configuration options available for this widget yet.
</p>
<p
style={{
fontSize: "0.78rem",
color: "var(--muted)",
margin: "8px 0 0",
opacity: 0.7,
}}
>
Widget configuration will be added in a future update.
</p>
</div>
</div>
{/* Footer */}
<div
style={{
display: "flex",
justifyContent: "flex-end",
padding: "12px 20px",
borderTop: "1px solid var(--border)",
}}
>
<button
onClick={onClose}
onMouseEnter={(): void => {
setHoverClose(true);
}}
onMouseLeave={(): void => {
setHoverClose(false);
}}
style={{
padding: "6px 16px",
borderRadius: "var(--r)",
border: "1px solid var(--border)",
background: hoverClose ? "var(--surface-2)" : "transparent",
color: "var(--text-2)",
fontSize: "0.83rem",
fontWeight: 500,
cursor: "pointer",
transition: "background 0.12s ease",
}}
>
Close
</button>
</div>
</div>
</>
);
}

View File

@@ -22,6 +22,7 @@ export interface WidgetGridProps {
layout: WidgetPlacement[];
onLayoutChange: (layout: WidgetPlacement[]) => void;
onRemoveWidget?: (widgetId: string) => void;
onEditWidget?: (widgetId: string) => void;
isEditing?: boolean;
className?: string;
}
@@ -30,6 +31,7 @@ export function WidgetGrid({
layout,
onLayoutChange,
onRemoveWidget,
onEditWidget,
isEditing = false,
className,
}: WidgetGridProps): React.JSX.Element {
@@ -183,6 +185,12 @@ export function WidgetGrid({
id={item.i}
title={widgetDef.displayName}
description={widgetDef.description}
{...(isEditing &&
onEditWidget && {
onEdit: (): void => {
onEditWidget(item.i);
},
})}
{...(isEditing &&
onRemoveWidget && {
onRemove: (): void => {

View File

@@ -10,9 +10,9 @@
| 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 | 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-WDG-003 | done | 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 | 2026-02-23 | 25K | ~12K | PR #498 merged |
| TW-WDG-004 | in-progress | Widget configuration UI — Per-widget settings dialog using configSchema, configure data source/filters/colors/title | #488 | web | feat/ms18-layout-management | TW-WDG-002 | TW-VER-001 | worker | 2026-02-23 | — | 30K | — | Bundled with WDG-005 (single PR) |
| TW-WDG-005 | in-progress | Layout management UI — Save/rename/switch/delete layouts, reset to default. UI controls in dashboard header area | #488 | web | feat/ms18-layout-management | TW-WDG-002 | TW-VER-001 | worker | 2026-02-23 | — | 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 | — | |
| TW-EDT-002 | not-started | Markdown round-trip + File Manager integration — Import markdown to Tiptap, export to markdown + HTML. Replace textarea in knowledge create/edit | #489 | web | TBD | TW-EDT-001 | TW-VER-001 | worker | — | — | 30K | — | |
| TW-KBN-001 | not-started | Kanban filtering — Add filter bar (project, assignee, priority, search). Support project-level and user-level views. URL param persistence | #490 | web | TBD | TW-PLAN-001 | TW-VER-001 | worker | — | — | 30K | — | |