feat(web): add widget config dialog and layout management controls (#499)
All checks were successful
ci/woodpecker/push/web Pipeline was successful
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:
@@ -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}
|
||||
|
||||
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[];
|
||||
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 => {
|
||||
|
||||
@@ -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 | — | |
|
||||
|
||||
Reference in New Issue
Block a user