import { useReducer, useCallback, useRef, useEffect } from "react"; import { nanoid } from "nanoid"; import type { DashboardLayout, LayoutItem, Widget, } from "~/presenters/v3/MetricDashboardPresenter.server"; import type { WidgetData, QueryWidgetConfig } from "~/components/metrics/QueryWidget"; // ============================================================================ // Types // ============================================================================ type EditorMode = null | { type: "add" } | { type: "edit"; widgetId: string; widget: WidgetData }; type DashboardState = { /** The layout items (positions/sizes) */ layout: LayoutItem[]; /** The widget configurations keyed by widget ID */ widgets: Record; /** Current editor mode (add/edit/closed) */ editorMode: EditorMode; }; // ============================================================================ // Actions // ============================================================================ type DashboardAction = | { type: "ADD_WIDGET"; payload: { id: string; widget: Widget; layoutItem: LayoutItem } } | { type: "UPDATE_WIDGET"; payload: { id: string; widget: Widget } } | { type: "RENAME_WIDGET"; payload: { id: string; title: string } } | { type: "DELETE_WIDGET"; payload: { id: string } } | { type: "DUPLICATE_WIDGET"; payload: { id: string; newId: string } } | { type: "UPDATE_LAYOUT"; payload: { layout: LayoutItem[] } } | { type: "RESET_STATE"; payload: { layout: LayoutItem[]; widgets: Record } } | { type: "OPEN_ADD_EDITOR" } | { type: "OPEN_EDIT_EDITOR"; payload: { widgetId: string; widget: WidgetData } } | { type: "CLOSE_EDITOR" }; // ============================================================================ // Reducer // ============================================================================ function dashboardReducer(state: DashboardState, action: DashboardAction): DashboardState { switch (action.type) { case "ADD_WIDGET": return { ...state, layout: [...state.layout, action.payload.layoutItem], widgets: { ...state.widgets, [action.payload.id]: action.payload.widget, }, editorMode: null, }; case "UPDATE_WIDGET": return { ...state, widgets: { ...state.widgets, [action.payload.id]: action.payload.widget, }, editorMode: null, }; case "RENAME_WIDGET": { const existingWidget = state.widgets[action.payload.id]; if (!existingWidget) return state; return { ...state, widgets: { ...state.widgets, [action.payload.id]: { ...existingWidget, title: action.payload.title, }, }, }; } case "DELETE_WIDGET": { const { [action.payload.id]: _, ...remainingWidgets } = state.widgets; return { ...state, layout: state.layout.filter((item) => item.i !== action.payload.id), widgets: remainingWidgets, }; } case "DUPLICATE_WIDGET": { const original = state.widgets[action.payload.id]; const originalLayout = state.layout.find((l) => l.i === action.payload.id); if (!original || !originalLayout) return state; const maxBottom = Math.max(0, ...state.layout.map((l) => l.y + l.h)); // Deep copy the widget to ensure no shared references // This prevents edits to one widget from affecting the duplicate const duplicatedWidget: Widget = { title: `${original.title} (Copy)`, query: original.query, display: JSON.parse(JSON.stringify(original.display)) as QueryWidgetConfig, }; return { ...state, layout: [ ...state.layout, { ...originalLayout, i: action.payload.newId, y: maxBottom, x: 0 }, ], widgets: { ...state.widgets, [action.payload.newId]: duplicatedWidget, }, }; } case "UPDATE_LAYOUT": return { ...state, layout: action.payload.layout }; case "RESET_STATE": return { ...state, layout: action.payload.layout, widgets: action.payload.widgets, }; case "OPEN_ADD_EDITOR": return { ...state, editorMode: { type: "add" } }; case "OPEN_EDIT_EDITOR": return { ...state, editorMode: { type: "edit", widgetId: action.payload.widgetId, widget: action.payload.widget, }, }; case "CLOSE_EDITOR": return { ...state, editorMode: null }; default: return state; } } // ============================================================================ // Hook Options // ============================================================================ export type UseDashboardEditorOptions = { /** Initial dashboard layout data from the server */ initialData: DashboardLayout; /** URL for widget actions (add, update, delete, duplicate) */ widgetActionUrl: string; /** URL for layout updates. If empty or not provided, uses current page URL. */ layoutActionUrl?: string; /** Maximum number of widgets allowed per dashboard. If not provided, no limit is enforced. */ widgetLimit?: number; /** Callback when a sync error occurs */ onSyncError?: (error: Error, action: string) => void; /** Callback when a widget action is blocked by the limit */ onWidgetLimitReached?: () => void; }; // ============================================================================ // Sync Queue Types // ============================================================================ type WidgetSyncTask = { type: "widget"; action: string; data: Record; }; type LayoutSyncTask = { type: "layout"; layout: LayoutItem[]; }; type SyncTask = WidgetSyncTask | LayoutSyncTask; // ============================================================================ // Hook // ============================================================================ export function useDashboardEditor({ initialData, widgetActionUrl, layoutActionUrl, widgetLimit, onSyncError, onWidgetLimitReached, }: UseDashboardEditorOptions) { const [state, dispatch] = useReducer(dashboardReducer, { layout: initialData.layout, widgets: initialData.widgets, editorMode: null, }); // Refs for debouncing and tracking initialization const layoutDebounceRef = useRef | null>(null); const isInitializedRef = useRef(false); const currentLayoutJsonRef = useRef(JSON.stringify(initialData.layout)); // Sync queue to prevent race conditions const syncQueueRef = useRef([]); const isSyncingRef = useRef(false); // Reset state when initialData changes (e.g., navigating to different dashboard) const initialDataJson = JSON.stringify({ layout: initialData.layout, widgets: initialData.widgets, }); useEffect(() => { // Cancel any pending layout save if (layoutDebounceRef.current) { clearTimeout(layoutDebounceRef.current); layoutDebounceRef.current = null; } // Clear the sync queue when switching dashboards syncQueueRef.current = []; // Reset state to new initial data dispatch({ type: "RESET_STATE", payload: { layout: initialData.layout, widgets: initialData.widgets }, }); // Update refs currentLayoutJsonRef.current = JSON.stringify(initialData.layout); isInitializedRef.current = false; // Allow saves after a short delay to skip initial mount callbacks const initTimeout = setTimeout(() => { isInitializedRef.current = true; }, 100); return () => { clearTimeout(initTimeout); if (layoutDebounceRef.current) { clearTimeout(layoutDebounceRef.current); } }; }, [initialDataJson]); // ------------------------------------------------------------------------- // Sync queue processor - ensures only one sync runs at a time // ------------------------------------------------------------------------- const processNextSync = useCallback(async () => { // If already syncing or queue is empty, do nothing if (isSyncingRef.current || syncQueueRef.current.length === 0) { return; } isSyncingRef.current = true; const task = syncQueueRef.current.shift()!; try { if (task.type === "widget") { const formData = new FormData(); formData.set("action", task.action); Object.entries(task.data).forEach(([k, v]) => formData.set(k, v)); const response = await fetch(widgetActionUrl, { method: "POST", body: formData, }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Failed to ${task.action} widget: ${errorText}`); } } else if (task.type === "layout") { const formData = new FormData(); formData.set("action", "layout"); formData.set("layout", JSON.stringify(task.layout)); // Use current page URL if layoutActionUrl is not provided const url = layoutActionUrl || window.location.pathname; const response = await fetch(url, { method: "POST", body: formData, }); if (!response.ok) { const errorText = await response.text(); throw new Error("Failed to update layout: " + errorText); } } } catch (error) { console.error(`Dashboard sync error:`, error); const actionName = task.type === "widget" ? task.action : "layout"; onSyncError?.(error instanceof Error ? error : new Error(String(error)), actionName); } finally { isSyncingRef.current = false; // Process next item in queue processNextSync(); } }, [widgetActionUrl, layoutActionUrl, onSyncError]); // ------------------------------------------------------------------------- // Queue helpers // ------------------------------------------------------------------------- const queueWidgetSync = useCallback( (action: string, data: Record) => { syncQueueRef.current.push({ type: "widget", action, data }); processNextSync(); }, [processNextSync] ); const queueLayoutSync = useCallback( (layout: LayoutItem[]) => { // For layout syncs, we only care about the latest state // Remove any pending layout syncs and add the new one syncQueueRef.current = syncQueueRef.current.filter((task) => task.type !== "layout"); syncQueueRef.current.push({ type: "layout", layout }); processNextSync(); }, [processNextSync] ); // ------------------------------------------------------------------------- // Action handlers // ------------------------------------------------------------------------- // Count only non-title widgets for limit checks (title widgets are free) const countedWidgets = Object.values(state.widgets).filter( (w) => w.display.type !== "title" ).length; const addWidget = useCallback( (title: string, query: string, config: QueryWidgetConfig) => { // Guard: check widget limit (title widgets don't count) if (widgetLimit !== undefined && countedWidgets >= widgetLimit) { onWidgetLimitReached?.(); return; } const id = nanoid(8); const maxBottom = Math.max(0, ...state.layout.map((l) => l.y + l.h)); const layoutItem: LayoutItem = { i: id, x: 0, y: maxBottom, w: 12, h: 15 }; const widget: Widget = { title, query, display: config }; // Update local state immediately dispatch({ type: "ADD_WIDGET", payload: { id, widget, layoutItem } }); // Queue sync to server (processed sequentially) // Send the client-generated ID so the server uses the same ID queueWidgetSync("add", { widgetId: id, title, query, config: JSON.stringify(config), }); }, [state.layout, countedWidgets, widgetLimit, onWidgetLimitReached, queueWidgetSync] ); const addTitleWidget = useCallback( (title: string) => { const id = nanoid(8); const maxBottom = Math.max(0, ...state.layout.map((l) => l.y + l.h)); // Title widgets are fixed at h=2 and full width const layoutItem: LayoutItem = { i: id, x: 0, y: maxBottom, w: 12, h: 2 }; const config: QueryWidgetConfig = { type: "title" }; const widget: Widget = { title, query: "", display: config }; // Update local state immediately dispatch({ type: "ADD_WIDGET", payload: { id, widget, layoutItem } }); // Queue sync to server (processed sequentially) // Send the client-generated ID so the server uses the same ID queueWidgetSync("add", { widgetId: id, title, query: "", config: JSON.stringify(config), }); }, [state.layout, queueWidgetSync] ); const updateWidget = useCallback( (widgetId: string, title: string, query: string, config: QueryWidgetConfig) => { const widget: Widget = { title, query, display: config }; // Update local state immediately dispatch({ type: "UPDATE_WIDGET", payload: { id: widgetId, widget } }); // Queue sync to server (processed sequentially) queueWidgetSync("update", { widgetId, title, query, config: JSON.stringify(config), }); }, [queueWidgetSync] ); const deleteWidget = useCallback( (widgetId: string) => { // Update local state immediately dispatch({ type: "DELETE_WIDGET", payload: { id: widgetId } }); // Queue sync to server (processed sequentially) queueWidgetSync("delete", { widgetId }); }, [queueWidgetSync] ); const duplicateWidget = useCallback( (widgetId: string) => { // Guard: check widget limit (title widgets don't count) if (widgetLimit !== undefined && countedWidgets >= widgetLimit) { onWidgetLimitReached?.(); return; } const newId = nanoid(8); // Update local state immediately dispatch({ type: "DUPLICATE_WIDGET", payload: { id: widgetId, newId } }); // Queue sync to server (processed sequentially) // Send the client-generated newId so the server uses the same ID for the duplicate queueWidgetSync("duplicate", { widgetId, newId }); }, [countedWidgets, widgetLimit, onWidgetLimitReached, queueWidgetSync] ); const renameWidget = useCallback( (widgetId: string, title: string) => { // Update local state immediately dispatch({ type: "RENAME_WIDGET", payload: { id: widgetId, title } }); // Queue sync to server (processed sequentially) queueWidgetSync("rename", { widgetId, title }); }, [queueWidgetSync] ); const updateLayout = useCallback( (newLayout: LayoutItem[]) => { // Skip if not yet initialized (prevents saving during mount/navigation) if (!isInitializedRef.current) { return; } const newLayoutJson = JSON.stringify(newLayout); // Skip if layout hasn't actually changed if (newLayoutJson === currentLayoutJsonRef.current) { return; } // Update local state immediately dispatch({ type: "UPDATE_LAYOUT", payload: { layout: newLayout } }); // Clear existing debounce timeout if (layoutDebounceRef.current) { clearTimeout(layoutDebounceRef.current); } // Debounce before queueing - this ensures rapid layout changes // (like dragging) don't queue up many requests layoutDebounceRef.current = setTimeout(() => { currentLayoutJsonRef.current = newLayoutJson; // Queue layout sync (replaces any pending layout sync in queue) queueLayoutSync(newLayout); }, 500); }, [queueLayoutSync] ); const openAddEditor = useCallback(() => { dispatch({ type: "OPEN_ADD_EDITOR" }); }, []); const openEditEditor = useCallback((widgetId: string, widget: WidgetData) => { dispatch({ type: "OPEN_EDIT_EDITOR", payload: { widgetId, widget } }); }, []); const closeEditor = useCallback(() => { dispatch({ type: "CLOSE_EDITOR" }); }, []); // ------------------------------------------------------------------------- // Return value // ------------------------------------------------------------------------- return { /** Current dashboard state */ state, /** Action dispatchers */ actions: { addWidget, addTitleWidget, updateWidget, renameWidget, deleteWidget, duplicateWidget, updateLayout, openAddEditor, openEditEditor, closeEditor, }, }; }