feat(web): add widget config dialog and layout management controls
All checks were successful
ci/woodpecker/push/web Pipeline was successful
All checks were successful
ci/woodpecker/push/web Pipeline was successful
Add per-widget configuration dialog (WidgetConfigDialog) with settings infrastructure ready for future configSchema-based forms. Add layout management controls: reset to default, edit/done toggle, and widget gear icon for configuration. Wire onEditWidget through WidgetGrid to BaseWidget. Implements TW-WDG-004 and TW-WDG-005. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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 => {
|
||||
|
||||
Reference in New Issue
Block a user