feat(web): add widget config dialog and layout management controls #499
@@ -5,6 +5,7 @@ import type { ReactElement } from "react";
|
|||||||
import type { WidgetPlacement } from "@mosaic/shared";
|
import type { WidgetPlacement } from "@mosaic/shared";
|
||||||
import { WidgetGrid } from "@/components/widgets/WidgetGrid";
|
import { WidgetGrid } from "@/components/widgets/WidgetGrid";
|
||||||
import { WidgetPicker } from "@/components/widgets/WidgetPicker";
|
import { WidgetPicker } from "@/components/widgets/WidgetPicker";
|
||||||
|
import { WidgetConfigDialog } from "@/components/widgets/WidgetConfigDialog";
|
||||||
import { DEFAULT_LAYOUT } from "@/components/widgets/defaultLayout";
|
import { DEFAULT_LAYOUT } from "@/components/widgets/defaultLayout";
|
||||||
import { fetchDefaultLayout, createLayout, updateLayout } from "@/lib/api/layouts";
|
import { fetchDefaultLayout, createLayout, updateLayout } from "@/lib/api/layouts";
|
||||||
import { useWorkspaceId } from "@/lib/hooks";
|
import { useWorkspaceId } from "@/lib/hooks";
|
||||||
@@ -15,6 +16,7 @@ export default function DashboardPage(): ReactElement {
|
|||||||
const [layoutId, setLayoutId] = useState<string | null>(null);
|
const [layoutId, setLayoutId] = useState<string | null>(null);
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [isPickerOpen, setIsPickerOpen] = useState(false);
|
const [isPickerOpen, setIsPickerOpen] = useState(false);
|
||||||
|
const [configWidgetId, setConfigWidgetId] = useState<string | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
// Debounce timer for auto-saving layout changes
|
// Debounce timer for auto-saving layout changes
|
||||||
@@ -106,6 +108,15 @@ export default function DashboardPage(): ReactElement {
|
|||||||
[layout, saveLayout]
|
[layout, saveLayout]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleResetLayout = useCallback((): void => {
|
||||||
|
setLayout(DEFAULT_LAYOUT);
|
||||||
|
saveLayout(DEFAULT_LAYOUT);
|
||||||
|
}, [saveLayout]);
|
||||||
|
|
||||||
|
const handleEditWidget = useCallback((widgetId: string): void => {
|
||||||
|
setConfigWidgetId(widgetId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center" style={{ minHeight: 400 }}>
|
<div className="flex items-center justify-center" style={{ minHeight: 400 }}>
|
||||||
@@ -138,24 +149,42 @@ export default function DashboardPage(): ReactElement {
|
|||||||
</h1>
|
</h1>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{isEditing && (
|
{isEditing && (
|
||||||
<button
|
<>
|
||||||
onClick={(): void => {
|
<button
|
||||||
setIsPickerOpen(true);
|
onClick={handleResetLayout}
|
||||||
}}
|
style={{
|
||||||
style={{
|
padding: "6px 14px",
|
||||||
padding: "6px 14px",
|
borderRadius: "var(--r)",
|
||||||
borderRadius: "var(--r)",
|
border: "1px solid var(--border)",
|
||||||
border: "1px solid var(--border)",
|
background: "transparent",
|
||||||
background: "transparent",
|
color: "var(--muted)",
|
||||||
color: "var(--text-2)",
|
fontSize: "0.83rem",
|
||||||
fontSize: "0.83rem",
|
fontWeight: 500,
|
||||||
fontWeight: 500,
|
cursor: "pointer",
|
||||||
cursor: "pointer",
|
transition: "all 0.15s ease",
|
||||||
transition: "all 0.15s ease",
|
}}
|
||||||
}}
|
>
|
||||||
>
|
Reset
|
||||||
+ Add Widget
|
</button>
|
||||||
</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
|
<button
|
||||||
onClick={(): void => {
|
onClick={(): void => {
|
||||||
@@ -184,9 +213,21 @@ export default function DashboardPage(): ReactElement {
|
|||||||
layout={layout}
|
layout={layout}
|
||||||
onLayoutChange={handleLayoutChange}
|
onLayoutChange={handleLayoutChange}
|
||||||
{...(isEditing && { onRemoveWidget: handleRemoveWidget })}
|
{...(isEditing && { onRemoveWidget: handleRemoveWidget })}
|
||||||
|
{...(isEditing && { onEditWidget: handleEditWidget })}
|
||||||
isEditing={isEditing}
|
isEditing={isEditing}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Widget config dialog */}
|
||||||
|
{configWidgetId && (
|
||||||
|
<WidgetConfigDialog
|
||||||
|
widgetId={configWidgetId}
|
||||||
|
open
|
||||||
|
onClose={(): void => {
|
||||||
|
setConfigWidgetId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Widget picker drawer */}
|
{/* Widget picker drawer */}
|
||||||
<WidgetPicker
|
<WidgetPicker
|
||||||
open={isPickerOpen}
|
open={isPickerOpen}
|
||||||
|
|||||||
183
apps/web/src/components/widgets/WidgetConfigDialog.tsx
Normal file
183
apps/web/src/components/widgets/WidgetConfigDialog.tsx
Normal 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,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ export interface WidgetGridProps {
|
|||||||
layout: WidgetPlacement[];
|
layout: WidgetPlacement[];
|
||||||
onLayoutChange: (layout: WidgetPlacement[]) => void;
|
onLayoutChange: (layout: WidgetPlacement[]) => void;
|
||||||
onRemoveWidget?: (widgetId: string) => void;
|
onRemoveWidget?: (widgetId: string) => void;
|
||||||
|
onEditWidget?: (widgetId: string) => void;
|
||||||
isEditing?: boolean;
|
isEditing?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
@@ -30,6 +31,7 @@ export function WidgetGrid({
|
|||||||
layout,
|
layout,
|
||||||
onLayoutChange,
|
onLayoutChange,
|
||||||
onRemoveWidget,
|
onRemoveWidget,
|
||||||
|
onEditWidget,
|
||||||
isEditing = false,
|
isEditing = false,
|
||||||
className,
|
className,
|
||||||
}: WidgetGridProps): React.JSX.Element {
|
}: WidgetGridProps): React.JSX.Element {
|
||||||
@@ -183,6 +185,12 @@ export function WidgetGrid({
|
|||||||
id={item.i}
|
id={item.i}
|
||||||
title={widgetDef.displayName}
|
title={widgetDef.displayName}
|
||||||
description={widgetDef.description}
|
description={widgetDef.description}
|
||||||
|
{...(isEditing &&
|
||||||
|
onEditWidget && {
|
||||||
|
onEdit: (): void => {
|
||||||
|
onEditWidget(item.i);
|
||||||
|
},
|
||||||
|
})}
|
||||||
{...(isEditing &&
|
{...(isEditing &&
|
||||||
onRemoveWidget && {
|
onRemoveWidget && {
|
||||||
onRemove: (): void => {
|
onRemove: (): void => {
|
||||||
|
|||||||
@@ -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-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-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-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-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 | 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-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 | 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-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-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-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 | — | |
|
| 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 | — | |
|
||||||
|
|||||||
Reference in New Issue
Block a user