diff --git a/docs/adr/0003-dashboard-grid-layout.md b/docs/adr/0003-dashboard-grid-layout.md new file mode 100644 index 0000000..57cc2c4 --- /dev/null +++ b/docs/adr/0003-dashboard-grid-layout.md @@ -0,0 +1,59 @@ +# ADR 0003: Dashboard Widget Grid Layout with react-grid-layout + +| Field | Value | +|-------|-------| +| **Status** | Proposed | +| **Date** | 2026-07-16 | +| **Authors** | Engineering | +| **Reviewers** | _TBD_ | +| **Supersedes** | — | +| **Related** | ADR 0001 (route owns layout), [state-management guide](../state-management.md) | + +--- + +## Summary + +Adopt **react-grid-layout v2** for arranging the authenticated sessions dashboard widgets (drag + resize), with an explicit **edit mode** and **feature-local `localStorage`** persistence. Layout state stays out of Zustand and nuqs. + +--- + +## Context + +The dashboard previously used two hardcoded flex rows (≈80/20 + 60/40). Users could not rearrange widgets. A reorder-only library (`@dnd-kit`) would not provide sizing or responsive grid math. Server-synced preferences are out of scope for v1. + +Constraints: + +- Launch form and session cards are dense interactive UIs — whole-widget drag would steal clicks. +- ADR 0001: the route/feature owns layout; Zustand is for cross-route UI. +- Below `md`, drag/resize is awkward on touch — stack via responsive layouts instead. + +--- + +## Decision + +1. **Library:** `react-grid-layout@^2` (hooks API: `ResponsiveGridLayout`, `useContainerWidth`). +2. **Interaction:** Customize toggle enables drag (handle-only) and resize; default view has both disabled. +3. **Persistence:** Versioned blob in `localStorage` (`canfar-dashboard-layout-v10`) with layouts + hidden widget ids, loaded/merged via `useDashboardLayout`. Not cleared on logout. Grid density is 24 columns / 40px rows for finer resize steps. +4. **Chrome:** Drag handle + hide (eye) control on `DashboardWidget` while editing; hidden widgets appear in an “Available widgets” tray under the toolbar. `SessionModalsHost` stays outside the grid. + +--- + +## Consequences + +### Positive + +- Users can rearrange and resize the four widgets and keep the layout across reloads. +- Edit mode protects form controls and card actions. +- Forward-compatible merge fills in new widget ids when the catalog grows. + +### Trade-offs + +- Extra client bundle for one route (acceptable; can lazy-load later). +- Mouse-first rearrange; keyboard reorder is a follow-up. +- No cross-device sync until a preferences API exists. + +### Follow-ups + +- Server-backed per-user layouts +- Add/remove/hide widgets catalog +- Keyboard-only rearrange diff --git a/docs/adr/README.md b/docs/adr/README.md index 20e05f4..0d4c9ed 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -16,6 +16,8 @@ Each ADR follows a structured format: | ADR | Title | Status | |-----|-------|--------| | [0001](./0001-client-state-management.md) | Client State Management with Zustand | Proposed | +| [0002](./0002-portal-modal-unification.md) | Portal Modal Unification | Proposed | +| [0003](./0003-dashboard-grid-layout.md) | Dashboard Widget Grid Layout with react-grid-layout | Proposed | ## Related guides diff --git a/docs/state-management.md b/docs/state-management.md index 4a0742a..6df1074 100644 --- a/docs/state-management.md +++ b/docs/state-management.md @@ -15,7 +15,8 @@ Developer reference for the Science Portal. Architectural rationale and decision | Deploy-time config | **React Context** | `useCanfar`, `serviceUrls`, `basePath` | | Bookmarkable / shareable | **nuqs** | File path `?path=`, session filters | | Cross-route UI, not in URL | **Zustand** | Upload queue, auth modals, multi-select | -| Single component, ephemeral | **local `useState`** | Form fields, MUI `anchorEl` | +| Route-owned UI preference (browser) | **Feature-local `localStorage`** | Dashboard widget grid layout | +| Single component, ephemeral | **local `useState`** | Form fields, MUI `anchorEl`, layout edit mode | --- @@ -141,6 +142,37 @@ src/lib/stores/ --- +## Feature-local preferences (`localStorage`) + +### When to use + +- Preference belongs to **one route/feature**, not cross-route orchestration +- Value should survive reload in the same browser +- Not shareable via URL and not server-backed (yet) + +### Dashboard layout + +The sessions dashboard (`src/lib/features/sessions/`) persists widget positions with: + +| Piece | Role | +|-------|------| +| `dashboardLayout.ts` | Widget ids, defaults, breakpoints, CSS class constants | +| `dashboardGridUi.tsx` | Shared skeleton + keyed grid-item factory | +| `dashboardLayoutStorage.ts` | `localStorage` key derived from layout version | +| `useDashboardLayout.ts` | Load + debounced save + reset + hide/show widgets | + +Rules: + +1. **Do not** put dashboard layout in Zustand or nuqs. +2. **Do not** clear layout on logout (browser preference, not session secret). +3. Always merge saved layouts with defaults so new widgets get a slot. +4. Persist `hidden` widget ids with the layout blob; at least one widget stays visible. +5. Ephemeral “Customize layout” toggle stays in component `useState`. + +See [ADR 0003](./adr/0003-dashboard-grid-layout.md). + +--- + ## nuqs (URL state) ### When to use diff --git a/package-lock.json b/package-lock.json index e5875b3..bc02e4f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "nuqs": "^2.8.9", "react": "19.2.5", "react-dom": "19.2.5", + "react-grid-layout": "^2.2.3", "recharts": "^2.15.4", "zustand": "^5.0.14" }, @@ -7011,12 +7012,64 @@ "react": "^19.2.5" } }, + "node_modules/react-draggable": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.7.0.tgz", + "integrity": "sha512-kTpANmKWVnFXiZ76Ag2ZowiFStuBYnJ606PI1TbUsOg29/400/JNIxI9+CuenhiAqFuXWJffz6F4UI3R51kUug==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" + } + }, + "node_modules/react-grid-layout": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-2.2.3.tgz", + "integrity": "sha512-OAEJHBxmfuxQfVtZwRzmsokijGlBgzYIJ7MUlLk/VSa43SaGzu15w5D0P2RDrfX5EvP9POMbL6bFrai/huDzbQ==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1", + "fast-equals": "^4.0.3", + "prop-types": "^15.8.1", + "react-draggable": "^4.4.6", + "react-resizable": "^3.1.3", + "resize-observer-polyfill": "^1.5.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" + } + }, + "node_modules/react-grid-layout/node_modules/fast-equals": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz", + "integrity": "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==", + "license": "MIT" + }, "node_modules/react-is": { "version": "19.2.5", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.5.tgz", "integrity": "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==", "license": "MIT" }, + "node_modules/react-resizable": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.2.0.tgz", + "integrity": "sha512-3NKQ0SLZV7rs3LQHeXlOzDSRQfFrkX6TVet77/Qk03zqiZyee37b7N8/gwDJAA8UUjRz7PdWCCy49hcso45SMQ==", + "license": "MIT", + "dependencies": { + "prop-types": "15.x", + "react-draggable": "^4.5.0" + }, + "peerDependencies": { + "react": ">= 16.3", + "react-dom": ">= 16.3" + } + }, "node_modules/react-smooth": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", @@ -7130,6 +7183,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", diff --git a/package.json b/package.json index 3586307..cad0420 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "nuqs": "^2.8.9", "react": "19.2.5", "react-dom": "19.2.5", + "react-grid-layout": "^2.2.3", "recharts": "^2.15.4", "zustand": "^5.0.14" }, diff --git a/src/app/implementation/dashboardWidget.tsx b/src/app/implementation/dashboardWidget.tsx index d544234..f81e5b9 100644 --- a/src/app/implementation/dashboardWidget.tsx +++ b/src/app/implementation/dashboardWidget.tsx @@ -12,9 +12,22 @@ import { Tooltip, Typography, } from '@mui/material'; -import { Refresh as RefreshIcon, HelpOutline as HelpOutlineIcon } from '@mui/icons-material'; +import { + Refresh as RefreshIcon, + HelpOutline as HelpOutlineIcon, + DragIndicator as DragIndicatorIcon, + VisibilityOff as VisibilityOffIcon, +} from '@mui/icons-material'; import { useTheme } from '@mui/material/styles'; import type { DashboardWidgetProps, DashboardWidgetHelp } from '@/app/types/DashboardWidgetProps'; +import { + useDashboardLayoutEdit, + useDashboardWidgetId, +} from '@/lib/features/sessions/DashboardLayoutEditContext'; +import { + DASHBOARD_DRAG_HANDLE_CLASS, + DASHBOARD_WIDGET_LABELS, +} from '@/lib/features/sessions/dashboardLayout'; function HelpAffordance({ help, widgetTitle }: { help: DashboardWidgetHelp; widgetTitle: React.ReactNode }) { const theme = useTheme(); @@ -93,6 +106,8 @@ export function DashboardWidgetImpl({ statusValue = 100, footer, fillHeight = false, + showDragHandle, + dragHandleAriaLabel, maxWidth, sx, className, @@ -100,6 +115,19 @@ export function DashboardWidgetImpl({ children, }: DashboardWidgetProps) { const theme = useTheme(); + const { isEditing, canHideWidget, hideWidget } = useDashboardLayoutEdit(); + const widgetId = useDashboardWidgetId(); + const dragHandleVisible = showDragHandle ?? isEditing; + const showHideControl = isEditing && widgetId !== null; + const resolvedDragHandleLabel = + dragHandleAriaLabel ?? + (typeof title === 'string' + ? `Drag to rearrange ${title}` + : 'Drag to rearrange widget'); + const hideLabel = + widgetId !== null + ? `Hide ${DASHBOARD_WIDGET_LABELS[widgetId]}` + : 'Hide widget'; // isLoading = initial load (skeleton children); isFetching = background // refetch (content stays). Both animate the status bar and block refresh. @@ -139,7 +167,7 @@ export function DashboardWidgetImpl({ display: 'flex', flexDirection: 'column', ...(maxWidth !== undefined && { maxWidth }), - ...(fillHeight && { height: '100%', flex: 1 }), + ...(fillHeight && { height: '100%', flex: 1, minHeight: 0 }), [theme.breakpoints.down('sm')]: { padding: theme.spacing(1.5), }, @@ -169,7 +197,46 @@ export function DashboardWidgetImpl({ }, }} > - + + {dragHandleVisible && ( + + + + )} + {showHideControl && ( + + + { + if (widgetId) hideWidget(widgetId); + }} + sx={{ p: 0.25, color: 'text.secondary' }} + > + + + + + )} {children} diff --git a/src/app/implementation/launchFormWidget.tsx b/src/app/implementation/launchFormWidget.tsx index b62ece8..ed05f45 100644 --- a/src/app/implementation/launchFormWidget.tsx +++ b/src/app/implementation/launchFormWidget.tsx @@ -20,6 +20,7 @@ export function LaunchFormWidgetImpl({ progressPercentage = 0, helpUrl, signInAlertMessage, + fillHeight = false, imagesByType = {}, repositoryHosts = [], activeSessions = [], @@ -108,6 +109,7 @@ export function LaunchFormWidgetImpl({ onRefresh={onRefresh} help={helpUrl ? { url: helpUrl } : undefined} statusValue={showProgressIndicator ? progressPercentage : 100} + fillHeight={fillHeight} alert={ signInAlertMessage ? ( diff --git a/src/app/implementation/platformLoad.tsx b/src/app/implementation/platformLoad.tsx index 3754f48..47752aa 100644 --- a/src/app/implementation/platformLoad.tsx +++ b/src/app/implementation/platformLoad.tsx @@ -19,6 +19,7 @@ export const PlatformLoadImpl: React.FC = ({ className, title = 'Platform Load', showDisabledOverlay = false, + fillHeight = false, }) => { const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('sm')); @@ -114,6 +115,7 @@ export const PlatformLoadImpl: React.FC = ({ isLoading={effectiveLoading} onRefresh={showDisabledOverlay ? undefined : onRefresh} footer={lastUpdateFooter || undefined} + fillHeight={fillHeight} > {/* Content - Responsive MetricBlock layout; blurred when live stats disabled (CADC-15555) */} diff --git a/src/app/implementation/userStorageWidget.tsx b/src/app/implementation/userStorageWidget.tsx index 723a072..057a0a8 100644 --- a/src/app/implementation/userStorageWidget.tsx +++ b/src/app/implementation/userStorageWidget.tsx @@ -268,7 +268,7 @@ export const UserStorageWidgetImpl = React.forwardRef 0 ? progressPercentage : 100} fillHeight={fillHeight} - maxWidth={600} + maxWidth={fillHeight ? undefined : 600} > {/* Storage Cards or Empty State */} {!displayData && !isLoading ? ( diff --git a/src/app/types/DashboardWidgetProps.ts b/src/app/types/DashboardWidgetProps.ts index 45b6990..9d4960f 100644 --- a/src/app/types/DashboardWidgetProps.ts +++ b/src/app/types/DashboardWidgetProps.ts @@ -62,6 +62,16 @@ export interface DashboardWidgetProps { footer?: ReactNode; /** Stretch to fill the parent's height (flex column layout). */ fillHeight?: boolean; + /** + * Show a drag handle in the header for dashboard grid rearrange. + * When omitted, follows `useDashboardLayoutEdit().isEditing`. + */ + showDragHandle?: boolean; + /** + * Accessible name for the drag handle. + * @default `Drag to rearrange ${title}` when title is a string + */ + dragHandleAriaLabel?: string; /** Optional cap on the widget width (e.g. 600 for the storage widget). */ maxWidth?: number | string; /** Extra styles merged onto the Paper root. */ diff --git a/src/app/types/LaunchFormWidgetProps.ts b/src/app/types/LaunchFormWidgetProps.ts index 0bf5b00..7e0d510 100644 --- a/src/app/types/LaunchFormWidgetProps.ts +++ b/src/app/types/LaunchFormWidgetProps.ts @@ -15,4 +15,6 @@ export interface LaunchFormWidgetProps extends SessionLaunchFormProps { signInAlertMessage?: string; // Optional custom launch function to override default API call launchSessionFn?: (params: SessionLaunchParams) => Promise; + /** Stretch to fill the dashboard grid cell. */ + fillHeight?: boolean; } diff --git a/src/app/types/PlatformLoadProps.ts b/src/app/types/PlatformLoadProps.ts index 0676790..54ab29f 100644 --- a/src/app/types/PlatformLoadProps.ts +++ b/src/app/types/PlatformLoadProps.ts @@ -54,4 +54,6 @@ export interface PlatformLoadProps { * When true, shows a warning overlay over blurred placeholder metrics and disables refresh (CADC-15555). */ showDisabledOverlay?: boolean; + /** Stretch to fill the dashboard grid cell. */ + fillHeight?: boolean; } diff --git a/src/lib/features/sessions/DashboardGrid.tsx b/src/lib/features/sessions/DashboardGrid.tsx new file mode 100644 index 0000000..6b5d2d4 --- /dev/null +++ b/src/lib/features/sessions/DashboardGrid.tsx @@ -0,0 +1,134 @@ +'use client'; + +import { useMemo } from 'react'; +import { Box, useMediaQuery } from '@mui/material'; +import { useTheme } from '@mui/material/styles'; +import { + ResponsiveGridLayout, + useContainerWidth, + type Layout, + type ResponsiveLayouts, +} from 'react-grid-layout'; +import { + DASHBOARD_BREAKPOINTS, + DASHBOARD_COLS, + DASHBOARD_CUSTOMIZE_MIN_BREAKPOINT, + DASHBOARD_DRAG_HANDLE_CLASS, + DASHBOARD_GRID_ROOT_CLASS, + DASHBOARD_MARGIN, + DASHBOARD_ROW_HEIGHT, + type DashboardBreakpoint, + type DashboardLayouts, +} from './dashboardLayout'; +import { DashboardGridSkeleton } from './dashboardGridUi'; +import 'react-grid-layout/css/styles.css'; +import './dashboardGrid.css'; + +const DRAG_CANCEL = + 'button,a,input,textarea,select,.MuiIconButton-root'; +const RESIZE_HANDLES = ['e', 's', 'se'] as const; + +export interface DashboardGridProps { + layouts: DashboardLayouts; + /** Change this (e.g. on reset) to force the grid to remount with new layouts. */ + layoutEpoch?: number; + isEditing: boolean; + onLayoutChange: ( + current: Layout, + allLayouts: ResponsiveLayouts, + ) => void; + onInteractionStart?: () => void; + onInteractionStop?: () => void; + children: React.ReactNode; +} + +export function DashboardGrid({ + layouts, + layoutEpoch = 0, + isEditing, + onLayoutChange, + onInteractionStart, + onInteractionStop, + children, +}: DashboardGridProps) { + const theme = useTheme(); + const canInteract = useMediaQuery( + theme.breakpoints.up(DASHBOARD_CUSTOMIZE_MIN_BREAKPOINT), + ); + const { width, containerRef, mounted } = useContainerWidth({ + measureBeforeMount: true, + initialWidth: 1280, + }); + + const interactionEnabled = isEditing && canInteract; + + const cssVars = useMemo( + () => + ({ + '--dashboard-grid-placeholder': + theme.palette.mode === 'dark' + ? 'rgba(144, 202, 249, 0.45)' + : 'rgba(25, 118, 210, 0.35)', + '--dashboard-grid-outline': theme.palette.divider, + '--dashboard-grid-resize': + theme.palette.mode === 'dark' + ? 'rgba(255, 255, 255, 0.55)' + : 'rgba(0, 0, 0, 0.45)', + }) as React.CSSProperties, + [theme.palette.mode, theme.palette.divider], + ); + + const dragConfig = useMemo( + () => ({ + enabled: interactionEnabled, + handle: `.${DASHBOARD_DRAG_HANDLE_CLASS}`, + cancel: DRAG_CANCEL, + threshold: 3, + }), + [interactionEnabled], + ); + + const resizeConfig = useMemo( + () => ({ + enabled: interactionEnabled, + handles: RESIZE_HANDLES, + }), + [interactionEnabled], + ); + + return ( + + {!mounted ? ( + + ) : ( + + {children} + + )} + + ); +} diff --git a/src/lib/features/sessions/DashboardLayoutEditContext.tsx b/src/lib/features/sessions/DashboardLayoutEditContext.tsx new file mode 100644 index 0000000..9834723 --- /dev/null +++ b/src/lib/features/sessions/DashboardLayoutEditContext.tsx @@ -0,0 +1,61 @@ +'use client'; + +import { createContext, useContext, useMemo } from 'react'; +import type { DashboardWidgetId } from './dashboardLayout'; + +interface DashboardLayoutEditContextValue { + isEditing: boolean; + canHideWidget: boolean; + hideWidget: (id: DashboardWidgetId) => void; +} + +const DashboardLayoutEditContext = createContext({ + isEditing: false, + canHideWidget: false, + hideWidget: () => undefined, +}); + +const DashboardWidgetIdContext = createContext(null); + +export function DashboardLayoutEditProvider({ + isEditing, + canHideWidget, + hideWidget, + children, +}: { + isEditing: boolean; + canHideWidget: boolean; + hideWidget: (id: DashboardWidgetId) => void; + children: React.ReactNode; +}) { + const value = useMemo( + () => ({ isEditing, canHideWidget, hideWidget }), + [isEditing, canHideWidget, hideWidget], + ); + + return ( + + {children} + + ); +} + +export function DashboardWidgetIdProvider({ + id, + children, +}: { + id: DashboardWidgetId; + children: React.ReactNode; +}) { + return ( + {children} + ); +} + +export function useDashboardLayoutEdit(): DashboardLayoutEditContextValue { + return useContext(DashboardLayoutEditContext); +} + +export function useDashboardWidgetId(): DashboardWidgetId | null { + return useContext(DashboardWidgetIdContext); +} diff --git a/src/lib/features/sessions/DashboardLayoutToolbar.tsx b/src/lib/features/sessions/DashboardLayoutToolbar.tsx new file mode 100644 index 0000000..9800298 --- /dev/null +++ b/src/lib/features/sessions/DashboardLayoutToolbar.tsx @@ -0,0 +1,110 @@ +'use client'; + +import { Box, Button, Chip, Typography } from '@mui/material'; +import { + DashboardCustomize as CustomizeIcon, + RestartAlt as ResetIcon, + Visibility as VisibilityIcon, +} from '@mui/icons-material'; +import { + DASHBOARD_WIDGET_LABELS, + type DashboardWidgetId, +} from './dashboardLayout'; + +export interface DashboardLayoutToolbarProps { + isEditing: boolean; + onToggleEditing: () => void; + onReset: () => void; + /** Hidden widgets shown as a restore tray while editing. */ + availableWidgetIds?: DashboardWidgetId[]; + onShowWidget?: (id: DashboardWidgetId) => void; + /** When false, hide the toolbar (e.g. below md where drag is disabled). */ + visible?: boolean; +} + +export function DashboardLayoutToolbar({ + isEditing, + onToggleEditing, + onReset, + availableWidgetIds = [], + onShowWidget, + visible = true, +}: DashboardLayoutToolbarProps) { + if (!visible) return null; + + return ( + + + + {isEditing + ? 'Drag to rearrange, resize from the edges, or hide a widget with the eye icon.' + : 'Customize the dashboard layout to rearrange, resize, and show or hide widgets.'} + + + + + + + + {isEditing && ( + + + Available widgets + + {availableWidgetIds.length === 0 ? ( + + All widgets are on the dashboard + + ) : ( + availableWidgetIds.map((id) => ( + } + label={DASHBOARD_WIDGET_LABELS[id]} + onClick={() => onShowWidget?.(id)} + clickable + variant="outlined" + color="primary" + aria-label={`Show ${DASHBOARD_WIDGET_LABELS[id]}`} + /> + )) + )} + + )} + + ); +} diff --git a/src/lib/features/sessions/SessionsDashboard.tsx b/src/lib/features/sessions/SessionsDashboard.tsx index 4810084..877b126 100644 --- a/src/lib/features/sessions/SessionsDashboard.tsx +++ b/src/lib/features/sessions/SessionsDashboard.tsx @@ -1,6 +1,7 @@ 'use client'; -import { useMemo, useCallback, useEffect } from 'react'; +import { useMemo, useCallback, useEffect, useState } from 'react'; +import dynamic from 'next/dynamic'; import { ActiveSessionsWidget } from '@/app/components/ActiveSessionsWidget/ActiveSessionsWidget'; import { UserStorageWidget } from '@/app/components/UserStorageWidget/UserStorageWidget'; import { LaunchFormWidget } from '@/app/components/LaunchFormWidget/LaunchFormWidget'; @@ -27,10 +28,34 @@ import { } from '@/lib/config/site-config'; import { useOperatingSessionIds, useSessionUiActions } from '@/lib/stores'; import { SessionModalsHost } from '@/lib/features/sessions/SessionModalsHost'; +import { DashboardLayoutToolbar } from '@/lib/features/sessions/DashboardLayoutToolbar'; +import { DashboardLayoutEditProvider } from '@/lib/features/sessions/DashboardLayoutEditContext'; +import { useDashboardLayout } from '@/lib/features/sessions/useDashboardLayout'; +import { DASHBOARD_CUSTOMIZE_MIN_BREAKPOINT } from '@/lib/features/sessions/dashboardLayout'; +import { + DashboardGridSkeleton, + createDashboardGridItem, +} from '@/lib/features/sessions/dashboardGridUi'; + +/** Code-split react-grid-layout so it is not on the critical auth/data path. */ +const DashboardGrid = dynamic( + () => + import('@/lib/features/sessions/DashboardGrid').then((mod) => ({ + default: mod.DashboardGrid, + })), + { + ssr: false, + loading: () => , + }, +); + +const LAUNCH_HELP_URL = 'https://www.opencadc.org/canfar/latest/platform/sessions/'; export function SessionsDashboard() { const theme = useTheme(); - const isDesktopTopRow = useMediaQuery(theme.breakpoints.up('lg')); + const canCustomizeLayout = useMediaQuery( + theme.breakpoints.up(DASHBOARD_CUSTOMIZE_MIN_BREAKPOINT), + ); const { useCanfar, serviceUrls } = usePublicRuntimeConfig(); const isOIDCMode = !useCanfar; @@ -43,6 +68,31 @@ export function SessionsDashboard() { const operatingSessionIds = useOperatingSessionIds(); const { clearOperating } = useSessionUiActions(); + const [isEditingLayout, setIsEditingLayout] = useState(false); + const { + layouts, + hiddenIds, + availableWidgetIds, + canHideWidget, + layoutEpoch, + hydrated, + onLayoutChange, + onInteractionStart, + onInteractionStop, + hideWidget, + showWidget, + resetLayouts, + } = useDashboardLayout(); + + const hiddenIdSet = useMemo(() => new Set(hiddenIds), [hiddenIds]); + + // Exit edit mode when viewport shrinks below md (drag/resize disabled there). + useEffect(() => { + if (!canCustomizeLayout && isEditingLayout) { + setIsEditingLayout(false); + } + }, [canCustomizeLayout, isEditingLayout]); + const { data: sessions = [], isLoading: isLoadingSessionsQuery, @@ -136,9 +186,13 @@ export function SessionsDashboard() { })); }, [sessions]); - const handleSessionsRefresh = useCallback(() => { - refetchSessions(); - }, [refetchSessions]); + const repositoryHosts = useMemo( + () => + imageRepositories + .map((repo) => repo.host) + .filter((host): host is string => Boolean(host)), + [imageRepositories], + ); const handleStorageRefresh = useCallback(() => { void refetchStorage(); @@ -150,6 +204,109 @@ export function SessionsDashboard() { refetchContext(); }, [refetchImages, refetchRepositories, refetchContext]); + const handleToggleEditing = useCallback(() => { + setIsEditingLayout((prev) => !prev); + }, []); + + const coreOptions = context?.cores.options; + const memoryOptions = context?.memoryGB.options; + const gpuOptions = context?.gpus.options; + const storageErrorMessage = storageError?.message; + const isFetchingSessionsFlag = isAuthenticated && isFetchingSessions; + const isFetchingStorageFlag = isAuthenticated && isFetchingStorageSummary; + + /** + * Stable children array (not a Fragment) — react-grid-layout only sees + * immediate keyed children; a Fragment would hide all widgets. + * Hidden widgets are omitted so RGL collapses their slots. + */ + const gridWidgets = useMemo(() => { + const items = [ + { + id: 'active-sessions' as const, + node: ( + + ), + }, + { + id: 'user-storage' as const, + node: ( + + ), + }, + { + id: 'launch-form' as const, + node: ( + + ), + }, + { + id: 'platform-load' as const, + node: ( + + ), + }, + ]; + + return items + .filter((item) => !hiddenIdSet.has(item.id)) + .map((item) => createDashboardGridItem(item.id, item.node)); + }, [ + activeSessions, + operatingSessionIds, + isLoadingSessions, + isFetchingSessionsFlag, + refetchSessions, + storageSummary, + isLoadingUserStorage, + isFetchingStorageFlag, + storageErrorMessage, + handleStorageRefresh, + imagesByType, + repositoryHosts, + isLoadingLaunchForm, + isFetchingLaunchForm, + handleLaunchFormRefresh, + sessions, + handleLaunchSession, + coreOptions, + memoryOptions, + gpuOptions, + hiddenIdSet, + ]); + const footerSections = useMemo( () => [ { @@ -210,102 +367,36 @@ export function SessionsDashboard() { ) : ( - <> - - - + + + {!hydrated ? ( + + ) : ( + - - - - - - - - - - - - - repo.host) - .filter((host): host is string => Boolean(host))} - isLoading={isLoadingLaunchForm} - isFetching={isFetchingLaunchForm} - onRefresh={handleLaunchFormRefresh} - activeSessions={sessions} - launchSessionFn={handleLaunchSession} - coreOptions={context?.cores.options} - memoryOptions={context?.memoryGB.options} - gpuOptions={context?.gpus.options} - /> - - - - - - - - + {gridWidgets} + + )} + + )} diff --git a/src/lib/features/sessions/__tests__/dashboardLayout.test.ts b/src/lib/features/sessions/__tests__/dashboardLayout.test.ts new file mode 100644 index 0000000..67e3933 --- /dev/null +++ b/src/lib/features/sessions/__tests__/dashboardLayout.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest'; +import { + DASHBOARD_LAYOUT_VERSION, + DASHBOARD_WIDGET_IDS, + DEFAULT_DASHBOARD_LAYOUTS, + cloneLayouts, + filterLayoutsByVisibility, + mergeLayoutsWithDefaults, + normalizeHiddenWidgetIds, + parsePersistedDashboardLayout, +} from '../dashboardLayout'; + +describe('mergeLayoutsWithDefaults', () => { + it('returns defaults when saved is null', () => { + const merged = mergeLayoutsWithDefaults(null); + expect(merged.lg?.map((item) => item.i).sort()).toEqual([...DASHBOARD_WIDGET_IDS].sort()); + expect(merged.lg).toHaveLength(4); + }); + + it('keeps valid saved positions and fills missing widgets', () => { + const merged = mergeLayoutsWithDefaults({ + lg: [{ i: 'active-sessions', x: 2, y: 4, w: 10, h: 8 }], + }); + + const sessions = merged.lg?.find((item) => item.i === 'active-sessions'); + expect(sessions).toMatchObject({ x: 2, y: 4, w: 10, h: 8, minW: 8, minH: 4 }); + expect(merged.lg).toHaveLength(4); + expect(merged.lg?.map((item) => item.i).sort()).toEqual([...DASHBOARD_WIDGET_IDS].sort()); + }); + + it('drops unknown widget ids and enforces min sizes', () => { + const merged = mergeLayoutsWithDefaults({ + lg: [ + { i: 'unknown-widget', x: 0, y: 0, w: 3, h: 3 }, + { i: 'launch-form', x: 0, y: 0, w: 1, h: 1 }, + ], + }); + + expect(merged.lg?.some((item) => item.i === 'unknown-widget')).toBe(false); + const launch = merged.lg?.find((item) => item.i === 'launch-form'); + expect(launch?.w).toBeGreaterThanOrEqual(8); + expect(launch?.h).toBeGreaterThanOrEqual(12); + }); +}); + +describe('parsePersistedDashboardLayout', () => { + it('rejects wrong version or invalid shape', () => { + expect(parsePersistedDashboardLayout(null)).toBeNull(); + expect(parsePersistedDashboardLayout({ version: 99, layouts: {} })).toBeNull(); + expect(parsePersistedDashboardLayout({ version: DASHBOARD_LAYOUT_VERSION })).toBeNull(); + }); + + it('accepts a valid payload and merges layouts', () => { + const parsed = parsePersistedDashboardLayout({ + version: DASHBOARD_LAYOUT_VERSION, + layouts: { + lg: [{ i: 'user-storage', x: 0, y: 0, w: 3, h: 4 }], + }, + hidden: ['platform-load', 'unknown', 'platform-load'], + }); + + expect(parsed?.version).toBe(DASHBOARD_LAYOUT_VERSION); + expect(parsed?.layouts.lg).toHaveLength(4); + expect(parsed?.layouts.md).toHaveLength(4); + expect(parsed?.hidden).toEqual(['platform-load']); + }); +}); + +describe('normalizeHiddenWidgetIds', () => { + it('keeps at least one widget visible', () => { + expect(normalizeHiddenWidgetIds([...DASHBOARD_WIDGET_IDS])).toHaveLength( + DASHBOARD_WIDGET_IDS.length - 1, + ); + }); +}); + +describe('filterLayoutsByVisibility', () => { + it('omits hidden widget ids from each breakpoint', () => { + const filtered = filterLayoutsByVisibility(DEFAULT_DASHBOARD_LAYOUTS, ['launch-form']); + expect(filtered.lg?.every((item) => item.i !== 'launch-form')).toBe(true); + expect(filtered.lg).toHaveLength(3); + }); +}); + +describe('cloneLayouts', () => { + it('deep-clones layout items', () => { + const clone = cloneLayouts(DEFAULT_DASHBOARD_LAYOUTS); + expect(clone).toEqual(DEFAULT_DASHBOARD_LAYOUTS); + expect(clone.lg).not.toBe(DEFAULT_DASHBOARD_LAYOUTS.lg); + expect(clone.lg?.[0]).not.toBe(DEFAULT_DASHBOARD_LAYOUTS.lg?.[0]); + }); +}); diff --git a/src/lib/features/sessions/dashboardGrid.css b/src/lib/features/sessions/dashboardGrid.css new file mode 100644 index 0000000..2327b6a --- /dev/null +++ b/src/lib/features/sessions/dashboardGrid.css @@ -0,0 +1,59 @@ +/* Portal theme overrides for react-grid-layout (base CSS imported from DashboardGrid.tsx) */ + +.dashboard-grid-root .dashboard-grid-item { + height: 100%; + min-height: 0; +} + +.dashboard-grid-root .dashboard-grid-item > * { + height: 100%; + min-height: 0; +} + +.dashboard-grid-root .react-grid-item.react-grid-placeholder { + background: var(--dashboard-grid-placeholder, rgba(25, 118, 210, 0.35)); + opacity: 0.35; + border-radius: 8px; +} + +.dashboard-grid-root.dashboard-grid--editing .react-grid-item { + outline: 1px dashed var(--dashboard-grid-outline, rgba(0, 0, 0, 0.12)); + outline-offset: -1px; + border-radius: 8px; +} + +.dashboard-grid-root.dashboard-grid--editing .react-grid-item > .react-resizable-handle { + opacity: 0.85; +} + +.dashboard-grid-root .react-grid-item > .react-resizable-handle::after { + border-color: var(--dashboard-grid-resize, rgba(0, 0, 0, 0.45)); +} + +/* Widen east/south hit targets so independent axis resize is easier. */ +.dashboard-grid-root .react-grid-item > .react-resizable-handle-e { + width: 12px; + right: 0; + top: 12px; + bottom: 12px; + height: auto; + cursor: ew-resize; +} + +.dashboard-grid-root .react-grid-item > .react-resizable-handle-s { + height: 12px; + bottom: 0; + left: 12px; + right: 12px; + width: auto; + cursor: ns-resize; +} + +.dashboard-widget-drag-handle { + cursor: grab; + touch-action: none; +} + +.dashboard-widget-drag-handle:active { + cursor: grabbing; +} diff --git a/src/lib/features/sessions/dashboardGridUi.tsx b/src/lib/features/sessions/dashboardGridUi.tsx new file mode 100644 index 0000000..9732519 --- /dev/null +++ b/src/lib/features/sessions/dashboardGridUi.tsx @@ -0,0 +1,33 @@ +'use client'; + +import type { ReactNode } from 'react'; +import { Skeleton } from '@mui/material'; +import { + DASHBOARD_GRID_ITEM_CLASS, + DASHBOARD_GRID_SKELETON_HEIGHT, + type DashboardWidgetId, +} from './dashboardLayout'; +import { DashboardWidgetIdProvider } from './DashboardLayoutEditContext'; + +/** Shared placeholder while layout storage / container width settle. */ +export function DashboardGridSkeleton() { + return ( + + ); +} + +/** + * RGL grid child — `key` must equal `layout[].i` and be on this element + * (not a Fragment parent). Provides widget id to DashboardWidget chrome. + */ +export function createDashboardGridItem(id: DashboardWidgetId, child: ReactNode) { + return ( +
+ {child} +
+ ); +} diff --git a/src/lib/features/sessions/dashboardLayout.ts b/src/lib/features/sessions/dashboardLayout.ts new file mode 100644 index 0000000..8f82900 --- /dev/null +++ b/src/lib/features/sessions/dashboardLayout.ts @@ -0,0 +1,248 @@ +import type { Layout, LayoutItem, ResponsiveLayouts } from 'react-grid-layout'; + +/** Stable ids for dashboard grid items (must match React keys). */ +export const DASHBOARD_WIDGET_IDS = [ + 'active-sessions', + 'user-storage', + 'launch-form', + 'platform-load', +] as const; + +export type DashboardWidgetId = (typeof DASHBOARD_WIDGET_IDS)[number]; + +/** Catalog labels for the customize tray / a11y (not the live widget title). */ +export const DASHBOARD_WIDGET_LABELS: Record = { + 'active-sessions': 'Active Sessions', + 'user-storage': 'User Storage', + 'launch-form': 'Launch Session', + 'platform-load': 'Platform Load', +}; + +export type DashboardBreakpoint = 'lg' | 'md' | 'sm' | 'xs'; + +/** MUI breakpoint at/above which Customize / drag / resize are enabled. */ +export const DASHBOARD_CUSTOMIZE_MIN_BREAKPOINT = 'md' as const; + +export const DASHBOARD_BREAKPOINTS: Record = { + lg: 1200, + md: 900, + sm: 600, + xs: 0, +}; + +/** + * Column counts — 2× denser than a classic 12-col dashboard so width + * resize steps are ~half as large (~4% of container per unit on lg). + */ +export const DASHBOARD_COLS: Record = { + lg: 24, + md: 20, + sm: 12, + xs: 8, +}; + +/** CSS class for the drag handle on DashboardWidget (RGL `dragConfig.handle`). */ +export const DASHBOARD_DRAG_HANDLE_CLASS = 'dashboard-widget-drag-handle'; + +/** Must match selectors in `dashboardGrid.css`. */ +export const DASHBOARD_GRID_ITEM_CLASS = 'dashboard-grid-item'; +export const DASHBOARD_GRID_ROOT_CLASS = 'dashboard-grid-root'; + +/** Bumped when factory heights/density/schema change so stale layouts are discarded. */ +export const DASHBOARD_LAYOUT_VERSION = 10; +export const DASHBOARD_LAYOUT_STORAGE_KEY = `canfar-dashboard-layout-v${DASHBOARD_LAYOUT_VERSION}`; + +/** Row height in px — smaller = finer vertical resize steps. */ +export const DASHBOARD_ROW_HEIGHT = 40; +export const DASHBOARD_MARGIN: readonly [number, number] = [12, 12]; +export const DASHBOARD_GRID_SKELETON_HEIGHT = 480; + +export type DashboardLayouts = ResponsiveLayouts; + +export interface PersistedDashboardLayout { + version: number; + layouts: DashboardLayouts; + /** Widget ids not rendered on the grid (positions kept in `layouts`). */ + hidden: DashboardWidgetId[]; +} + +const baseConstraints: Record> = { + 'active-sessions': { minW: 8, minH: 4 }, + 'user-storage': { minW: 4, minH: 4 }, + /** Fits Advanced tab without a large empty footer region. */ + 'launch-form': { minW: 8, minH: 12 }, + /** Tall enough for metrics + disabled overlay without an inner scrollbar. */ + 'platform-load': { minW: 6, minH: 7 }, +}; + +function item( + id: DashboardWidgetId, + x: number, + y: number, + w: number, + h: number, +): LayoutItem { + return { i: id, x, y, w, h, ...baseConstraints[id] }; +} + +/** Single-column stack used for sm/xs — only `cols` differs. */ +function stackedLayout(cols: number): LayoutItem[] { + return [ + item('active-sessions', 0, 0, cols, 7), + item('user-storage', 0, 7, cols, 5), + item('launch-form', 0, 12, cols, 14), + item('platform-load', 0, 26, cols, 9), + ]; +} + +/** + * Initial (factory) layouts — original portal proportions: + * top row ~80/20 (sessions / storage), bottom ~60/40 (launch / platform). + * Heights kept compact; users can grow widgets in Customize mode. + */ +export const DEFAULT_DASHBOARD_LAYOUTS: DashboardLayouts = { + lg: [ + // 80% / 20% of 24 cols — ~320px tall + item('active-sessions', 0, 0, 19, 8), + item('user-storage', 19, 0, 5, 8), + // Launch ~560px; Platform 9 rows + item('launch-form', 0, 8, 14, 14), + item('platform-load', 14, 8, 10, 9), + ], + md: [ + item('active-sessions', 0, 0, 14, 7), + item('user-storage', 14, 0, 6, 7), + item('launch-form', 0, 7, 12, 14), + item('platform-load', 12, 7, 8, 9), + ], + sm: stackedLayout(DASHBOARD_COLS.sm), + xs: stackedLayout(DASHBOARD_COLS.xs), +}; + +export function isDashboardWidgetId(value: string): value is DashboardWidgetId { + return (DASHBOARD_WIDGET_IDS as readonly string[]).includes(value); +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value); +} + +function isLayoutItemLike(value: unknown): value is LayoutItem { + if (!value || typeof value !== 'object') return false; + const item = value as Record; + return ( + typeof item.i === 'string' && + isFiniteNumber(item.x) && + isFiniteNumber(item.y) && + isFiniteNumber(item.w) && + isFiniteNumber(item.h) + ); +} + +/** + * Merge a possibly partial/invalid saved layout with defaults. + * Unknown widget ids are dropped; missing widgets are filled from defaults. + */ +export function mergeLayoutsWithDefaults( + saved: DashboardLayouts | null | undefined, + defaults: DashboardLayouts = DEFAULT_DASHBOARD_LAYOUTS, +): DashboardLayouts { + const breakpoints = Object.keys(defaults) as DashboardBreakpoint[]; + const result: DashboardLayouts = {}; + + for (const bp of breakpoints) { + const defaultLayout = defaults[bp] ?? []; + const defaultById = new Map(defaultLayout.map((entry) => [entry.i, entry])); + const savedLayout = saved?.[bp]; + const merged: LayoutItem[] = []; + const seen = new Set(); + + if (Array.isArray(savedLayout)) { + for (const raw of savedLayout) { + if (!isLayoutItemLike(raw) || !isDashboardWidgetId(raw.i) || seen.has(raw.i)) { + continue; + } + const fallback = defaultById.get(raw.i); + if (!fallback) continue; + const constraints = baseConstraints[raw.i]; + merged.push({ + ...fallback, + ...constraints, + x: Math.max(0, Math.floor(raw.x)), + y: Math.max(0, Math.floor(raw.y)), + w: Math.max(constraints.minW ?? 1, Math.floor(raw.w)), + h: Math.max(constraints.minH ?? 1, Math.floor(raw.h)), + i: raw.i, + }); + seen.add(raw.i); + } + } + + for (const id of DASHBOARD_WIDGET_IDS) { + if (!seen.has(id)) { + const fallback = defaultById.get(id); + if (fallback) merged.push({ ...fallback }); + } + } + + result[bp] = merged; + } + + return result; +} + +export function normalizeHiddenWidgetIds(raw: unknown): DashboardWidgetId[] { + if (!Array.isArray(raw)) return []; + const seen = new Set(); + for (const value of raw) { + if (typeof value === 'string' && isDashboardWidgetId(value)) { + seen.add(value); + } + } + // Never allow hiding every widget — keep at least one visible. + if (seen.size >= DASHBOARD_WIDGET_IDS.length) { + seen.delete(DASHBOARD_WIDGET_IDS[0]); + } + return DASHBOARD_WIDGET_IDS.filter((id) => seen.has(id)); +} + +/** Layouts passed to RGL — omit hidden widgets so slots collapse. */ +export function filterLayoutsByVisibility( + layouts: DashboardLayouts, + hidden: ReadonlySet | readonly DashboardWidgetId[], +): DashboardLayouts { + const hiddenSet = hidden instanceof Set ? hidden : new Set(hidden); + const result: DashboardLayouts = {}; + for (const [bp, layout] of Object.entries(layouts) as [ + DashboardBreakpoint, + Layout | undefined, + ][]) { + if (!layout) continue; + result[bp] = layout.filter( + (entry) => isDashboardWidgetId(entry.i) && !hiddenSet.has(entry.i), + ); + } + return result; +} + +/** Validate and normalize a persisted blob; returns null if unusable. */ +export function parsePersistedDashboardLayout(raw: unknown): PersistedDashboardLayout | null { + if (!raw || typeof raw !== 'object') return null; + const candidate = raw as Record; + if (candidate.version !== DASHBOARD_LAYOUT_VERSION) return null; + if (!candidate.layouts || typeof candidate.layouts !== 'object') return null; + + const layouts = mergeLayoutsWithDefaults(candidate.layouts as DashboardLayouts); + const hidden = normalizeHiddenWidgetIds(candidate.hidden); + return { version: DASHBOARD_LAYOUT_VERSION, layouts, hidden }; +} + +export function cloneLayouts(layouts: DashboardLayouts): DashboardLayouts { + const clone: DashboardLayouts = {}; + for (const [bp, layout] of Object.entries(layouts) as [DashboardBreakpoint, Layout | undefined][]) { + if (layout) { + clone[bp] = layout.map((entry) => ({ ...entry })); + } + } + return clone; +} diff --git a/src/lib/features/sessions/dashboardLayoutStorage.ts b/src/lib/features/sessions/dashboardLayoutStorage.ts new file mode 100644 index 0000000..d9d7e2a --- /dev/null +++ b/src/lib/features/sessions/dashboardLayoutStorage.ts @@ -0,0 +1,80 @@ +import { + DASHBOARD_LAYOUT_STORAGE_KEY, + DASHBOARD_LAYOUT_VERSION, + DEFAULT_DASHBOARD_LAYOUTS, + cloneLayouts, + parsePersistedDashboardLayout, + type DashboardLayouts, + type DashboardWidgetId, + type PersistedDashboardLayout, +} from './dashboardLayout'; + +export interface StoredDashboardLayout { + layouts: DashboardLayouts; + hidden: DashboardWidgetId[]; +} + +function readStorage(): string | null { + if (typeof window === 'undefined') return null; + try { + return window.localStorage.getItem(DASHBOARD_LAYOUT_STORAGE_KEY); + } catch { + return null; + } +} + +function writeStorage(value: string | null): void { + if (typeof window === 'undefined') return; + try { + if (value === null) { + window.localStorage.removeItem(DASHBOARD_LAYOUT_STORAGE_KEY); + } else { + window.localStorage.setItem(DASHBOARD_LAYOUT_STORAGE_KEY, value); + } + } catch (error) { + console.warn('Failed to update dashboard layout storage:', error); + } +} + +export function loadDashboardLayoutState(): StoredDashboardLayout { + const defaults: StoredDashboardLayout = { + layouts: cloneLayouts(DEFAULT_DASHBOARD_LAYOUTS), + hidden: [], + }; + + const raw = readStorage(); + if (!raw) return defaults; + + try { + const parsed = parsePersistedDashboardLayout(JSON.parse(raw) as unknown); + if (!parsed) return defaults; + return { layouts: parsed.layouts, hidden: parsed.hidden }; + } catch { + return defaults; + } +} + +/** @deprecated Prefer loadDashboardLayoutState — kept for call-site clarity. */ +export function loadDashboardLayouts(): DashboardLayouts { + return loadDashboardLayoutState().layouts; +} + +export function saveDashboardLayoutState(state: StoredDashboardLayout): void { + const payload: PersistedDashboardLayout = { + version: DASHBOARD_LAYOUT_VERSION, + layouts: state.layouts, + hidden: state.hidden, + }; + writeStorage(JSON.stringify(payload)); +} + +export function saveDashboardLayouts( + layouts: DashboardLayouts, + hidden: DashboardWidgetId[] = [], +): void { + saveDashboardLayoutState({ layouts, hidden }); +} + +export function clearDashboardLayouts(): void { + writeStorage(null); +} diff --git a/src/lib/features/sessions/useDashboardLayout.ts b/src/lib/features/sessions/useDashboardLayout.ts new file mode 100644 index 0000000..a5f40d6 --- /dev/null +++ b/src/lib/features/sessions/useDashboardLayout.ts @@ -0,0 +1,178 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { Layout, ResponsiveLayouts } from 'react-grid-layout'; +import { + DASHBOARD_WIDGET_IDS, + DEFAULT_DASHBOARD_LAYOUTS, + cloneLayouts, + filterLayoutsByVisibility, + mergeLayoutsWithDefaults, + type DashboardBreakpoint, + type DashboardLayouts, + type DashboardWidgetId, +} from './dashboardLayout'; +import { + clearDashboardLayouts, + loadDashboardLayoutState, + saveDashboardLayoutState, +} from './dashboardLayoutStorage'; + +const PERSIST_DEBOUNCE_MS = 250; + +export function useDashboardLayout() { + const [layouts, setLayouts] = useState(() => + cloneLayouts(DEFAULT_DASHBOARD_LAYOUTS), + ); + const [hiddenIds, setHiddenIds] = useState([]); + const [hydrated, setHydrated] = useState(false); + /** Bumped on reset so ResponsiveGridLayout remounts with defaults. */ + const [layoutEpoch, setLayoutEpoch] = useState(0); + const persistTimerRef = useRef | null>(null); + /** Skip the next onLayoutChange persist after a programmatic reset. */ + const skipNextPersistRef = useRef(false); + /** Latest layouts without forcing a React render on every drag pixel. */ + const layoutsRef = useRef(layouts); + const hiddenIdsRef = useRef(hiddenIds); + const interactingRef = useRef(false); + + useEffect(() => { + const loaded = loadDashboardLayoutState(); + layoutsRef.current = loaded.layouts; + hiddenIdsRef.current = loaded.hidden; + setLayouts(loaded.layouts); + setHiddenIds(loaded.hidden); + setHydrated(true); + }, []); + + useEffect(() => { + return () => { + if (persistTimerRef.current) { + clearTimeout(persistTimerRef.current); + } + }; + }, []); + + const persist = useCallback((nextLayouts: DashboardLayouts, nextHidden: DashboardWidgetId[]) => { + if (persistTimerRef.current) { + clearTimeout(persistTimerRef.current); + } + persistTimerRef.current = setTimeout(() => { + saveDashboardLayoutState({ layouts: nextLayouts, hidden: nextHidden }); + }, PERSIST_DEBOUNCE_MS); + }, []); + + const handleLayoutChange = useCallback( + (_current: Layout, allLayouts: ResponsiveLayouts) => { + // RGL only sees visible widgets; merge then restore hidden positions. + const merged = mergeLayoutsWithDefaults(allLayouts); + for (const bp of Object.keys(merged) as DashboardBreakpoint[]) { + const byId = new Map((merged[bp] ?? []).map((entry) => [entry.i, { ...entry }])); + for (const id of hiddenIdsRef.current) { + const previous = layoutsRef.current[bp]?.find((entry) => entry.i === id); + if (previous) { + byId.set(id, { ...previous }); + } + } + merged[bp] = DASHBOARD_WIDGET_IDS.map((id) => byId.get(id)!); + } + + layoutsRef.current = merged; + + if (!interactingRef.current) { + setLayouts(merged); + } + + if (skipNextPersistRef.current) { + skipNextPersistRef.current = false; + return; + } + if (hydrated) { + persist(merged, hiddenIdsRef.current); + } + }, + [hydrated, persist], + ); + + const handleInteractionStart = useCallback(() => { + interactingRef.current = true; + }, []); + + const handleInteractionStop = useCallback(() => { + interactingRef.current = false; + setLayouts(cloneLayouts(layoutsRef.current)); + }, []); + + const hideWidget = useCallback( + (id: DashboardWidgetId) => { + const visibleCount = DASHBOARD_WIDGET_IDS.filter( + (widgetId) => !hiddenIdsRef.current.includes(widgetId), + ).length; + if (visibleCount <= 1 || hiddenIdsRef.current.includes(id)) return; + + const nextHidden = [...hiddenIdsRef.current, id]; + hiddenIdsRef.current = nextHidden; + setHiddenIds(nextHidden); + setLayoutEpoch((epoch) => epoch + 1); + if (hydrated) { + persist(layoutsRef.current, nextHidden); + } + }, + [hydrated, persist], + ); + + const showWidget = useCallback( + (id: DashboardWidgetId) => { + if (!hiddenIdsRef.current.includes(id)) return; + const nextHidden = hiddenIdsRef.current.filter((widgetId) => widgetId !== id); + hiddenIdsRef.current = nextHidden; + setHiddenIds(nextHidden); + setLayoutEpoch((epoch) => epoch + 1); + if (hydrated) { + persist(layoutsRef.current, nextHidden); + } + }, + [hydrated, persist], + ); + + const resetLayouts = useCallback(() => { + const defaults = cloneLayouts(DEFAULT_DASHBOARD_LAYOUTS); + if (persistTimerRef.current) { + clearTimeout(persistTimerRef.current); + persistTimerRef.current = null; + } + skipNextPersistRef.current = true; + interactingRef.current = false; + clearDashboardLayouts(); + layoutsRef.current = defaults; + hiddenIdsRef.current = []; + setLayouts(defaults); + setHiddenIds([]); + setLayoutEpoch((epoch) => epoch + 1); + }, []); + + const hiddenSet = useMemo(() => new Set(hiddenIds), [hiddenIds]); + const visibleLayouts = useMemo( + () => filterLayoutsByVisibility(layouts, hiddenSet), + [layouts, hiddenSet], + ); + const availableWidgetIds = hiddenIds; + const canHideWidget = DASHBOARD_WIDGET_IDS.length - hiddenIds.length > 1; + + return { + layouts: visibleLayouts, + /** Full layouts including hidden widget positions (for debugging / future use). */ + allLayouts: layouts, + hiddenIds, + availableWidgetIds, + canHideWidget, + layoutEpoch, + hydrated, + onLayoutChange: handleLayoutChange, + onInteractionStart: handleInteractionStart, + onInteractionStop: handleInteractionStop, + hideWidget, + showWidget, + resetLayouts, + }; +}