Skip to content

Commit 2e0c988

Browse files
Copilothotlong
andcommitted
fix: stabilize config draft, add widget delete, fix live preview
Critical fixes for complete ListView-parity: 1. Stabilize config references using configVersion counter pattern (matching ViewConfigPanel's stableActiveView approach) — prevents useConfigDraft from resetting the draft on every field change, so Save/Discard footer works correctly. 2. Add widget delete button via WidgetConfigPanel's new headerExtra prop — renders a Trash2 icon in the config panel header. 3. Fix dashboard config live preview — properly convert refreshInterval to number when updating editSchema. 4. Add headerExtra prop to WidgetConfigPanel component. 5. Add tests for widget deletion, live field change preview, and config panel header actions. Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 4de6992 commit 2e0c988

4 files changed

Lines changed: 167 additions & 8 deletions

File tree

apps/console/src/__tests__/DashboardDesignInteraction.test.tsx

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ vi.mock('@object-ui/plugin-dashboard', () => ({
115115
return (
116116
<div data-testid="widget-config-panel">
117117
<span data-testid="widget-config-title">{props.config?.title ?? 'none'}</span>
118+
{props.headerExtra && <div data-testid="widget-config-header-extra">{props.headerExtra}</div>}
118119
<button data-testid="widget-config-close" onClick={props.onClose}>Close</button>
119120
</div>
120121
);
@@ -253,4 +254,58 @@ describe('Dashboard Design Mode — Inline Config Panel', () => {
253254
expect(screen.getByTestId('renderer-design-mode')).toHaveTextContent('false');
254255
expect(screen.getByTestId('renderer-selected')).toHaveTextContent('none');
255256
});
257+
258+
it('should show delete button in widget config panel header', async () => {
259+
await renderDashboardView();
260+
await openConfigPanel();
261+
262+
await act(async () => {
263+
fireEvent.click(screen.getByTestId('renderer-widget-w1'));
264+
});
265+
266+
expect(screen.getByTestId('widget-config-header-extra')).toBeInTheDocument();
267+
expect(screen.getByTestId('widget-delete-button')).toBeInTheDocument();
268+
});
269+
270+
it('should remove widget and switch to dashboard config when delete is clicked', async () => {
271+
await renderDashboardView();
272+
await openConfigPanel();
273+
274+
await act(async () => {
275+
fireEvent.click(screen.getByTestId('renderer-widget-w1'));
276+
});
277+
expect(screen.getByTestId('widget-config-panel')).toBeInTheDocument();
278+
279+
// Click the delete button
280+
await act(async () => {
281+
fireEvent.click(screen.getByTestId('widget-delete-button'));
282+
});
283+
284+
// Should switch back to dashboard config (widget deselected)
285+
expect(screen.getByTestId('dashboard-config-panel')).toBeInTheDocument();
286+
expect(screen.queryByTestId('widget-config-panel')).not.toBeInTheDocument();
287+
// Deleted widget should be removed from the preview
288+
expect(screen.queryByTestId('renderer-widget-w1')).not.toBeInTheDocument();
289+
// Backend should be called to persist the deletion
290+
expect(mockUpdate).toHaveBeenCalled();
291+
});
292+
293+
it('should preserve live preview when field changes via onFieldChange', async () => {
294+
await renderDashboardView();
295+
await openConfigPanel();
296+
297+
await act(async () => {
298+
fireEvent.click(screen.getByTestId('renderer-widget-w1'));
299+
});
300+
301+
// Simulate a live field change via onFieldChange
302+
await act(async () => {
303+
widgetConfigCalls.onFieldChange?.('title', 'Live Title');
304+
});
305+
306+
// Preview should update live
307+
expect(screen.getByTestId('renderer-widget-w1')).toHaveTextContent('Live Title');
308+
// Config panel should still show the widget (not reset or disappear)
309+
expect(screen.getByTestId('widget-config-panel')).toBeInTheDocument();
310+
});
256311
});

apps/console/src/__tests__/DashboardViewSelection.test.tsx

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ vi.mock('@object-ui/plugin-dashboard', () => ({
114114
return (
115115
<div data-testid="widget-config-panel">
116116
<span data-testid="widget-config-title">{props.config?.title ?? 'none'}</span>
117+
{props.headerExtra && <div data-testid="widget-config-header-extra">{props.headerExtra}</div>}
117118
<button
118119
data-testid="widget-config-save"
119120
onClick={() => props.onSave?.({ ...props.config, title: 'Updated Revenue' })}
@@ -307,4 +308,56 @@ describe('DashboardView — Selection Sync Integration', () => {
307308
}),
308309
);
309310
});
311+
312+
it('should delete widget and persist to backend', async () => {
313+
await renderDashboardView();
314+
315+
await act(async () => {
316+
fireEvent.click(screen.getByTestId('dashboard-edit-button'));
317+
});
318+
await act(async () => {
319+
fireEvent.click(screen.getByTestId('renderer-widget-w1'));
320+
});
321+
322+
// Click delete
323+
await act(async () => {
324+
fireEvent.click(screen.getByTestId('widget-delete-button'));
325+
});
326+
327+
// Widget removed from preview
328+
expect(screen.queryByTestId('renderer-widget-w1')).not.toBeInTheDocument();
329+
// Remaining widget still present
330+
expect(screen.getByTestId('renderer-widget-w2')).toBeInTheDocument();
331+
// Backend should be called without the deleted widget
332+
expect(mockUpdate).toHaveBeenCalledWith(
333+
'sys_dashboard',
334+
'sales',
335+
expect.objectContaining({
336+
widgets: expect.not.arrayContaining([
337+
expect.objectContaining({ id: 'w1' }),
338+
]),
339+
}),
340+
);
341+
});
342+
343+
it('should update preview live when onFieldChange fires without resetting config panel', async () => {
344+
await renderDashboardView();
345+
346+
await act(async () => {
347+
fireEvent.click(screen.getByTestId('dashboard-edit-button'));
348+
});
349+
await act(async () => {
350+
fireEvent.click(screen.getByTestId('renderer-widget-w1'));
351+
});
352+
353+
// Simulate live field change
354+
await act(async () => {
355+
widgetConfigCalls.onFieldChange?.('title', 'Live Preview Title');
356+
});
357+
358+
// Preview should update
359+
expect(screen.getByTestId('renderer-widget-w1')).toHaveTextContent('Live Preview Title');
360+
// Widget config panel should still be visible
361+
expect(screen.getByTestId('widget-config-panel')).toBeInTheDocument();
362+
});
310363
});

apps/console/src/components/DashboardView.tsx

Lines changed: 55 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
DashboardConfigPanel,
1313
WidgetConfigPanel,
1414
} from '@object-ui/plugin-dashboard';
15-
import { Empty, EmptyTitle, EmptyDescription } from '@object-ui/components';
15+
import { Empty, EmptyTitle, EmptyDescription, Button } from '@object-ui/components';
1616
import {
1717
LayoutDashboard,
1818
Pencil,
@@ -23,6 +23,7 @@ import {
2323
Table2,
2424
LayoutGrid,
2525
Plus,
26+
Trash2,
2627
} from 'lucide-react';
2728
import { MetadataToggle, MetadataPanel, useMetadataInspector } from './MetadataInspector';
2829
import { SkeletonDashboard } from './skeletons';
@@ -111,6 +112,8 @@ export function DashboardView({ dataSource }: { dataSource?: any }) {
111112
const [isLoading, setIsLoading] = useState(true);
112113
const [configPanelOpen, setConfigPanelOpen] = useState(false);
113114
const [selectedWidgetId, setSelectedWidgetId] = useState<string | null>(null);
115+
// Version counter — incremented on save to refresh the stable config reference
116+
const [configVersion, setConfigVersion] = useState(0);
114117

115118
useEffect(() => {
116119
setIsLoading(true);
@@ -141,6 +144,7 @@ export function DashboardView({ dataSource }: { dataSource?: any }) {
141144
const handleOpenConfigPanel = useCallback(() => {
142145
setEditSchema(dashboard as DashboardSchema);
143146
setConfigPanelOpen(true);
147+
setConfigVersion((v) => v + 1);
144148
}, [dashboard]);
145149

146150
const handleCloseConfigPanel = useCallback(() => {
@@ -168,14 +172,35 @@ export function DashboardView({ dataSource }: { dataSource?: any }) {
168172
setEditSchema(newSchema);
169173
saveSchema(newSchema);
170174
setSelectedWidgetId(id);
175+
setConfigVersion((v) => v + 1);
171176
},
172177
[editSchema, saveSchema],
173178
);
174179

180+
const removeWidget = useCallback(
181+
(widgetId: string) => {
182+
if (!editSchema) return;
183+
const newSchema = {
184+
...editSchema,
185+
widgets: editSchema.widgets.filter((w) => w.id !== widgetId),
186+
};
187+
setEditSchema(newSchema);
188+
saveSchema(newSchema);
189+
if (selectedWidgetId === widgetId) {
190+
setSelectedWidgetId(null);
191+
}
192+
},
193+
[editSchema, selectedWidgetId, saveSchema],
194+
);
195+
175196
// ---- Dashboard config panel handlers ------------------------------------
197+
// Stabilize config reference: only recompute when panel opens or after save.
198+
// This prevents useConfigDraft from resetting the draft on every parent re-render
199+
// (same pattern as ViewConfigPanel's stableActiveView).
176200
const dashboardConfig = useMemo(
177201
() => extractDashboardConfig(editSchema || (dashboard as DashboardSchema)),
178-
[editSchema, dashboard],
202+
// eslint-disable-next-line react-hooks/exhaustive-deps
203+
[dashboardName, configVersion],
179204
);
180205

181206
const handleDashboardConfigSave = useCallback(
@@ -185,31 +210,40 @@ export function DashboardView({ dataSource }: { dataSource?: any }) {
185210
...editSchema,
186211
columns: config.columns,
187212
gap: config.gap,
188-
rowHeight: config.rowHeight,
189213
refreshInterval: Number(config.refreshInterval) || 0,
190214
title: config.title,
191-
showDescription: config.showDescription,
192-
theme: config.theme,
193215
} as DashboardSchema;
194216
setEditSchema(newSchema);
195217
saveSchema(newSchema);
218+
setConfigVersion((v) => v + 1);
196219
},
197220
[editSchema, saveSchema],
198221
);
199222

200223
const handleDashboardFieldChange = useCallback(
201224
(field: string, value: any) => {
202-
setEditSchema((prev) => (prev ? { ...prev, [field]: value } : prev));
225+
if (!editSchema) return;
226+
// Map config field keys to proper DashboardSchema updates for live preview
227+
setEditSchema((prev) => {
228+
if (!prev) return prev;
229+
if (field === 'refreshInterval') {
230+
return { ...prev, refreshInterval: Number(value) || 0 };
231+
}
232+
return { ...prev, [field]: value };
233+
});
203234
},
204-
[],
235+
[editSchema],
205236
);
206237

207238
// ---- Widget config panel handlers ---------------------------------------
208239
const selectedWidget = editSchema?.widgets?.find((w) => w.id === selectedWidgetId);
209240

241+
// Stabilize widget config: only recompute when selecting a different widget
242+
// or after save — prevents useConfigDraft from resetting the draft on live preview updates.
210243
const widgetConfig = useMemo(
211244
() => (selectedWidget ? flattenWidgetConfig(selectedWidget) : {}),
212-
[selectedWidget],
245+
// eslint-disable-next-line react-hooks/exhaustive-deps
246+
[selectedWidgetId, configVersion],
213247
);
214248

215249
const handleWidgetConfigSave = useCallback(
@@ -224,6 +258,7 @@ export function DashboardView({ dataSource }: { dataSource?: any }) {
224258
};
225259
setEditSchema(newSchema);
226260
saveSchema(newSchema);
261+
setConfigVersion((v) => v + 1);
227262
},
228263
[editSchema, selectedWidgetId, selectedWidget, saveSchema],
229264
);
@@ -336,6 +371,18 @@ export function DashboardView({ dataSource }: { dataSource?: any }) {
336371
config={widgetConfig}
337372
onSave={handleWidgetConfigSave}
338373
onFieldChange={handleWidgetFieldChange}
374+
headerExtra={
375+
<Button
376+
size="sm"
377+
variant="ghost"
378+
onClick={() => removeWidget(selectedWidgetId!)}
379+
className="h-7 w-7 p-0 text-destructive hover:text-destructive"
380+
data-testid="widget-delete-button"
381+
title="Delete widget"
382+
>
383+
<Trash2 className="h-3.5 w-3.5" />
384+
</Button>
385+
}
339386
/>
340387
) : (
341388
<DashboardConfigPanel

packages/plugin-dashboard/src/WidgetConfigPanel.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,8 @@ export interface WidgetConfigPanelProps {
177177
onSave: (config: Record<string, any>) => void;
178178
/** Optional live-update callback */
179179
onFieldChange?: (field: string, value: any) => void;
180+
/** Extra content rendered in the header row (e.g. delete button) */
181+
headerExtra?: React.ReactNode;
180182
}
181183

182184
// ---------------------------------------------------------------------------
@@ -197,6 +199,7 @@ export function WidgetConfigPanel({
197199
config,
198200
onSave,
199201
onFieldChange,
202+
headerExtra,
200203
}: WidgetConfigPanelProps) {
201204
const { draft, isDirty, updateField, discard } = useConfigDraft(config, {
202205
onUpdate: onFieldChange,
@@ -212,6 +215,7 @@ export function WidgetConfigPanel({
212215
onFieldChange={updateField}
213216
onSave={() => onSave(draft)}
214217
onDiscard={discard}
218+
headerExtra={headerExtra}
215219
/>
216220
);
217221
}

0 commit comments

Comments
 (0)