feat(dashboard): manage account layout drafts and recovery
This commit is contained in:
@@ -0,0 +1,28 @@
|
|||||||
|
import { defaultDashboardLayout, normalizeDashboardLayout, WIDGETS } from './dashboardLayout';
|
||||||
|
|
||||||
|
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']);
|
||||||
|
expect(layout.widgets.every(w => w.visible)).toBe(true);
|
||||||
|
expect(WIDGETS[0].sizes).toEqual(['full']);
|
||||||
|
layout.widgets[0].visible = false;
|
||||||
|
expect(defaultDashboardLayout().widgets[0].visible).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('saved layouts retain known order, reject duplicates and append missing sections hidden', () => {
|
||||||
|
const result = normalizeDashboardLayout({ version: 1, revision: 7, widgets: [
|
||||||
|
{ id: 'skills', visible: false, size: 'full' },
|
||||||
|
{ id: 'unknown', visible: true, size: 'full' },
|
||||||
|
{ id: 'skills', visible: true, size: 'standard' },
|
||||||
|
{ 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.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 => {
|
||||||
|
expect(normalizeDashboardLayout(input)).toEqual(defaultDashboardLayout());
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
export type WidgetSize = 'standard' | 'full';
|
||||||
|
|
||||||
|
export const WIDGETS = [
|
||||||
|
{ id: 'cards', titleKey: 'dashboardSummaryCards', defaultSize: 'full', sizes: ['full'] },
|
||||||
|
{ id: 'attention', titleKey: 'dashboardTodayTitle', defaultSize: 'standard', sizes: ['standard', 'full'] },
|
||||||
|
{ id: 'funnel', titleKey: 'dashboardCurrentPipeline', defaultSize: 'standard', sizes: ['standard', 'full'] },
|
||||||
|
{ id: 'activity', titleKey: 'dashboardApplicationActivity', defaultSize: 'full', sizes: ['standard', 'full'] },
|
||||||
|
{ id: 'stageAge', titleKey: 'dashboardStageAge', defaultSize: 'standard', sizes: ['standard', 'full'] },
|
||||||
|
{ id: 'sources', titleKey: 'dashboardResponseSources', defaultSize: 'standard', sizes: ['standard', 'full'] },
|
||||||
|
{ id: 'salary', titleKey: 'dashboardSalaryInsights', defaultSize: 'full', sizes: ['standard', 'full'] },
|
||||||
|
{ id: 'companies', titleKey: 'dashboardTopCompaniesByActivity', defaultSize: 'standard', sizes: ['standard', 'full'] },
|
||||||
|
{ id: 'skills', titleKey: 'dashboardJobTags', defaultSize: 'standard', sizes: ['standard', 'full'] },
|
||||||
|
] 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 function defaultDashboardLayout(): DashboardLayout {
|
||||||
|
return { version: 1, revision: 0, widgets: WIDGETS.map(w => ({ id: w.id, visible: true, size: w.defaultSize })) };
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
const revision = value.revision as number;
|
||||||
|
const widgets: DashboardWidget[] = [];
|
||||||
|
const seen = new Set<WidgetId>();
|
||||||
|
for (const item of value.widgets) {
|
||||||
|
if (!item || typeof item !== 'object') continue;
|
||||||
|
const definition = WIDGETS.find(w => w.id === item.id);
|
||||||
|
if (!definition || seen.has(definition.id)) continue;
|
||||||
|
seen.add(definition.id);
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
for (const definition of WIDGETS) {
|
||||||
|
if (!seen.has(definition.id)) widgets.push({ id: definition.id, visible: revision === 0, size: definition.defaultSize });
|
||||||
|
}
|
||||||
|
return { version: 1, revision, widgets };
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||||
|
import { api } from '../api';
|
||||||
|
import { setAuthUserKey } from '../auth';
|
||||||
|
import { defaultDashboardLayout } from '../dashboardLayout';
|
||||||
|
import { useDashboardLayout } from './useDashboardLayout';
|
||||||
|
|
||||||
|
jest.mock('../api', () => ({ api: { get: jest.fn(), put: jest.fn() } }));
|
||||||
|
const get = api.get as jest.Mock;
|
||||||
|
const put = api.put as jest.Mock;
|
||||||
|
const unsaved = { version: 1, revision: 0, widgets: [] };
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
localStorage.clear();
|
||||||
|
setAuthUserKey('a', false);
|
||||||
|
get.mockResolvedValue({ data: unsaved });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loads once, migrates only owner legacy flags, and cancels without writing', async () => {
|
||||||
|
localStorage.setItem('dashboardPrefs:a', JSON.stringify({ cards: false, activity: false }));
|
||||||
|
const { result, rerender } = renderHook(() => useDashboardLayout(true));
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
expect(result.current.layout.widgets[0].visible).toBe(false);
|
||||||
|
expect(result.current.layout.widgets[1].visible).toBe(true);
|
||||||
|
act(() => result.current.startEditing());
|
||||||
|
act(() => result.current.setDraft(d => d && ({ ...d, widgets: [...d.widgets].reverse() })));
|
||||||
|
rerender();
|
||||||
|
expect(result.current.dirty).toBe(true);
|
||||||
|
expect(get).toHaveBeenCalledTimes(1);
|
||||||
|
act(() => result.current.cancelEditing());
|
||||||
|
expect(result.current.draft).toBeNull();
|
||||||
|
expect(put).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('save waits for acknowledgement and retains draft on conflict until explicit reload', async () => {
|
||||||
|
const { result } = renderHook(() => useDashboardLayout(true));
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
act(() => result.current.startEditing());
|
||||||
|
act(() => result.current.setDraft(d => d && ({ ...d, widgets: d.widgets.map(w => ({ ...w, visible: false })) })));
|
||||||
|
put.mockRejectedValueOnce({ response: { status: 409 } });
|
||||||
|
await act(async () => { expect(await result.current.save()).toBe(false); });
|
||||||
|
expect(result.current.conflict).toBe(true);
|
||||||
|
expect(result.current.draft?.widgets.every(w => !w.visible)).toBe(true);
|
||||||
|
expect(result.current.layout.widgets.every(w => w.visible)).toBe(true);
|
||||||
|
get.mockResolvedValueOnce({ data: { ...defaultDashboardLayout(), revision: 3 } });
|
||||||
|
await act(async () => result.current.reload());
|
||||||
|
expect(result.current.editing).toBe(true);
|
||||||
|
expect(result.current.draft?.revision).toBe(3);
|
||||||
|
expect(result.current.conflict).toBe(false);
|
||||||
|
put.mockResolvedValueOnce({ data: { ...defaultDashboardLayout(), revision: 4 } });
|
||||||
|
await act(async () => { expect(await result.current.save()).toBe(true); });
|
||||||
|
expect(result.current.layout.revision).toBe(4);
|
||||||
|
expect(result.current.editing).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failed or future-version reads cannot enable editing or saves', async () => {
|
||||||
|
get.mockRejectedValueOnce(new Error('offline'));
|
||||||
|
const { result } = renderHook(() => useDashboardLayout(true));
|
||||||
|
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: [] } });
|
||||||
|
await act(async () => result.current.reload());
|
||||||
|
expect(result.current.loadError).toBe('unsupportedVersion');
|
||||||
|
act(() => result.current.startEditing());
|
||||||
|
await act(async () => { expect(await result.current.save()).toBe(false); });
|
||||||
|
expect(put).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('old save handlers and delayed reads cannot affect a new owner', async () => {
|
||||||
|
let resolveRead!: (value: unknown) => void;
|
||||||
|
const { result } = renderHook(() => useDashboardLayout(true));
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
act(() => result.current.startEditing());
|
||||||
|
const oldSave = result.current.save;
|
||||||
|
get.mockImplementationOnce(() => new Promise(resolve => { resolveRead = resolve; }));
|
||||||
|
let oldRead!: Promise<void>;
|
||||||
|
act(() => { oldRead = result.current.reload(); });
|
||||||
|
act(() => setAuthUserKey('b'));
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
await act(async () => { resolveRead({ data: { ...defaultDashboardLayout(), revision: 99 } }); await oldRead; });
|
||||||
|
await act(async () => { expect(await oldSave()).toBe(false); });
|
||||||
|
expect(result.current.layout.revision).toBe(0);
|
||||||
|
expect(result.current.editing).toBe(false);
|
||||||
|
expect(put).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pending save acknowledgement is ignored after identity changes', async () => {
|
||||||
|
let acknowledge!: (value: unknown) => void;
|
||||||
|
put.mockImplementationOnce(() => new Promise(resolve => { acknowledge = resolve; }));
|
||||||
|
const { result } = renderHook(() => useDashboardLayout(true));
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
act(() => result.current.startEditing());
|
||||||
|
let save!: Promise<boolean>;
|
||||||
|
act(() => { save = result.current.save(); });
|
||||||
|
expect(result.current.saving).toBe(true);
|
||||||
|
expect(result.current.layout.revision).toBe(0);
|
||||||
|
act(() => setAuthUserKey('b'));
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
await act(async () => {
|
||||||
|
acknowledge({ data: { ...defaultDashboardLayout(), revision: 10 } });
|
||||||
|
expect(await save).toBe(false);
|
||||||
|
});
|
||||||
|
expect(result.current.layout.revision).toBe(0);
|
||||||
|
expect(result.current.saving).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not import anonymous legacy flags or replace saved server choices', async () => {
|
||||||
|
localStorage.setItem('dashboardPrefs:anon', JSON.stringify({ cards: false }));
|
||||||
|
const { result } = renderHook(() => useDashboardLayout(true));
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
expect(result.current.layout.widgets[0].visible).toBe(true);
|
||||||
|
localStorage.setItem('dashboardPrefs:a', JSON.stringify({ cards: false }));
|
||||||
|
get.mockResolvedValueOnce({ data: { ...defaultDashboardLayout(), revision: 1 } });
|
||||||
|
await act(async () => result.current.reload());
|
||||||
|
expect(result.current.layout.widgets[0].visible).toBe(true);
|
||||||
|
});
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react';
|
||||||
|
import { api } from '../api';
|
||||||
|
import { AUTH_USER_CHANGED_EVENT, getAuthUserKey } from '../auth';
|
||||||
|
import { getUserKeyFromToken } from '../themePrefs';
|
||||||
|
import { defaultDashboardLayout, normalizeDashboardLayout, type DashboardLayout } from '../dashboardLayout';
|
||||||
|
|
||||||
|
type LayoutError = 'request' | 'unsupportedVersion' | null;
|
||||||
|
type LayoutState = {
|
||||||
|
owner: string; layout: DashboardLayout; draft: DashboardLayout | null; editing: boolean;
|
||||||
|
loading: boolean; loaded: boolean; loadError: LayoutError; saveError: LayoutError; conflict: boolean; saving: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function initialState(owner: string, loading: boolean): LayoutState {
|
||||||
|
return { owner, layout: defaultDashboardLayout(), draft: null, editing: false, loading, loaded: false,
|
||||||
|
loadError: null, saveError: null, conflict: false, saving: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
function readLayout(input: unknown): DashboardLayout {
|
||||||
|
const value = input as Partial<DashboardLayout> | null;
|
||||||
|
if (value && value.version !== 1) 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') {
|
||||||
|
try {
|
||||||
|
const legacy = JSON.parse(localStorage.getItem(`dashboardPrefs:${getUserKeyFromToken()}`) || 'null');
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
} catch { /* Corrupt or unavailable local preferences leave defaults intact. */ }
|
||||||
|
}
|
||||||
|
return layout;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clone = (layout: DashboardLayout): DashboardLayout => ({ ...layout, widgets: layout.widgets.map(w => ({ ...w })) });
|
||||||
|
|
||||||
|
export function useDashboardLayout(enabled: boolean) {
|
||||||
|
const [state, setState] = useState<LayoutState>(() => initialState(getAuthUserKey(), enabled));
|
||||||
|
const mounted = useRef(false);
|
||||||
|
const generation = useRef(0);
|
||||||
|
const controller = useRef<AbortController | null>(null);
|
||||||
|
const savePending = useRef(false);
|
||||||
|
const stateOwner = useRef(state.owner);
|
||||||
|
stateOwner.current = state.owner;
|
||||||
|
|
||||||
|
const reload = useCallback(async () => {
|
||||||
|
const owner = getAuthUserKey();
|
||||||
|
const request = ++generation.current;
|
||||||
|
controller.current?.abort();
|
||||||
|
const abort = new AbortController();
|
||||||
|
controller.current = abort;
|
||||||
|
savePending.current = false;
|
||||||
|
setState(s => enabled && s.owner === owner ? { ...s, loading: true, loaded: false, loadError: null, saveError: null, conflict: false, saving: false } : initialState(owner, enabled));
|
||||||
|
if (!enabled) return;
|
||||||
|
const current = () => mounted.current && generation.current === request && getAuthUserKey() === owner;
|
||||||
|
try {
|
||||||
|
const response = await api.get('/auth/preferences/dashboard', { signal: abort.signal });
|
||||||
|
if (!current()) return;
|
||||||
|
const layout = readLayout(response.data);
|
||||||
|
setState(s => ({ ...s, layout, draft: s.editing ? clone(layout) : null, loading: false, loaded: true }));
|
||||||
|
} catch (error) {
|
||||||
|
if (current()) setState(s => ({ ...s, loading: false, loaded: false, loadError: error instanceof Error && error.message === 'unsupportedVersion' ? 'unsupportedVersion' : 'request' }));
|
||||||
|
}
|
||||||
|
}, [enabled]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
mounted.current = true;
|
||||||
|
void reload();
|
||||||
|
const identityChanged = () => {
|
||||||
|
if (getAuthUserKey() !== stateOwner.current) {
|
||||||
|
stateOwner.current = getAuthUserKey();
|
||||||
|
void reload();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener(AUTH_USER_CHANGED_EVENT, identityChanged);
|
||||||
|
window.addEventListener('auth-changed', identityChanged);
|
||||||
|
return () => {
|
||||||
|
mounted.current = false;
|
||||||
|
controller.current?.abort();
|
||||||
|
window.removeEventListener(AUTH_USER_CHANGED_EVENT, identityChanged);
|
||||||
|
window.removeEventListener('auth-changed', identityChanged);
|
||||||
|
};
|
||||||
|
}, [reload]);
|
||||||
|
|
||||||
|
const startEditing = () => {
|
||||||
|
if (enabled && state.loaded && !state.loading && !state.saving && state.owner === getAuthUserKey()) setState(s => ({ ...s, draft: clone(s.layout), editing: true, saveError: null, conflict: false }));
|
||||||
|
};
|
||||||
|
const setDraft: Dispatch<SetStateAction<DashboardLayout | null>> = update => {
|
||||||
|
setState(s => s.editing && !s.saving && s.owner === getAuthUserKey()
|
||||||
|
? { ...s, draft: (typeof update === 'function' ? update(s.draft) : update) ?? s.draft } : s);
|
||||||
|
};
|
||||||
|
const cancelEditing = () => {
|
||||||
|
if (!savePending.current) setState(s => ({ ...s, draft: null, editing: false, saveError: null, conflict: false }));
|
||||||
|
};
|
||||||
|
const save = async (): Promise<boolean> => {
|
||||||
|
if (!enabled || !state.loaded || state.loading || !state.editing || !state.draft || state.conflict || savePending.current || state.owner !== getAuthUserKey()) return false;
|
||||||
|
const owner = state.owner;
|
||||||
|
const request = ++generation.current;
|
||||||
|
savePending.current = true;
|
||||||
|
const abort = new AbortController();
|
||||||
|
controller.current?.abort();
|
||||||
|
controller.current = abort;
|
||||||
|
const current = () => mounted.current && generation.current === request && getAuthUserKey() === owner;
|
||||||
|
setState(s => ({ ...s, saving: true, saveError: null }));
|
||||||
|
try {
|
||||||
|
const response = await api.put('/auth/preferences/dashboard', { ...normalizeDashboardLayout(state.draft), revision: state.layout.revision }, { signal: abort.signal });
|
||||||
|
if (!current()) return false;
|
||||||
|
const layout = readLayout(response.data);
|
||||||
|
setState(s => ({ ...s, layout, draft: null, editing: false, saving: false, conflict: false }));
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (current()) {
|
||||||
|
const conflict = (error as { response?: { status?: number } })?.response?.status === 409;
|
||||||
|
setState(s => ({ ...s, saving: false, conflict, saveError: error instanceof Error && error.message === 'unsupportedVersion' ? 'unsupportedVersion' : 'request' }));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
if (current()) savePending.current = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { layout: state.layout, draft: state.draft, editing: state.editing, loading: state.loading,
|
||||||
|
loadError: state.loadError, saveError: state.saveError, conflict: state.conflict, saving: state.saving,
|
||||||
|
dirty: state.editing && JSON.stringify(state.draft) !== JSON.stringify(state.layout),
|
||||||
|
startEditing, setDraft, cancelEditing, save, reload };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user