mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
* fix(web): persist design-files view state across navigation
pageSize, sortKey, sortDir, and kindFilter reset on every navigation
because DesignFilesPanel remounts via key={projectId}. Persist them to
localStorage under od:design-files:view-state:v1:<projectId> so each
project's view prefs survive tab-switching.
- Read persisted state via lazy useState initializers (SSR-safe try/catch)
- Write back in a single useEffect keyed on all four values
- Scoped per-project so proj-a settings never bleed into proj-b
- Schema-guarded: invalid/missing fields fall through to defaults
- Red spec: apps/web/tests/components/DesignFilesPanel.view-state-persist.test.tsx
* fix(web): address review feedback on view-state persistence
- Add typeof window guard in readViewState for explicit SSR safety
- Consolidate 4 separate localStorage reads into a single useRef read at
mount time; each lazy useState initializer now reads from savedViewState.current
instead of re-parsing localStorage independently
* fix(web): harden design-files view-state persistence
- Validate restored kindFilter values against the current ProjectFileKind
union via isProjectFileKind() so stale stored values from a prior schema
are dropped silently instead of being cast unchecked.
- Introduce DEFAULT_SORT_KEY/SORT_DIR/PAGE_SIZE constants so the useState
initialisers and the new validation guard share a single source of truth.
- Add viewStateHasMounted ref to skip the first-render write in the persist
useEffect. Without this guard every project the user visits accumulates a
default-value entry in localStorage on mount, growing stale-key garbage
unboundedly and making future field additions silently inject defaults into
every existing entry.
- Harden kindFilter test: replace the silent early-return-on-missing-trigger
with expect(filterTrigger).not.toBeNull() so a render failure surfaces as
a real test failure rather than a passing no-op.
* test(e2e): design files view state persists across navigation and reload
Adds a Playwright UI smoke test in e2e/ui/ that exercises the three key
guarantees of the view-state persistence fix:
(a) Tab-away / tab-back: navigating to a file tab and returning remounts
DesignFilesPanel (conditionally rendered); all four prefs (sortKey,
sortDir, pageSize, kindFilter) are restored from localStorage.
(b) Hard reload: localStorage survives page.reload(); prefs are intact on
the next mount.
(c) Per-project key isolation: a second project starts with defaults and
does not inherit values from the first project's localStorage entry.
The test uses OD_PORT=18011 / OD_WEB_PORT=18012 to avoid port conflicts with
the default development ports.
Also fixes a race in DesignFilesPanel: the stale-kind cleanup useEffect was
running against an empty availableKinds set before the async file list arrived
on mount, which cleared a kindFilter correctly restored from localStorage.
Guard added: skip the cleanup when availableKinds is empty.
Red on origin/main (no persistence logic exists there); green on this branch.
* fix(e2e): address code-reviewer feedback on view-state-persist test
- Add data-testid='df-page-size-select' to per-page <select> in
DesignFilesPanel (W2: decouple test from i18n string 'Show')
- Add StrictMode comment to viewStateHasMounted guard explaining
the dev-mode double-write behaviour (W1: document the invariant)
- Switch nav-away from dblclick to single-click + Open button,
matching the pattern used in app-design-files.test.ts (W4)
- Raise timeout from 60s to 90s for cold CI runners (W3)
- Unify seedTextFile/seedPngFile into shared seedFile helper (N3)
- Add home-hero-input assertion in gotoEntryHome (N2)
- Switch waitForPageSizeSelect to use data-testid (W2)
* test(e2e): split design-files persist into nav, reload, and per-project scenarios
* fix(web): tighten isPageSize to discrete option set, add invalid-value regression test
* fix(web): isolate DesignFilesPanel.test.tsx from persisted view-state key
191 lines
6.5 KiB
TypeScript
191 lines
6.5 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// Red spec for bug #3a: view state (pageSize, sortKey, sortDir, kindFilter)
|
|
// resets on navigation because the component remounts via key={projectId}.
|
|
// These tests must go RED on origin/main and GREEN after the fix.
|
|
|
|
import { cleanup, fireEvent, render } from '@testing-library/react';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { DesignFilesPanel } from '../../src/components/DesignFilesPanel';
|
|
import type { ProjectFile, ProjectFileKind } from '../../src/types';
|
|
|
|
// Minimal localStorage stub mirroring the pattern in state/config.test.ts
|
|
const store = new Map<string, string>();
|
|
vi.stubGlobal('localStorage', {
|
|
getItem: vi.fn((key: string) => store.get(key) ?? null),
|
|
setItem: vi.fn((key: string, value: string) => {
|
|
store.set(key, value);
|
|
}),
|
|
removeItem: vi.fn((key: string) => {
|
|
store.delete(key);
|
|
}),
|
|
clear: vi.fn(() => {
|
|
store.clear();
|
|
}),
|
|
});
|
|
|
|
function file(name: string, kind: ProjectFileKind = 'html', mtime = Date.now()): ProjectFile {
|
|
return { path: name, name, type: 'file', size: 1024, mtime, kind, mime: 'text/html' };
|
|
}
|
|
|
|
function generateFiles(count: number): ProjectFile[] {
|
|
const kinds: ProjectFileKind[] = ['html', 'image', 'sketch', 'text', 'code', 'pdf'];
|
|
return Array.from({ length: count }, (_, i) => {
|
|
const kind = kinds[i % kinds.length]!;
|
|
return file(`file-${i + 1}.html`, kind, Date.now() - i * 60_000);
|
|
});
|
|
}
|
|
|
|
function renderPanel(
|
|
files: ProjectFile[],
|
|
projectId = 'proj-a',
|
|
) {
|
|
return render(
|
|
<DesignFilesPanel
|
|
projectId={projectId}
|
|
files={files}
|
|
liveArtifacts={[]}
|
|
onRefreshFiles={vi.fn()}
|
|
onOpenFile={vi.fn()}
|
|
onOpenLiveArtifact={vi.fn()}
|
|
onRenameFile={vi.fn()}
|
|
onDeleteFile={vi.fn()}
|
|
onDeleteFiles={vi.fn()}
|
|
onUpload={vi.fn()}
|
|
onUploadFiles={vi.fn()}
|
|
onPaste={vi.fn()}
|
|
onNewSketch={vi.fn()}
|
|
/>,
|
|
);
|
|
}
|
|
|
|
function getPerPageSelect(container: HTMLElement): HTMLSelectElement {
|
|
// The per-page select is the first select in the panel
|
|
return container.querySelector<HTMLSelectElement>('.df-pagination-start select')!;
|
|
}
|
|
|
|
function getSortBtn(container: HTMLElement, label: string): HTMLElement {
|
|
return Array.from(container.querySelectorAll<HTMLElement>('.df-th-btn')).find(
|
|
(el) => el.textContent?.trim().startsWith(label),
|
|
)!;
|
|
}
|
|
|
|
describe('DesignFilesPanel view-state persistence', () => {
|
|
beforeEach(() => {
|
|
store.clear();
|
|
vi.mocked(localStorage.getItem).mockClear();
|
|
vi.mocked(localStorage.setItem).mockClear();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
});
|
|
|
|
it('restores pageSize from localStorage after remount', () => {
|
|
const files = generateFiles(500);
|
|
|
|
// First mount: change page size to 60
|
|
const first = renderPanel(files);
|
|
const sel = getPerPageSelect(first.container);
|
|
fireEvent.change(sel, { target: { value: '60' } });
|
|
first.unmount();
|
|
|
|
// Second mount simulates navigation away and back (key={projectId} causes remount)
|
|
const second = renderPanel(files);
|
|
const restoredSel = getPerPageSelect(second.container);
|
|
expect(restoredSel.value).toBe('60');
|
|
});
|
|
|
|
it('restores sort key from localStorage after remount', () => {
|
|
const files = generateFiles(50);
|
|
|
|
// First mount: click "Name" header to sort by name
|
|
const first = renderPanel(files);
|
|
fireEvent.click(getSortBtn(first.container, 'Name'));
|
|
first.unmount();
|
|
|
|
// Second mount: "Name" column should show the sort arrow
|
|
const second = renderPanel(files);
|
|
const nameBtn = getSortBtn(second.container, 'Name');
|
|
expect(nameBtn.textContent).toContain('↑');
|
|
});
|
|
|
|
it('restores sort direction from localStorage after remount', () => {
|
|
const files = generateFiles(50);
|
|
|
|
// First mount: click "Name" twice to get desc
|
|
const first = renderPanel(files);
|
|
fireEvent.click(getSortBtn(first.container, 'Name'));
|
|
fireEvent.click(getSortBtn(first.container, 'Name'));
|
|
first.unmount();
|
|
|
|
// Second mount: Name column should show desc arrow
|
|
const second = renderPanel(files);
|
|
const nameBtn = getSortBtn(second.container, 'Name');
|
|
expect(nameBtn.textContent).toContain('↓');
|
|
});
|
|
|
|
it('restores kindFilter from localStorage after remount', () => {
|
|
// Files with mixed kinds so the filter button appears
|
|
const files = [
|
|
file('a.html', 'html'),
|
|
file('b.png', 'image'),
|
|
file('c.txt', 'text'),
|
|
];
|
|
|
|
// First mount: open the filter popover and check 'HTML page'
|
|
const first = renderPanel(files);
|
|
const filterTrigger = first.container.querySelector<HTMLElement>('.df-kind-filter-trigger');
|
|
expect(filterTrigger).not.toBeNull();
|
|
fireEvent.click(filterTrigger!);
|
|
const checkboxes = first.container.querySelectorAll<HTMLInputElement>(
|
|
'.df-kind-filter-list input[type="checkbox"]',
|
|
);
|
|
// Check the first checkbox (HTML)
|
|
if (checkboxes[0]) fireEvent.click(checkboxes[0]);
|
|
first.unmount();
|
|
|
|
// Second mount: filter button should show active state (a kind is selected)
|
|
const second = renderPanel(files);
|
|
const trigger = second.container.querySelector('.df-kind-filter-trigger');
|
|
expect(trigger?.classList.contains('active')).toBe(true);
|
|
});
|
|
|
|
it('does not bleed pageSize from one project into another', () => {
|
|
const files = generateFiles(500);
|
|
|
|
// Project A: set page size 60
|
|
const first = renderPanel(files, 'proj-a');
|
|
fireEvent.change(getPerPageSelect(first.container), { target: { value: '60' } });
|
|
first.unmount();
|
|
|
|
// Project B: should still have the default (30), not project A's setting
|
|
const second = renderPanel(files, 'proj-b');
|
|
expect(getPerPageSelect(second.container).value).toBe('30');
|
|
});
|
|
|
|
it('writes view state to localStorage on pageSize change', () => {
|
|
const files = generateFiles(500);
|
|
const { container } = renderPanel(files);
|
|
|
|
fireEvent.change(getPerPageSelect(container), { target: { value: '45' } });
|
|
|
|
expect(vi.mocked(localStorage.setItem)).toHaveBeenCalledWith(
|
|
expect.stringMatching(/od:design-files:view-state/),
|
|
expect.any(String),
|
|
);
|
|
});
|
|
|
|
it('falls back to default pageSize when stored value is not a supported option', () => {
|
|
const files = generateFiles(500);
|
|
|
|
// Seed localStorage with an unsupported value (fractional, out-of-set integer)
|
|
for (const bad of [0.5, 17, 999, -1, 0]) {
|
|
vi.mocked(localStorage.getItem).mockReturnValueOnce(JSON.stringify({ pageSize: bad }));
|
|
const { container } = renderPanel(files);
|
|
expect(getPerPageSelect(container).value).toBe('30');
|
|
cleanup();
|
|
}
|
|
});
|
|
});
|