feat(dashboard): add revision-safe interactive widget grid
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user