Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions docs/adr/0003-dashboard-grid-layout.md
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
34 changes: 33 additions & 1 deletion docs/state-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

---

Expand Down Expand Up @@ -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
Expand Down
59 changes: 59 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
74 changes: 71 additions & 3 deletions src/app/implementation/dashboardWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -93,13 +106,28 @@ export function DashboardWidgetImpl({
statusValue = 100,
footer,
fillHeight = false,
showDragHandle,
dragHandleAriaLabel,
maxWidth,
sx,
className,
ref,
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.
Expand Down Expand Up @@ -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),
},
Expand Down Expand Up @@ -169,7 +197,46 @@ export function DashboardWidgetImpl({
},
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', minWidth: 0 }}>
{dragHandleVisible && (
<Box
component="span"
className={DASHBOARD_DRAG_HANDLE_CLASS}
aria-label={resolvedDragHandleLabel}
title={resolvedDragHandleLabel}
sx={{
display: 'inline-flex',
alignItems: 'center',
color: 'text.secondary',
borderRadius: 1,
p: 0.25,
'&:hover': { color: 'text.primary', bgcolor: 'action.hover' },
}}
>
<DragIndicatorIcon fontSize="small" aria-hidden />
</Box>
)}
{showHideControl && (
<Tooltip
title={
canHideWidget ? hideLabel : 'At least one widget must stay visible'
}
>
<Box component="span" sx={{ display: 'inline-flex' }}>
<IconButton
size="small"
aria-label={hideLabel}
disabled={!canHideWidget}
onClick={() => {
if (widgetId) hideWidget(widgetId);
}}
sx={{ p: 0.25, color: 'text.secondary' }}
>
<VisibilityOffIcon fontSize="small" />
</IconButton>
</Box>
</Tooltip>
)}
<Typography
variant="h6"
component="h2"
Expand Down Expand Up @@ -221,6 +288,7 @@ export function DashboardWidgetImpl({
display: 'flex',
flexDirection: 'column',
minHeight: 0,
overflow: fillHeight ? 'auto' : undefined,
}}
>
{children}
Expand Down
2 changes: 2 additions & 0 deletions src/app/implementation/launchFormWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export function LaunchFormWidgetImpl({
progressPercentage = 0,
helpUrl,
signInAlertMessage,
fillHeight = false,
imagesByType = {},
repositoryHosts = [],
activeSessions = [],
Expand Down Expand Up @@ -108,6 +109,7 @@ export function LaunchFormWidgetImpl({
onRefresh={onRefresh}
help={helpUrl ? { url: helpUrl } : undefined}
statusValue={showProgressIndicator ? progressPercentage : 100}
fillHeight={fillHeight}
alert={
signInAlertMessage ? (
<Alert severity="info" sx={{ mb: 2 }}>
Expand Down
2 changes: 2 additions & 0 deletions src/app/implementation/platformLoad.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export const PlatformLoadImpl: React.FC<PlatformLoadProps> = ({
className,
title = 'Platform Load',
showDisabledOverlay = false,
fillHeight = false,
}) => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
Expand Down Expand Up @@ -114,6 +115,7 @@ export const PlatformLoadImpl: React.FC<PlatformLoadProps> = ({
isLoading={effectiveLoading}
onRefresh={showDisabledOverlay ? undefined : onRefresh}
footer={lastUpdateFooter || undefined}
fillHeight={fillHeight}
>
{/* Content - Responsive MetricBlock layout; blurred when live stats disabled (CADC-15555) */}
<Box sx={{ marginBottom: theme.spacing(2), position: 'relative' }}>
Expand Down
2 changes: 1 addition & 1 deletion src/app/implementation/userStorageWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@ export const UserStorageWidgetImpl = React.forwardRef<HTMLDivElement, UserStorag
showStatusBar={showProgressIndicator}
statusValue={progressPercentage > 0 ? progressPercentage : 100}
fillHeight={fillHeight}
maxWidth={600}
maxWidth={fillHeight ? undefined : 600}
>
{/* Storage Cards or Empty State */}
{!displayData && !isLoading ? (
Expand Down
10 changes: 10 additions & 0 deletions src/app/types/DashboardWidgetProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
2 changes: 2 additions & 0 deletions src/app/types/LaunchFormWidgetProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,6 @@ export interface LaunchFormWidgetProps extends SessionLaunchFormProps {
signInAlertMessage?: string;
// Optional custom launch function to override default API call
launchSessionFn?: (params: SessionLaunchParams) => Promise<Session>;
/** Stretch to fill the dashboard grid cell. */
fillHeight?: boolean;
}
Loading
Loading