feat(dashboard): add revision-safe interactive widget grid

This commit is contained in:
cesnimda
2026-10-28 15:32:47 +01:00
parent aea7c29e70
commit 0bd290e788
21 changed files with 696 additions and 108 deletions
@@ -0,0 +1,75 @@
import React from 'react';
import { fireEvent, render, screen, within } from '@testing-library/react';
import DashboardGrid from './DashboardGrid';
import { defaultDashboardLayout } from '../dashboardLayout';
import { I18nProvider } from '../i18n/I18nProvider';
let mockGridProps: any;
let mockMeasuredWidth = 1200;
jest.mock('react-grid-layout', () => ({
...jest.requireActual('react-grid-layout'),
__esModule: true,
default: (props: any) => { mockGridProps = props; return <div>{props.children}</div>; },
useContainerWidth: () => ({ width: mockMeasuredWidth, containerRef: { current: null } }),
}));
beforeEach(() => { mockMeasuredWidth = 1200; mockGridProps = null; });
test('library collision handling resolves resize overlaps but leaves deliberate gaps unchanged', () => {
render(<I18nProvider><DashboardGrid layout={defaultDashboardLayout()} editing disabled={false} onChange={jest.fn()} childrenFor={() => <p>Report</p>} /></I18nProvider>);
const items = [
{ i: 'a', x: 0, y: 17, w: 6, h: 10 },
{ i: 'b', x: 0, y: 25, w: 6, h: 8 },
{ i: 'c', x: 6, y: 25, w: 6, h: 8 },
];
const original = JSON.stringify(items);
const resolved = mockGridProps.compactor.compact(items, 12);
const { getAllCollisions } = jest.requireActual('react-grid-layout');
expect(resolved.every((item: any) => getAllCollisions(resolved, item).length === 0)).toBe(true);
expect(JSON.stringify(items)).toBe(original);
const spaced = [{ i: 'a', x: 4, y: 17, w: 8, h: 10 }];
expect(mockGridProps.compactor.compact(spaced, 12)).toMatchObject(spaced);
});
test('mount and compaction do not save; completed resize keeps hidden geometry and uses the draft owner revision', () => {
const layout = defaultDashboardLayout(); layout.revision = 8;
layout.widgets[2] = { ...layout.widgets[2], visible: false, x: 4, y: 17, w: 8, h: 10 };
const change = jest.fn();
render(<I18nProvider><DashboardGrid layout={layout} editing disabled={false} onChange={change} childrenFor={widget => <p>{widget.id} content</p>} /></I18nProvider>);
expect(change).not.toHaveBeenCalled();
expect(mockGridProps.onLayoutChange).toBeUndefined();
const next = mockGridProps.layout.map((row: any) => row.i === 'attention' ? { ...row, w: 8, h: 12 } : row);
mockGridProps.onResizeStop(next);
expect(change).toHaveBeenCalledTimes(1);
const saved = change.mock.calls[0][0];
expect(saved.revision).toBe(8);
expect(saved.widgets[1]).toMatchObject({ w: 8, h: 12, size: 'standard' });
expect(saved.widgets[2]).toEqual(layout.widgets[2]);
});
test('narrow viewport retains desktop geometry and keyboard height changes are bounded', () => {
mockMeasuredWidth = 390;
const layout = defaultDashboardLayout();
const change = jest.fn();
const view = render(<I18nProvider><DashboardGrid layout={layout} editing disabled={false} onChange={change} childrenFor={widget => <p>{widget.id} content</p>} /></I18nProvider>);
expect(mockGridProps).toBeNull();
expect(change).not.toHaveBeenCalled();
const preview = screen.getByRole('region', { name: 'Dashboard preview' });
fireEvent.click(within(preview).getByText('Today'));
fireEvent.change(screen.getByLabelText('Height for Today'), { target: { value: '25' } });
expect(change).not.toHaveBeenCalled();
fireEvent.change(screen.getByLabelText('Height for Today'), { target: { value: '12' } });
expect(change.mock.calls[0][0].widgets[1].h).toBe(12);
mockMeasuredWidth = 1200;
view.rerender(<I18nProvider><DashboardGrid layout={layout} editing disabled={false} onChange={change} childrenFor={widget => <p>{widget.id} content</p>} /></I18nProvider>);
expect(change).toHaveBeenCalledTimes(1);
expect(mockGridProps.layout[1]).toMatchObject({ x: layout.widgets[1].x, y: layout.widgets[1].y });
});
test('conflict disables pointer operations and ignores a late completion callback', () => {
const change = jest.fn();
render(<I18nProvider><DashboardGrid layout={defaultDashboardLayout()} editing disabled onChange={change} childrenFor={() => <p>Report</p>} /></I18nProvider>);
expect(mockGridProps.dragConfig.enabled).toBe(false);
expect(mockGridProps.resizeConfig.enabled).toBe(false);
mockGridProps.onDragStop(mockGridProps.layout);
expect(change).not.toHaveBeenCalled();
});
@@ -0,0 +1,105 @@
import React, { useRef, useState } from 'react';
import { Box, Button, Stack, TextField, Typography } from '@mui/material';
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
import GridLayout, { getAllCollisions, noCompactor, verticalCompactor, useContainerWidth, type Compactor, type Layout } from 'react-grid-layout';
import { arrangeDashboardWidgets, type DashboardLayout, type DashboardWidget, WIDGETS } from '../dashboardLayout';
import { useI18n } from '../i18n/I18nProvider';
import 'react-grid-layout/css/styles.css';
import 'react-resizable/css/styles.css';
const preserveGaps: Compactor = {
...verticalCompactor,
compact(layout, cols) {
// The library resolves collisions; untouched gaps survive mount and reload.
return layout.some(item => getAllCollisions(layout, item).length > 0)
? verticalCompactor.compact(layout, cols) : noCompactor.compact(layout, cols);
},
};
type Props = {
layout: DashboardLayout;
editing: boolean;
disabled: boolean;
onChange: (layout: DashboardLayout) => void;
childrenFor: (widget: DashboardWidget) => React.ReactNode;
};
export default function DashboardGrid({ layout, editing, disabled, onChange, childrenFor }: Props) {
const { t } = useI18n();
const { width, containerRef } = useContainerWidth({ initialWidth: 0 });
const wide = width >= 800;
const [announcement, announce] = useState('');
const focus = useRef<HTMLDivElement>(null);
const contents = new Map(layout.widgets.filter(widget => widget.visible).map(widget => [widget.id, childrenFor(widget)]));
const visible = layout.widgets.filter(widget => widget.visible && contents.get(widget.id) != null);
const update = (id: string, patch: Partial<DashboardWidget>) => {
if (disabled) return;
const widgets = layout.widgets.map(widget => widget.id === id ? { ...widget, ...patch } : widget);
onChange({ ...layout, widgets: arrangeDashboardWidgets(widgets) });
};
const move = (id: string, direction: number) => {
const index = layout.widgets.findIndex(widget => widget.id === id);
const target = index + direction;
if (disabled || target < 0 || target >= layout.widgets.length) return;
const widgets = [...layout.widgets];
const [moved] = widgets.splice(index, 1); widgets.splice(target, 0, moved);
onChange({ ...layout, widgets: arrangeDashboardWidgets(widgets) });
announce(t('dashboardMoved', { name: t(WIDGETS.find(widget => widget.id === id)!.titleKey), position: target + 1, total: widgets.length }));
requestAnimationFrame(() => focus.current?.querySelector<HTMLButtonElement>(`[data-dashboard-widget="${id}"] button[data-move]:not(:disabled)`)?.focus());
};
const finish = (next: Layout) => {
if (!editing || disabled) return;
// Mount, compaction and viewport changes never write preferences.
if (next.some(item => item.x < 0 || item.y < 0 || item.x + item.w > 12 || item.y + item.h > 256)) return;
const widgets = layout.widgets.map(widget => {
const item = next.find(item => item.i === widget.id);
return item ? { ...widget, x: item.x, y: item.y, w: item.w, h: item.h, size: item.w === 12 ? 'full' as const : 'standard' as const } : widget;
});
const ordered = widgets.filter(widget => widget.visible).sort((a, b) => a.y - b.y || a.x - b.x);
onChange({ ...layout, widgets: widgets.map(widget => widget.visible ? ordered.shift()! : widget) });
announce(t('assetsUnsavedChanges'));
};
const tiles = visible.map(widget => {
const name = t(WIDGETS.find(item => item.id === widget.id)!.titleKey);
const index = layout.widgets.indexOf(widget);
return <Box key={widget.id} data-dashboard-widget={widget.id} data-widget-size={widget.size}
sx={{ minWidth: 0, display: 'flex', flexDirection: 'column', border: editing ? '1px solid' : undefined, borderColor: 'divider', borderRadius: 2, bgcolor: 'background.paper', overflow: 'hidden' }}>
{editing ? <Stack direction="row" useFlexGap flexWrap="wrap" spacing={0.5} sx={{ p: 1, flexShrink: 0, maxHeight: '65%', overflow: 'auto' }}>
<Button className="dashboard-grid-handle" disabled={disabled || !wide} aria-label={t('dashboardDrag', { name })} aria-describedby="dashboard-grid-help" sx={{ cursor: 'grab', minWidth: 44, minHeight: 44 }}><DragIndicatorIcon /></Button>
<Box component="details" sx={{ flex: 1, minWidth: 0 }}>
<Box component="summary" sx={{ cursor: 'pointer', p: 1, minHeight: 44 }}>{name}</Box>
<Stack direction="row" useFlexGap flexWrap="wrap" spacing={1}>
{[-1, 1].map(direction => <Button key={direction} data-move={direction} disabled={disabled || index + direction < 0 || index + direction >= layout.widgets.length}
aria-label={t(direction < 0 ? 'dashboardMoveEarlierNamed' : 'dashboardMoveLaterNamed', { name })} onClick={() => move(widget.id, direction)}>{t(direction < 0 ? 'dashboardEarlier' : 'dashboardLater')}</Button>)}
<TextField select label={t('dashboardSize')} value={widget.size} disabled={disabled || widget.id === 'cards'}
slotProps={{ select: { native: true }, htmlInput: { 'aria-label': t('dashboardSizeFor', { name }) } }}
onChange={event => update(widget.id, { size: event.target.value as 'standard' | 'full', w: event.target.value === 'full' ? 12 : 6 })}>
<option value="standard">{t('dashboardSizeStandard')}</option><option value="full">{t('dashboardSizeFull')}</option>
</TextField>
<TextField type="number" label={t('dashboardHeightFor', { name })} value={widget.h} disabled={disabled} sx={{ width: 110 }}
slotProps={{ htmlInput: { min: 4, max: 24, step: 1 } }} onChange={event => { const h = Number(event.target.value); if (Number.isInteger(h) && h >= 4 && h <= 24) update(widget.id, { h }); }} />
<Button disabled={disabled} onClick={() => {
update(widget.id, { visible: false });
announce(t('dashboardWidgetHidden', { name }));
requestAnimationFrame(() => focus.current?.focus());
}}>{t('dashboardHideWidget')}</Button>
</Stack>
</Box>
</Stack> : null}
<Box tabIndex={wide ? 0 : undefined} role={wide ? 'group' : undefined} aria-label={wide ? name : undefined}
sx={{ minHeight: 0, minWidth: 0, flex: 1, overflow: wide ? 'auto' : 'visible', p: editing ? 1 : 0 }}>{contents.get(widget.id)}</Box>
</Box>;
});
return <Box ref={containerRef} sx={{ minWidth: 0 }}>
{editing ? <Typography id="dashboard-grid-help" variant="body2">{t('dashboardGridHelp')}</Typography> : null}
<Typography role="status" aria-live="polite" sx={{ position: 'absolute', width: '1px', height: '1px', overflow: 'hidden', clipPath: 'inset(50%)' }}>{announcement}</Typography>
<Box ref={focus} tabIndex={editing ? -1 : undefined} role={editing ? 'region' : undefined} aria-label={editing ? t('dashboardPreview') : undefined}
sx={{ '& .react-resizable-handle': { width: 44, height: 44 }, '@media (prefers-reduced-motion: reduce)': { '& .react-grid-item': { transition: 'none' } } }}>
{wide ? <GridLayout width={width} compactor={preserveGaps} layout={visible.map(widget => ({ i: widget.id, x: widget.x, y: widget.y, w: widget.w, h: widget.h, minW: widget.id === 'cards' ? 12 : 4, maxW: 12, minH: 4, maxH: 24 }))}
gridConfig={{ cols: 12, rowHeight: 40, margin: [16, 16], containerPadding: [0, 0], maxRows: 256 }}
dragConfig={{ enabled: editing && !disabled, handle: '.dashboard-grid-handle', allowMobileScroll: true }}
resizeConfig={{ enabled: editing && !disabled, handles: ['se'] }} onDragStop={finish} onResizeStop={finish}>{tiles}</GridLayout>
: <Stack spacing={3}>{tiles}</Stack>}
</Box>
</Box>;
}
@@ -1,9 +1,7 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import { useBlocker, useBeforeUnload } from "react-router-dom";
import { Alert, Box, Button, Checkbox, FormControlLabel, Stack, TextField, Typography } from "@mui/material";
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
import { DashboardLayout, defaultDashboardLayout, WIDGETS } from "../dashboardLayout";
import { useDragReorder } from "../hooks/useDragReorder";
import { Alert, Box, Button, Checkbox, FormControlLabel, Stack, Typography } from "@mui/material";
import { arrangeDashboardWidgets, DashboardLayout, defaultDashboardLayout, WIDGETS } from "../dashboardLayout";
import { useI18n } from "../i18n/I18nProvider";
import { useConfirm } from "../confirm";
import { PageSection } from "../layout/PageLayout";
@@ -45,31 +43,10 @@ export default function DashboardLayoutEditor({ draft, dirty, saving, saveError,
const { t } = useI18n();
const { confirm } = useConfirm();
const panel = useRef<HTMLDivElement>(null);
const pendingFocus = useRef<{ id: string; direction: number } | null>(null);
const [announcement, announce] = useState("");
useEffect(() => {
panel.current?.focus({ preventScroll: true });
panel.current?.scrollIntoView?.({ block: "start" });
}, []);
useLayoutEffect(() => {
const target = pendingFocus.current;
if (!target) return;
pendingFocus.current = null;
const row = panel.current?.querySelector(`[data-layout-row="${target.id}"]`);
const preferred = row?.querySelector<HTMLButtonElement>(`button[data-move="${target.direction}"]:not(:disabled)`);
(preferred ?? row?.querySelector<HTMLButtonElement>("button[data-move]:not(:disabled)"))?.focus();
}, [draft]);
const reorder = (from: number, to: number) => {
if (saving || from === to || to < 0 || to >= draft.widgets.length) return;
const widgets = [...draft.widgets];
const [moved] = widgets.splice(from, 1); widgets.splice(to, 0, moved);
onChange({ ...draft, widgets });
const name = t(WIDGETS.find(widget => widget.id === moved.id)!.titleKey);
announce(t("dashboardMoved", { name, position: to + 1, total: widgets.length }));
};
const drag = useDragReorder(reorder);
const change = (id: string, patch: Partial<DashboardLayout["widgets"][number]>) => onChange({ ...draft, widgets: draft.widgets.map(widget => widget.id === id ? { ...widget, ...patch } : widget) });
const cancel = async () => {
if (!dirty || await confirm({ title: t("dashboardDiscardTitle"), message: t("dashboardDiscardBody"), confirmLabel: t("dashboardDiscard"), destructive: true })) onCancel();
};
@@ -91,30 +68,19 @@ export default function DashboardLayoutEditor({ draft, dirty, saving, saveError,
{saveError ? <Alert severity="error" sx={{ mb: 2 }}>{t(conflict ? "dashboardConflict" : "dashboardSaveFailed")}
{conflict ? <Button disabled={saving} onClick={() => void reload()}>{t("dashboardReload")}</Button> : null}
</Alert> : null}
<Typography role="status" aria-live="polite" variant="body2" sx={{ mb: 1 }}>{announcement || t(dirty ? "assetsUnsavedChanges" : "dashboardDraftSaved")}</Typography>
<Box component="ol" aria-label={t("dashboardManageWidgets")} sx={{ listStyle: "none", m: 0, p: 0, display: "grid", gap: 1 }}>
{draft.widgets.map((widget, index) => {
<Typography role="status" aria-live="polite" variant="body2" sx={{ mb: 1 }}>{t(dirty ? "assetsUnsavedChanges" : "dashboardDraftSaved")}</Typography>
<Box component="details">
<Box component="summary" sx={{ p: 1, cursor: "pointer", minHeight: 44 }}>{t("dashboardManageWidgets")}</Box>
<Stack direction="row" useFlexGap flexWrap="wrap" role="group" aria-label={t("dashboardManageWidgets")}>
{draft.widgets.map(widget => {
const entry = WIDGETS.find(item => item.id === widget.id)!;
const name = t(entry.titleKey);
const gesture = drag.getItemProps(index);
return <Box component="li" key={widget.id} data-layout-row={widget.id} onDragOver={saving ? undefined : gesture.onDragOver} onDrop={saving ? undefined : gesture.onDrop}
sx={{ display: "flex", alignItems: "center", flexWrap: "wrap", gap: 1, p: 1, border: "1px solid", borderColor: drag.overIndex === index ? "primary.main" : "divider", borderRadius: 1, bgcolor: drag.overIndex === index ? "action.hover" : "background.paper", opacity: drag.dragIndex === index ? 0.6 : 1, minWidth: 0 }}>
<Button {...gesture} disabled={saving} draggable={!saving} aria-label={t("dashboardDrag", { name })} aria-describedby="dashboard-drag-help" sx={{ minWidth: 44, minHeight: 44, cursor: "grab" }}><DragIndicatorIcon /></Button>
<FormControlLabel sx={{ flex: "1 1 14rem", minWidth: 0, mr: 0, overflowWrap: "anywhere" }} control={<Checkbox checked={widget.visible} disabled={saving} onChange={event => change(widget.id, { visible: event.target.checked })} />} label={name} />
<Stack direction="row" spacing={0.5} useFlexGap flexWrap="wrap">
{([-1, 1] as const).map(direction => <Button key={direction} data-move={direction} disabled={saving || index + direction < 0 || index + direction >= draft.widgets.length}
aria-label={t(direction === -1 ? "dashboardMoveEarlierNamed" : "dashboardMoveLaterNamed", { name })}
onClick={() => { pendingFocus.current = { id: widget.id, direction }; reorder(index, index + direction); }} sx={{ minHeight: 44 }}>{t(direction === -1 ? "dashboardEarlier" : "dashboardLater")}</Button>)}
</Stack>
<TextField select size="small" label={t("dashboardSize")} value={widget.size} disabled={saving || entry.sizes.length === 1}
onChange={event => change(widget.id, { size: event.target.value as "standard" | "full" })}
slotProps={{ select: { native: true }, htmlInput: { "aria-label": t("dashboardSizeFor", { name }) } }} sx={{ width: { xs: "100%", sm: 190 }, minWidth: 0, "& select": { minHeight: 28 } }}>
{entry.sizes.map(size => <option key={size} value={size}>{t(size === "full" ? "dashboardSizeFull" : "dashboardSizeStandard")}</option>)}
</TextField>
</Box>;
return <FormControlLabel key={widget.id} sx={{ minWidth: 0, overflowWrap: "anywhere" }} label={name}
control={<Checkbox checked={widget.visible} disabled={saving || conflict} onChange={event => onChange({ ...draft,
widgets: arrangeDashboardWidgets(draft.widgets.map(item => item.id === widget.id ? { ...item, visible: event.target.checked } : item)) })} />} />;
})}
</Stack>
</Box>
<Typography id="dashboard-drag-help" variant="body2" color="text.secondary" sx={{ mt: 2 }}>{t("dashboardReorderHelp")}</Typography>
</PageSection>
</Box>;
}
@@ -19,6 +19,7 @@ import OnboardingChecklist from "./OnboardingChecklist";
import { WIDGETS, defaultDashboardLayout, WidgetId } from "../dashboardLayout";
import { useDashboardLayout } from "../hooks/useDashboardLayout";
import DashboardLayoutEditor, { DashboardNavigationGuard } from "./DashboardLayoutEditor";
import DashboardGrid from "./DashboardGrid";
import { useOptionalToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
import { statusLabel, statusTone } from "../pipeline";
@@ -363,16 +364,13 @@ export default function DashboardView({ mode = "dashboard" }: { mode?: "dashboar
{summaryReady && hasJobs ? <Box sx={grid}>{reports.stageAge}{reports.sources}</Box> : null}
{reports.salary}
<Box sx={grid}>{reports.companies}{reports.skills}</Box>
</> : <Box role={layoutState.editing ? "region" : undefined} aria-label={layoutState.editing ? t("dashboardPreview") : undefined} sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "repeat(2, minmax(0, 1fr))" }, gap: 3, alignItems: "start" }}>
{activeLayout.widgets.filter(widget => widget.visible).map(widget => {
</> : <DashboardGrid layout={activeLayout} editing={layoutState.editing} disabled={layoutState.saving || layoutState.conflict || layoutState.loading}
onChange={layoutState.setDraft} childrenFor={widget => {
const content = reports[widget.id];
const ready = widget.id === "activity" || widget.id === "skills" ? trendsReady : summaryReady;
if (!content && !(ready && (hasJobs || layoutState.editing))) return null;
return <Box key={widget.id} data-dashboard-widget={widget.id} data-widget-size={widget.size} sx={{ minWidth: 0, gridColumn: { xs: "auto", md: widget.size === "full" ? "1 / -1" : "auto" } }}>
{content ?? <PageSection title={t(WIDGETS.find(item => item.id === widget.id)!.titleKey)}><Typography color="text.secondary">{t("dashboardNoSample")}</Typography></PageSection>}
</Box>;
})}
</Box>}
return content ?? <PageSection title={t(WIDGETS.find(item => item.id === widget.id)!.titleKey)}><Typography color="text.secondary">{t("dashboardNoSample")}</Typography></PageSection>;
}} />}
</Box>
);
}
@@ -26,7 +26,18 @@ beforeEach(() => {
mocked.get.mockImplementation((url: string) => Promise.resolve({ data: response(url) }) as any);
mocked.put.mockReset(); mocked.put.mockImplementation((_url: string, body: any) => { stored = { ...body, revision: body.revision + 1 }; return Promise.resolve({ data: stored }) as any; });
});
async function edit() { fireEvent.click(await screen.findByRole('button', { name: 'Customize dashboard' })); return screen.findByRole('region', { name: 'Editing dashboard' }); }
async function edit() {
fireEvent.click(await screen.findByRole('button', { name: 'Customize dashboard' }));
const editor = await screen.findByRole('region', { name: 'Editing dashboard' });
fireEvent.click(within(editor).getByText('Manage dashboard widgets'));
return editor;
}
function widgetOptions(name: string) {
const preview = screen.getByRole('region', { name: 'Dashboard preview' });
fireEvent.click(within(preview).getAllByText(name).find(element => element.tagName === 'SUMMARY')!);
return preview;
}
test('visibility is a preview; cancel restores the saved layout without a write', async () => {
open(); await screen.findByText('Tracked jobs');
@@ -42,15 +53,16 @@ test('visibility is a preview; cancel restores the saved layout without a write'
test('failed save keeps the draft and retry persists visibility, order and size', async () => {
open(); const editor = await edit();
fireEvent.click(within(editor).getByRole('checkbox', { name: 'Summary cards' }));
fireEvent.click(within(editor).getByRole('button', { name: 'Move Today earlier' }));
fireEvent.change(within(editor).getByLabelText('Size for Today'), { target: { value: 'full' } });
const preview = widgetOptions('Today');
fireEvent.click(within(preview).getByRole('button', { name: 'Move Today earlier' }));
fireEvent.change(within(preview).getByLabelText('Size for Today'), { target: { value: 'full' } });
mocked.put.mockRejectedValueOnce(new Error('offline'));
fireEvent.click(within(editor).getByRole('button', { name: 'Save layout' }));
await screen.findByText('Your layout could not be saved. Your changes are still here. Try again.');
expect(within(editor).getByRole('checkbox', { name: 'Summary cards' })).not.toBeChecked();
fireEvent.click(within(editor).getByRole('button', { name: 'Save layout' }));
await waitFor(() => expect(screen.queryByRole('region', { name: 'Editing dashboard' })).not.toBeInTheDocument());
expect(stored.widgets[0]).toEqual({ id: 'attention', visible: true, size: 'full' });
expect(stored.widgets[0]).toMatchObject({ id: 'attention', visible: true, size: 'full', x: 0, y: 0, w: 12 });
expect(stored.widgets.find((widget: any) => widget.id === 'cards').visible).toBe(false);
expect(screen.queryByText('Tracked jobs')).not.toBeInTheDocument();
});
@@ -67,8 +79,9 @@ test('all hidden widgets leave an accessible recovery and reset remains cancella
test('keyboard ordering keeps focus and announces the resulting position without refetching reports', async () => {
open(); const editor = await edit(); const before = mocked.get.mock.calls.length;
const move = within(editor).getByRole('button', { name: 'Move Today earlier' }); move.focus(); fireEvent.click(move);
await waitFor(() => expect(within(editor).getByRole('button', { name: 'Move Today later' })).toHaveFocus());
const preview = widgetOptions('Today');
const move = within(preview).getByRole('button', { name: 'Move Today earlier' }); move.focus(); fireEvent.click(move);
await waitFor(() => expect(within(preview).getByRole('button', { name: 'Move Today later' })).toHaveFocus());
expect(screen.getByText('Today moved to position 1 of 9.')).toBeInTheDocument();
expect(mocked.get.mock.calls).toHaveLength(before);
});
+32 -3
View File
@@ -1,5 +1,34 @@
import { defaultDashboardLayout, normalizeDashboardLayout, WIDGETS } from './dashboardLayout';
test('legacy preferences migrate to bounded geometry while preserving order, visibility and revision', () => {
const input = { version: 1, revision: 7, widgets: [
{ id: 'skills', visible: false, size: 'full' },
{ id: 'attention', visible: true, size: 'standard' },
{ id: 'cards', visible: true, size: 'full' },
] };
const original = JSON.stringify(input);
const result = normalizeDashboardLayout(input);
expect(result.version).toBe(2);
expect(result.revision).toBe(7);
expect(result.widgets.map(row => row.id).slice(0, 3)).toEqual(['skills', 'attention', 'cards']);
expect(result.widgets[0]).toMatchObject({ visible: false, size: 'full', w: 12 });
expect(result.widgets[1]).toMatchObject({ visible: true, size: 'standard', x: 0, y: 0, w: 6 });
expect(result.widgets[2]).toMatchObject({ visible: true, size: 'full', x: 0, w: 12 });
expect(result.widgets.slice(3).every(row => !row.visible)).toBe(true);
expect(normalizeDashboardLayout(input)).toEqual(result);
expect(JSON.stringify(input)).toBe(original);
});
test('schema two preserves chosen hidden geometry without resetting the owner revision', () => {
const result = normalizeDashboardLayout({ version: 2, revision: 12, widgets: [
{ id: 'attention', visible: false, size: 'standard', x: 4, y: 17, w: 8, h: 10 },
] });
expect(result.version).toBe(2);
expect(result.revision).toBe(12);
expect(result.widgets[0]).toEqual({ id: 'attention', visible: false, size: 'standard', x: 4, y: 17, w: 8, h: 10 });
expect(result.widgets.slice(1).every(row => !row.visible)).toBe(true);
});
test('default catalogue preserves every section and supported widths', () => {
const layout = defaultDashboardLayout();
expect(layout.widgets.map(w => w.id)).toEqual(['cards', 'attention', 'funnel', 'activity', 'stageAge', 'sources', 'salary', 'companies', 'skills']);
@@ -17,12 +46,12 @@ test('saved layouts retain known order, reject duplicates and append missing sec
{ id: 'cards', visible: 'yes', size: 'standard' },
] });
expect(result.revision).toBe(7);
expect(result.widgets[0]).toEqual({ id: 'skills', visible: false, size: 'full' });
expect(result.widgets[1]).toEqual({ id: 'cards', visible: false, size: 'full' });
expect(result.widgets[0]).toMatchObject({ id: 'skills', visible: false, size: 'full' });
expect(result.widgets[1]).toMatchObject({ id: 'cards', visible: false, size: 'full' });
expect(result.widgets.slice(2).every(w => !w.visible)).toBe(true);
expect(result.widgets).toHaveLength(9);
});
test.each([null, {}, { version: 2, revision: 8, widgets: [] }, { version: 1, revision: -1, widgets: [] }])('invalid input has safe defaults: %j', input => {
test.each([null, {}, { version: 3, revision: 8, widgets: [] }, { version: 1, revision: -1, widgets: [] }])('invalid input has safe defaults: %j', input => {
expect(normalizeDashboardLayout(input)).toEqual(defaultDashboardLayout());
});
+26 -7
View File
@@ -13,17 +13,30 @@ export const WIDGETS = [
] as const;
export type WidgetId = typeof WIDGETS[number]['id'];
export type DashboardWidget = { id: WidgetId; visible: boolean; size: WidgetSize };
export type DashboardLayout = { version: 1; revision: number; widgets: DashboardWidget[] };
export type DashboardWidget = { id: WidgetId; visible: boolean; size: WidgetSize; x: number; y: number; w: number; h: number };
export type DashboardLayout = { version: 2; revision: number; widgets: DashboardWidget[] };
export function arrangeDashboardWidgets(widgets: DashboardWidget[]): DashboardWidget[] {
let x = 0, y = 0, rowHeight = 0;
return widgets.map(widget => {
if (!widget.visible) return { ...widget };
if (x + widget.w > 12) { x = 0; y += rowHeight; rowHeight = 0; }
const placed = { ...widget, x, y };
x += widget.w;
rowHeight = Math.max(rowHeight, widget.h);
return placed;
});
}
export function defaultDashboardLayout(): DashboardLayout {
return { version: 1, revision: 0, widgets: WIDGETS.map(w => ({ id: w.id, visible: true, size: w.defaultSize })) };
return { version: 2, revision: 0, widgets: arrangeDashboardWidgets(WIDGETS.map(w => ({ id: w.id, visible: true, size: w.defaultSize,
x: 0, y: 0, w: w.defaultSize === 'full' ? 12 : 6, h: w.id === 'cards' ? 5 : 8 }))) };
}
export function normalizeDashboardLayout(input: unknown): DashboardLayout {
if (!input || typeof input !== 'object') return defaultDashboardLayout();
const value = input as Record<string, unknown>;
if (value.version !== 1 || !Number.isSafeInteger(value.revision) || (value.revision as number) < 0 || !Array.isArray(value.widgets)) return defaultDashboardLayout();
if ((value.version !== 1 && value.version !== 2) || !Number.isSafeInteger(value.revision) || (value.revision as number) < 0 || !Array.isArray(value.widgets)) return defaultDashboardLayout();
const revision = value.revision as number;
const widgets: DashboardWidget[] = [];
const seen = new Set<WidgetId>();
@@ -32,11 +45,17 @@ export function normalizeDashboardLayout(input: unknown): DashboardLayout {
const definition = WIDGETS.find(w => w.id === item.id);
if (!definition || seen.has(definition.id)) continue;
seen.add(definition.id);
const size: WidgetSize = (definition.sizes as readonly string[]).includes(item.size) ? item.size : definition.defaultSize;
const valid = value.version === 2 && [item.x, item.y, item.w, item.h].every(Number.isInteger)
&& item.x >= 0 && item.y >= 0 && item.w >= 4 && item.w <= 12 && item.h >= 4 && item.h <= 24
&& item.x + item.w <= 12 && item.y + item.h <= 256 && (definition.id !== 'cards' || item.w === 12);
const w = valid ? item.w : size === 'full' ? 12 : 6;
widgets.push({ id: definition.id, visible: typeof item.visible === 'boolean' ? item.visible : revision === 0,
size: (definition.sizes as readonly string[]).includes(item.size) ? item.size : definition.defaultSize });
size: w === 12 ? 'full' : 'standard', x: valid ? item.x : 0, y: valid ? item.y : 0, w,
h: valid ? item.h : definition.id === 'cards' ? 5 : 8 });
}
for (const definition of WIDGETS) {
if (!seen.has(definition.id)) widgets.push({ id: definition.id, visible: revision === 0, size: definition.defaultSize });
if (!seen.has(definition.id)) widgets.push({ ...defaultDashboardLayout().widgets.find(w => w.id === definition.id)!, visible: revision === 0 });
}
return { version: 1, revision, widgets };
return { version: 2, revision, widgets: value.version === 1 ? arrangeDashboardWidgets(widgets) : widgets };
}
@@ -58,7 +58,7 @@ test('failed or future-version reads cannot enable editing or saves', async () =
await waitFor(() => expect(result.current.loadError).toBe('request'));
act(() => result.current.startEditing());
expect(result.current.editing).toBe(false);
get.mockResolvedValueOnce({ data: { version: 2, revision: 5, widgets: [] } });
get.mockResolvedValueOnce({ data: { version: 3, revision: 5, widgets: [] } });
await act(async () => result.current.reload());
expect(result.current.loadError).toBe('unsupportedVersion');
act(() => result.current.startEditing());
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateA
import { api } from '../api';
import { AUTH_USER_CHANGED_EVENT, getAuthUserKey } from '../auth';
import { getUserKeyFromToken } from '../themePrefs';
import { defaultDashboardLayout, normalizeDashboardLayout, type DashboardLayout } from '../dashboardLayout';
import { arrangeDashboardWidgets, defaultDashboardLayout, normalizeDashboardLayout, type DashboardLayout } from '../dashboardLayout';
type LayoutError = 'request' | 'unsupportedVersion' | null;
type LayoutState = {
@@ -16,8 +16,8 @@ function initialState(owner: string, loading: boolean): LayoutState {
}
function readLayout(input: unknown): DashboardLayout {
const value = input as Partial<DashboardLayout> | null;
if (value && value.version !== 1) throw new Error('unsupportedVersion');
const value = input as { version?: unknown; revision?: unknown; widgets?: unknown } | null;
if (value && value.version !== 1 && value.version !== 2) throw new Error('unsupportedVersion');
if (!value || !Number.isSafeInteger(value.revision) || (value.revision as number) < 0 || !Array.isArray(value.widgets)) throw new Error('request');
const layout = normalizeDashboardLayout(input);
if (layout.revision === 0 && layout.widgets.length && value.widgets.length === 0 && getUserKeyFromToken() !== 'anon') {
@@ -26,6 +26,7 @@ function readLayout(input: unknown): DashboardLayout {
for (const widget of layout.widgets) {
if (['cards', 'activity', 'funnel', 'companies', 'skills'].includes(widget.id) && typeof legacy?.[widget.id] === 'boolean') widget.visible = legacy[widget.id];
}
layout.widgets = arrangeDashboardWidgets(layout.widgets);
} catch { /* Corrupt or unavailable local preferences leave defaults intact. */ }
}
return layout;
+8
View File
@@ -1921,6 +1921,10 @@ export const translations = {
dashboardMoved: "{name} moved to position {position} of {total}.",
dashboardSizeFor: "Size for {name}",
dashboardSize: "Size",
dashboardHeightFor: "Height for {name}",
dashboardHideWidget: "Hide widget",
dashboardWidgetHidden: "{name} hidden. Use Manage dashboard widgets to show it again.",
dashboardGridHelp: "Drag a widget handle or resize its lower corner. Open the widget name for keyboard movement, size and visibility controls. On smaller screens widgets fill the width; desktop positions are preserved.",
dashboardSizeStandard: "Standard",
dashboardSizeFull: "Full width",
dashboardReorderHelp: "Drag a handle to reorder, or use Earlier and Later with keyboard or touch. Hidden widgets keep their position and size. On small screens every widget fills the width.",
@@ -4947,6 +4951,10 @@ export const translations = {
dashboardMoved: "{name} er flyttet til plass {position} av {total}.",
dashboardSizeFor: "Størrelse for {name}",
dashboardSize: "Størrelse",
dashboardHeightFor: "Høyde for {name}",
dashboardHideWidget: "Skjul widget",
dashboardWidgetHidden: "{name} er skjult. Bruk Administrer dashboard-widgeter for å vise den igjen.",
dashboardGridHelp: "Dra i håndtaket eller endre størrelse fra nedre hjørne. Åpne widgetnavnet for å flytte, endre størrelse eller skjule med tastaturet. På mindre skjermer fyller widgetene bredden; plasseringen på større skjermer beholdes.",
dashboardSizeStandard: "Standard",
dashboardSizeFull: "Full bredde",
dashboardReorderHelp: "Dra i håndtaket for å endre rekkefølgen, eller bruk Tidligere og Senere med tastatur eller berøring. Skjulte widgeter beholder plass og størrelse. På små skjermer fyller alle widgeter bredden.",