feat(05-01): dashboard grid, clock widget, widget registry, store, and tests
- Install react-grid-layout@2.2.3 and react-resizable - Create widget-registry.tsx with all 4 widget types, WIDGET_CONSTRAINTS, WidgetProps - Create dashboard-api.ts with fetch/save layout and widget CRUD functions - Create dashboard-store.ts (Zustand, NO persist — D-05) with edit mode and auto-save on exit - Create DashboardGrid with react-grid-layout v2 Responsive, ResizeObserver width - Create ClockWidget using Intl.DateTimeFormat (no manual UTC offsets) - Create WidgetWrapper with drag handle and delete button in edit mode - Create EditModeToggle (pencil/checkmark), WidgetCatalogModal (2x2 grid) - Rewrite portal page.tsx as dashboard with grid, edit toggle, widget catalog - Add ResizeObserver polyfill in test setup, CSS mock support in vitest config - All 5 tests green (dashboard grid + clock widget) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
import { create } from 'zustand';
|
||||
import * as api from '@/lib/dashboard-api';
|
||||
import type { WidgetType } from '@/components/dashboard/widget-registry';
|
||||
import { WIDGET_CONSTRAINTS } from '@/components/dashboard/widget-registry';
|
||||
|
||||
export interface WidgetInstance {
|
||||
id: string;
|
||||
widgetType: string;
|
||||
config: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface DashboardState {
|
||||
layouts: Record<string, Array<{ i: string; x: number; y: number; w: number; h: number }>>;
|
||||
widgets: WidgetInstance[];
|
||||
isEditMode: boolean;
|
||||
isDirty: boolean;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
|
||||
setEditMode: (mode: boolean) => void;
|
||||
updateLayouts: (layouts: Record<string, unknown>) => void;
|
||||
addWidget: (widgetType: WidgetType) => Promise<void>;
|
||||
removeWidget: (id: string) => Promise<void>;
|
||||
loadDashboard: () => Promise<void>;
|
||||
saveLayout: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard Zustand store. NO persist middleware — layout comes from DB (D-05).
|
||||
* Save only on exiting edit mode (D-01), not on every drag.
|
||||
*/
|
||||
export const useDashboardStore = create<DashboardState>()((set, get) => ({
|
||||
layouts: { lg: [], md: [], sm: [], xs: [], xxs: [] },
|
||||
widgets: [],
|
||||
isEditMode: false,
|
||||
isDirty: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
setEditMode: (mode: boolean) => {
|
||||
const prev = get().isEditMode;
|
||||
set({ isEditMode: mode });
|
||||
// On exit edit mode, auto-save if dirty (D-01)
|
||||
if (prev && !mode && get().isDirty) {
|
||||
get().saveLayout();
|
||||
}
|
||||
},
|
||||
|
||||
updateLayouts: (layouts) => {
|
||||
set({
|
||||
layouts: layouts as DashboardState['layouts'],
|
||||
isDirty: true,
|
||||
});
|
||||
},
|
||||
|
||||
addWidget: async (widgetType: WidgetType) => {
|
||||
try {
|
||||
const newWidget = await api.addWidget(widgetType);
|
||||
const constraints = WIDGET_CONSTRAINTS[widgetType];
|
||||
|
||||
set((state) => {
|
||||
// Add default layout entry for all breakpoints
|
||||
const newLayouts = { ...state.layouts };
|
||||
for (const bp of Object.keys(newLayouts)) {
|
||||
const arr = [...(newLayouts[bp] || [])];
|
||||
// Place at next available y position
|
||||
const maxY = arr.reduce((max, item) => Math.max(max, item.y + item.h), 0);
|
||||
arr.push({
|
||||
i: newWidget.id,
|
||||
x: 0,
|
||||
y: maxY,
|
||||
w: constraints.defaultW,
|
||||
h: constraints.defaultH,
|
||||
});
|
||||
newLayouts[bp] = arr;
|
||||
}
|
||||
|
||||
return {
|
||||
widgets: [...state.widgets, newWidget],
|
||||
layouts: newLayouts,
|
||||
isDirty: true,
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
set({ error: 'Failed to add widget' });
|
||||
}
|
||||
},
|
||||
|
||||
removeWidget: async (id: string) => {
|
||||
try {
|
||||
await api.removeWidget(id);
|
||||
set((state) => {
|
||||
const newLayouts = { ...state.layouts };
|
||||
for (const bp of Object.keys(newLayouts)) {
|
||||
newLayouts[bp] = newLayouts[bp].filter((item) => item.i !== id);
|
||||
}
|
||||
return {
|
||||
widgets: state.widgets.filter((w) => w.id !== id),
|
||||
layouts: newLayouts,
|
||||
isDirty: true,
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
set({ error: 'Failed to remove widget' });
|
||||
}
|
||||
},
|
||||
|
||||
loadDashboard: async () => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const [layouts, widgets] = await Promise.all([
|
||||
api.fetchLayout(),
|
||||
api.fetchWidgets(),
|
||||
]);
|
||||
set({
|
||||
layouts: layouts as DashboardState['layouts'],
|
||||
widgets,
|
||||
isLoading: false,
|
||||
});
|
||||
} catch {
|
||||
set({
|
||||
error: 'Failed to load dashboard',
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
saveLayout: async () => {
|
||||
try {
|
||||
await api.saveLayout(get().layouts);
|
||||
set({ isDirty: false });
|
||||
} catch {
|
||||
set({ error: 'Failed to save layout' });
|
||||
}
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user