mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +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
635 lines
22 KiB
TypeScript
635 lines
22 KiB
TypeScript
// @vitest-environment jsdom
|
||
|
||
import { act, cleanup, fireEvent, render, screen, waitFor, within } 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';
|
||
|
||
// Stub localStorage so the component's view-state persistence writes to an
|
||
// in-memory store. Cleared in beforeEach so no test bleeds state into the next.
|
||
const lsStore = new Map<string, string>();
|
||
vi.stubGlobal('localStorage', {
|
||
getItem: (key: string) => lsStore.get(key) ?? null,
|
||
setItem: (key: string, value: string) => { lsStore.set(key, value); },
|
||
removeItem: (key: string) => { lsStore.delete(key); },
|
||
clear: () => { lsStore.clear(); },
|
||
});
|
||
|
||
function extForKind(kind: ProjectFileKind): string {
|
||
if (kind === 'html') return 'html';
|
||
if (kind === 'image') return 'png';
|
||
if (kind === 'sketch') return 'sketch.json';
|
||
if (kind === 'text') return 'txt';
|
||
if (kind === 'code') return 'ts';
|
||
if (kind === 'pdf') return 'pdf';
|
||
return 'bin';
|
||
}
|
||
|
||
function file(overrides: Partial<ProjectFile> & Pick<ProjectFile, 'name'>): ProjectFile {
|
||
return {
|
||
path: overrides.name,
|
||
type: 'file',
|
||
size: 1024,
|
||
mtime: Date.now(),
|
||
kind: 'html',
|
||
mime: 'text/html',
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
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({
|
||
name: `file-${i + 1}.${extForKind(kind)}`,
|
||
kind,
|
||
size: 1024 * (i + 1),
|
||
mtime: Date.now() - i * 60_000,
|
||
mime: 'text/plain',
|
||
});
|
||
});
|
||
}
|
||
|
||
function renderPanel(files: ProjectFile[]) {
|
||
const onOpenFile = vi.fn();
|
||
const onDeleteFiles = vi.fn();
|
||
const result = render(
|
||
<DesignFilesPanel
|
||
projectId="test-project"
|
||
files={files}
|
||
liveArtifacts={[]}
|
||
onRefreshFiles={vi.fn()}
|
||
onOpenFile={onOpenFile}
|
||
onOpenLiveArtifact={vi.fn()}
|
||
onRenameFile={vi.fn()}
|
||
onDeleteFile={vi.fn()}
|
||
onDeleteFiles={onDeleteFiles}
|
||
onUpload={vi.fn()}
|
||
onUploadFiles={vi.fn()}
|
||
onPaste={vi.fn()}
|
||
onNewSketch={vi.fn()}
|
||
/>,
|
||
);
|
||
return { ...result, onDeleteFiles, onOpenFile };
|
||
}
|
||
|
||
function getPageInfo(container: HTMLElement): string {
|
||
const el = container.querySelector('.df-page-info');
|
||
return el?.textContent?.trim() ?? '';
|
||
}
|
||
|
||
/** page-btn order: bottom-Prev=0, bottom-Next=1 */
|
||
function getPageBtns(container: HTMLElement) {
|
||
return Array.from(container.querySelectorAll<HTMLButtonElement>('.df-page-btn'));
|
||
}
|
||
|
||
function getSelects(container: HTMLElement) {
|
||
return Array.from(container.querySelectorAll<HTMLSelectElement>('select'));
|
||
}
|
||
|
||
describe('DesignFilesPanel grouping', () => {
|
||
beforeEach(() => {
|
||
lsStore.clear();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup();
|
||
vi.useRealTimers();
|
||
});
|
||
|
||
it('does not show grouping controls when only live artifacts are available', () => {
|
||
render(
|
||
<DesignFilesPanel
|
||
projectId="project-1"
|
||
files={[]}
|
||
liveArtifacts={[
|
||
{
|
||
kind: 'live-artifact',
|
||
artifactId: 'artifact-1',
|
||
tabId: 'live:artifact-1',
|
||
projectId: 'project-1',
|
||
title: 'Live Preview',
|
||
slug: 'live-preview',
|
||
status: 'active',
|
||
refreshStatus: 'idle',
|
||
pinned: false,
|
||
preview: { type: 'html', entry: 'index.html' },
|
||
hasDocument: true,
|
||
updatedAt: '2026-05-09T12:00:00.000Z',
|
||
},
|
||
]}
|
||
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()}
|
||
/>,
|
||
);
|
||
|
||
expect(screen.queryByRole('group', { name: 'Group by' })).toBeNull();
|
||
expect(screen.getByTestId('design-file-row-live:artifact-1')).toBeTruthy();
|
||
});
|
||
|
||
it('groups files by kind when kind grouping is selected', () => {
|
||
renderPanel([
|
||
file({ name: 'page.html', kind: 'html', mime: 'text/html' }),
|
||
file({ name: 'chart.png', kind: 'image', mime: 'image/png' }),
|
||
]);
|
||
|
||
const sectionLabels = Array.from(
|
||
document.querySelectorAll<HTMLElement>('.df-section-label'),
|
||
).map((el) => el.textContent ?? '');
|
||
expect(sectionLabels.some((text) => text.includes('HTML page'))).toBe(true);
|
||
expect(sectionLabels.some((text) => text.includes('Image'))).toBe(true);
|
||
expect(screen.getByTestId('design-file-row-page.html')).toBeTruthy();
|
||
expect(screen.getByTestId('design-file-row-chart.png')).toBeTruthy();
|
||
expect(screen.queryByText('Today')).toBeNull();
|
||
});
|
||
|
||
it('keeps kind grouping selected by default', () => {
|
||
renderPanel([
|
||
file({ name: 'page.html', kind: 'html', mime: 'text/html' }),
|
||
file({ name: 'chart.png', kind: 'image', mime: 'image/png' }),
|
||
]);
|
||
|
||
const groupControls = screen.getByRole('group', { name: 'Group by' });
|
||
const kindGroupButton = within(groupControls).getByRole('button', { name: 'Kind' });
|
||
expect(kindGroupButton.getAttribute('aria-pressed')).toBe('true');
|
||
expect(screen.getByText('Name')).toBeTruthy();
|
||
expect(document.querySelector('.df-th-kind')?.textContent).toContain('Kind');
|
||
expect(screen.queryByText('Today')).toBeNull();
|
||
});
|
||
|
||
it('can group files by modified date and collapse a date group', () => {
|
||
const now = new Date(2026, 4, 9, 12).getTime();
|
||
vi.useFakeTimers();
|
||
vi.setSystemTime(now);
|
||
|
||
renderPanel([
|
||
file({ name: 'today.html', mtime: new Date(2026, 4, 9, 11).getTime() }),
|
||
file({ name: 'yesterday.html', mtime: new Date(2026, 4, 8, 12).getTime() }),
|
||
]);
|
||
|
||
expect(screen.queryByText('Today')).toBeNull();
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: 'Modified' }));
|
||
|
||
expect(screen.getByText('Today')).toBeTruthy();
|
||
expect(screen.getByText('Yesterday')).toBeTruthy();
|
||
expect(screen.getByTestId('design-file-row-today.html')).toBeTruthy();
|
||
expect(screen.getByTestId('design-file-row-yesterday.html')).toBeTruthy();
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: /Collapse Today/i }));
|
||
|
||
expect(screen.queryByTestId('design-file-row-today.html')).toBeNull();
|
||
expect(screen.getByTestId('design-file-row-yesterday.html')).toBeTruthy();
|
||
});
|
||
|
||
it('keeps files from seven calendar days ago in the previous 7 days group', () => {
|
||
const now = new Date(2026, 4, 9, 12).getTime();
|
||
vi.useFakeTimers();
|
||
vi.setSystemTime(now);
|
||
|
||
renderPanel([file({ name: 'week-old.html', mtime: new Date(2026, 4, 2, 12).getTime() })]);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: 'Modified' }));
|
||
|
||
expect(screen.getByText('Previous 7 days')).toBeTruthy();
|
||
expect(screen.queryByText('Previous 30 days')).toBeNull();
|
||
expect(screen.getByTestId('design-file-row-week-old.html')).toBeTruthy();
|
||
});
|
||
|
||
it('keeps files at the seven calendar day boundary in the previous 7 days group', () => {
|
||
const now = new Date(2026, 4, 9, 12).getTime();
|
||
vi.useFakeTimers();
|
||
vi.setSystemTime(now);
|
||
|
||
renderPanel([
|
||
file({ name: 'week-boundary.html', mtime: new Date(2026, 4, 2, 0, 0, 0, 0).getTime() }),
|
||
]);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: 'Modified' }));
|
||
|
||
expect(screen.getByText('Previous 7 days')).toBeTruthy();
|
||
expect(screen.queryByText('Previous 30 days')).toBeNull();
|
||
expect(screen.getByTestId('design-file-row-week-boundary.html')).toBeTruthy();
|
||
});
|
||
|
||
it('keeps files from thirty calendar days ago in the previous 30 days group', () => {
|
||
const now = new Date(2026, 4, 9, 12).getTime();
|
||
vi.useFakeTimers();
|
||
vi.setSystemTime(now);
|
||
|
||
renderPanel([
|
||
file({ name: 'month-old.html', mtime: new Date(2026, 3, 9, 12).getTime() }),
|
||
]);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: 'Modified' }));
|
||
|
||
expect(screen.getByText('Previous 30 days')).toBeTruthy();
|
||
expect(screen.queryByText('Older')).toBeNull();
|
||
expect(screen.getByTestId('design-file-row-month-old.html')).toBeTruthy();
|
||
});
|
||
|
||
it('keeps files at the thirty calendar day boundary in the previous 30 days group', () => {
|
||
const now = new Date(2026, 4, 9, 12).getTime();
|
||
vi.useFakeTimers();
|
||
vi.setSystemTime(now);
|
||
|
||
renderPanel([
|
||
file({
|
||
name: 'month-boundary.html',
|
||
mtime: new Date(2026, 3, 9, 0, 0, 0, 0).getTime(),
|
||
}),
|
||
]);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: 'Modified' }));
|
||
|
||
expect(screen.getByText('Previous 30 days')).toBeTruthy();
|
||
expect(screen.queryByText('Older')).toBeNull();
|
||
expect(screen.getByTestId('design-file-row-month-boundary.html')).toBeTruthy();
|
||
});
|
||
|
||
it('groups files older than thirty calendar days into older', () => {
|
||
const now = new Date(2026, 4, 9, 12).getTime();
|
||
vi.useFakeTimers();
|
||
vi.setSystemTime(now);
|
||
|
||
renderPanel([file({ name: 'archive.html', mtime: new Date(2026, 3, 8, 12).getTime() })]);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: 'Modified' }));
|
||
|
||
expect(screen.getByText('Older')).toBeTruthy();
|
||
expect(screen.queryByText('Previous 30 days')).toBeNull();
|
||
expect(screen.getByTestId('design-file-row-archive.html')).toBeTruthy();
|
||
});
|
||
|
||
it('groups only the current page so large file lists stay paginated', () => {
|
||
const now = new Date(2026, 4, 9, 12).getTime();
|
||
vi.useFakeTimers();
|
||
vi.setSystemTime(now);
|
||
|
||
renderPanel(
|
||
Array.from({ length: 31 }, (_, i) =>
|
||
file({ name: `today-${String(i + 1).padStart(2, '0')}.html`, mtime: now - i }),
|
||
),
|
||
);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: 'Modified' }));
|
||
|
||
expect(screen.getByTestId('design-file-row-today-01.html')).toBeTruthy();
|
||
expect(screen.queryByTestId('design-file-row-today-31.html')).toBeNull();
|
||
expect(getPageInfo(document.body)).toContain('1–30 of 31');
|
||
});
|
||
|
||
it('updates modified date groups when the local day changes', () => {
|
||
vi.useFakeTimers();
|
||
vi.setSystemTime(new Date(2026, 4, 9, 23, 59, 50));
|
||
|
||
renderPanel([file({ name: 'late-edit.html', mtime: new Date(2026, 4, 9, 23, 59).getTime() })]);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: 'Modified' }));
|
||
|
||
expect(screen.getByText('Today')).toBeTruthy();
|
||
expect(screen.queryByText('Yesterday')).toBeNull();
|
||
|
||
act(() => {
|
||
vi.advanceTimersByTime(10_001);
|
||
});
|
||
|
||
expect(screen.getByText('Yesterday')).toBeTruthy();
|
||
expect(screen.queryByText('Today')).toBeNull();
|
||
expect(screen.getByTestId('design-file-row-late-edit.html')).toBeTruthy();
|
||
});
|
||
});
|
||
|
||
describe('DesignFilesPanel large-list regression', () => {
|
||
beforeEach(() => {
|
||
lsStore.clear();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup();
|
||
});
|
||
|
||
it('renders only the default page size (30) rows with 500 files', () => {
|
||
const files = generateFiles(500);
|
||
const { container } = renderPanel(files);
|
||
expect(container.querySelectorAll('.df-file-row').length).toBe(30);
|
||
});
|
||
|
||
it('shows all 500 rows when page size is set to All', () => {
|
||
const files = generateFiles(500);
|
||
const { container } = renderPanel(files);
|
||
|
||
const selects = getSelects(container);
|
||
fireEvent.change(selects[0]!, { target: { value: 'all' } });
|
||
|
||
expect(container.querySelectorAll('.df-file-row').length).toBe(500);
|
||
});
|
||
|
||
it('shows 60 rows when page size is changed to 60', () => {
|
||
const files = generateFiles(500);
|
||
const { container } = renderPanel(files);
|
||
|
||
const selects = getSelects(container);
|
||
fireEvent.change(selects[0]!, { target: { value: '60' } });
|
||
|
||
expect(container.querySelectorAll('.df-file-row').length).toBe(60);
|
||
});
|
||
|
||
it('navigates pages with Next button and updates row content', () => {
|
||
const files = generateFiles(500);
|
||
const { container } = renderPanel(files);
|
||
|
||
expect(container.querySelectorAll('.df-file-row').length).toBe(30);
|
||
expect(container.querySelector('.df-file-row')!.textContent).toContain('file-1');
|
||
|
||
const btns = getPageBtns(container);
|
||
fireEvent.click(btns[1]!);
|
||
|
||
expect(container.querySelectorAll('.df-file-row').length).toBe(30);
|
||
expect(container.querySelector('.df-file-row')!.textContent).toContain('file-31');
|
||
});
|
||
|
||
it('shows disabled Previous on first page and Next on last page', () => {
|
||
const files = generateFiles(45);
|
||
const { container } = renderPanel(files);
|
||
|
||
const btns = getPageBtns(container);
|
||
expect(btns[0]!.disabled).toBe(true);
|
||
expect(btns[1]!.disabled).toBe(false);
|
||
|
||
fireEvent.click(btns[1]!);
|
||
const btns2 = getPageBtns(container);
|
||
expect(btns2[0]!.disabled).toBe(false);
|
||
|
||
fireEvent.click(getPageBtns(container)[1]!);
|
||
fireEvent.click(getPageBtns(container)[1]!);
|
||
expect(getPageBtns(container)[1]!.disabled).toBe(true);
|
||
});
|
||
|
||
it('jumps to a specific page via page dropdown at bottom', () => {
|
||
const files = generateFiles(200);
|
||
const { container } = renderPanel(files);
|
||
|
||
const selects = getSelects(container);
|
||
fireEvent.change(selects[1]!, { target: { value: '3' } });
|
||
|
||
expect(container.querySelector('.df-file-row')!.textContent).toContain('file-91');
|
||
});
|
||
|
||
it('updates page info text when navigating', () => {
|
||
const files = generateFiles(500);
|
||
const { container } = renderPanel(files);
|
||
|
||
expect(getPageInfo(container)).toContain('1–30 of 500');
|
||
|
||
const btns = getPageBtns(container);
|
||
fireEvent.click(btns[1]!);
|
||
|
||
expect(getPageInfo(container)).toContain('31–60 of 500');
|
||
});
|
||
|
||
it('keeps the bulk toolbar focused on the all-files action instead of duplicating page select', () => {
|
||
const { container } = renderPanel(generateFiles(20));
|
||
|
||
const toolbar = container.querySelector('.df-select-bar');
|
||
expect(toolbar?.textContent).toContain('Select everything');
|
||
expect(toolbar?.textContent).not.toContain('Select all on page');
|
||
});
|
||
|
||
it('hides redundant pagination controls for a single small page', () => {
|
||
const { container } = renderPanel(generateFiles(3));
|
||
|
||
expect(container.querySelector('.df-pagination')).toBeNull();
|
||
expect(container.querySelector('.df-page-btn')).toBeNull();
|
||
expect(container.querySelector('.df-select-bar')).toBeNull();
|
||
});
|
||
|
||
it('uses non-control table cells as file row click targets', () => {
|
||
const files = generateFiles(1);
|
||
const { container, onOpenFile } = renderPanel(files);
|
||
const row = container.querySelector('.df-file-row')!;
|
||
|
||
fireEvent.click(row.querySelector('.df-cell-icon')!);
|
||
expect(container.querySelector('[data-testid="design-file-preview"]')?.textContent).toContain(
|
||
'file-1.html',
|
||
);
|
||
|
||
fireEvent.click(row.querySelector('.df-cell-kind')!);
|
||
expect(container.querySelector('[data-testid="design-file-preview"]')?.textContent).toContain(
|
||
'file-1.html',
|
||
);
|
||
|
||
fireEvent.click(row.querySelector('.df-cell-name')!);
|
||
expect(container.querySelector('[data-testid="design-file-preview"]')?.textContent).toContain(
|
||
'file-1.html',
|
||
);
|
||
|
||
fireEvent.doubleClick(row.querySelector('.df-cell-name')!);
|
||
expect(onOpenFile).toHaveBeenCalledWith('file-1.html');
|
||
onOpenFile.mockClear();
|
||
|
||
fireEvent.doubleClick(row.querySelector('.df-cell-time')!);
|
||
expect(onOpenFile).toHaveBeenCalledWith('file-1.html');
|
||
});
|
||
|
||
it('does not preview or open files from row controls', () => {
|
||
const files = generateFiles(1);
|
||
const { container, onOpenFile } = renderPanel(files);
|
||
const row = container.querySelector('.df-file-row')!;
|
||
|
||
fireEvent.click(row.querySelector('.df-row-check')!);
|
||
expect(container.querySelector('[data-testid="design-file-preview"]')).toBeNull();
|
||
expect(onOpenFile).not.toHaveBeenCalled();
|
||
|
||
fireEvent.click(row.querySelector('.df-row-menu')!);
|
||
expect(container.querySelector('[data-testid="design-file-preview"]')).toBeNull();
|
||
expect(onOpenFile).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('renders sketch files with the static sketch preview instead of a broken image', async () => {
|
||
const fetchMock = vi.fn(async () => new Response(JSON.stringify({
|
||
version: 1,
|
||
items: [
|
||
{
|
||
kind: 'rect',
|
||
x: 20,
|
||
y: 16,
|
||
w: 120,
|
||
h: 72,
|
||
color: '#1c1b1a',
|
||
size: 2,
|
||
},
|
||
],
|
||
}), {
|
||
status: 200,
|
||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||
}));
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
const sketchFile = file({
|
||
name: 'board.sketch.json',
|
||
path: 'board.sketch.json',
|
||
kind: 'sketch',
|
||
mime: 'application/json; charset=utf-8',
|
||
});
|
||
const { container } = renderPanel([sketchFile]);
|
||
|
||
fireEvent.click(container.querySelector('.df-file-row .df-row-name-btn')!);
|
||
|
||
await waitFor(() => {
|
||
expect(container.querySelector('[data-testid="sketch-preview-svg"]')).toBeTruthy();
|
||
});
|
||
expect(container.querySelector('.df-preview-thumb img')).toBeNull();
|
||
expect(fetchMock).toHaveBeenCalledWith('/api/projects/test-project/raw/board.sketch.json', { cache: 'no-store' });
|
||
});
|
||
|
||
it('passes every selected file to batch delete', () => {
|
||
const files = generateFiles(3);
|
||
const { container, onDeleteFiles } = renderPanel(files);
|
||
const rows = Array.from(container.querySelectorAll('.df-file-row'));
|
||
|
||
const firstName = rows[0]!.getAttribute('data-testid')!.replace(/^design-file-row-/, '');
|
||
const secondName = rows[1]!.getAttribute('data-testid')!.replace(/^design-file-row-/, '');
|
||
fireEvent.click(rows[0]!.querySelector('.df-row-check')!);
|
||
fireEvent.click(rows[1]!.querySelector('.df-row-check')!);
|
||
fireEvent.click(container.querySelector('[data-testid="design-files-batch-delete"]')!);
|
||
|
||
expect(onDeleteFiles).toHaveBeenCalledTimes(1);
|
||
expect(onDeleteFiles).toHaveBeenCalledWith([firstName, secondName]);
|
||
});
|
||
|
||
it('renders 500 files within a reasonable time', () => {
|
||
const files = generateFiles(500);
|
||
const start = performance.now();
|
||
renderPanel(files);
|
||
const elapsed = performance.now() - start;
|
||
expect(elapsed).toBeLessThan(2000);
|
||
});
|
||
});
|
||
|
||
describe('DesignFilesPanel directory navigation', () => {
|
||
afterEach(() => {
|
||
cleanup();
|
||
});
|
||
|
||
it('collapses nested files into a single folder row at root with correct descendant count', () => {
|
||
renderPanel([
|
||
file({ name: 'assets/logo.png', kind: 'image' }),
|
||
file({ name: 'assets/icons/star.svg', kind: 'image' }),
|
||
]);
|
||
|
||
const dirRows = document.querySelectorAll('.df-dir-row');
|
||
expect(dirRows.length).toBe(1);
|
||
expect(dirRows[0]!.textContent).toContain('assets');
|
||
expect(dirRows[0]!.textContent).toContain('2');
|
||
});
|
||
|
||
it('clicking a folder row navigates into it and shows only basenames and nested dirs', () => {
|
||
renderPanel([
|
||
file({ name: 'assets/logo.png', kind: 'image' }),
|
||
file({ name: 'assets/icons/star.svg', kind: 'image' }),
|
||
]);
|
||
|
||
fireEvent.click(document.querySelector('.df-dir-row .df-row-name-btn')!);
|
||
|
||
expect(document.querySelector('.df-breadcrumbs')).toBeTruthy();
|
||
expect(document.querySelector('.df-breadcrumb-current')?.textContent).toBe('assets');
|
||
|
||
const fileRow = screen.getByTestId('design-file-row-assets/logo.png');
|
||
expect(fileRow.querySelector('.df-row-name')?.textContent).toBe('logo.png');
|
||
expect(fileRow.querySelector('.df-row-name')?.textContent).not.toContain('assets/');
|
||
|
||
const dirRows = document.querySelectorAll('.df-dir-row');
|
||
expect(dirRows.length).toBe(1);
|
||
expect(dirRows[0]!.textContent).toContain('icons');
|
||
});
|
||
|
||
it('clicking the root breadcrumb navigates back to root', () => {
|
||
renderPanel([
|
||
file({ name: 'assets/logo.png', kind: 'image' }),
|
||
file({ name: 'top.html', kind: 'html' }),
|
||
]);
|
||
|
||
fireEvent.click(document.querySelector('.df-dir-row .df-row-name-btn')!);
|
||
expect(document.querySelector('.df-breadcrumbs')).toBeTruthy();
|
||
|
||
fireEvent.click(document.querySelector('.df-breadcrumb-btn')!);
|
||
|
||
expect(document.querySelector('.df-breadcrumbs')).toBeNull();
|
||
expect(screen.getByTestId('design-file-row-top.html')).toBeTruthy();
|
||
expect(document.querySelectorAll('.df-dir-row').length).toBe(1);
|
||
});
|
||
|
||
it('clears selection and resets page when navigating into or out of a directory', () => {
|
||
renderPanel([
|
||
file({ name: 'assets/logo.png', kind: 'image' }),
|
||
file({ name: 'top.html', kind: 'html' }),
|
||
]);
|
||
|
||
const topRow = screen.getByTestId('design-file-row-top.html');
|
||
fireEvent.click(topRow.querySelector('.df-row-check')!);
|
||
expect(topRow.classList.contains('selected')).toBe(true);
|
||
|
||
fireEvent.click(document.querySelector('.df-dir-row .df-row-name-btn')!);
|
||
expect(document.querySelectorAll('.df-file-row.selected').length).toBe(0);
|
||
|
||
fireEvent.click(document.querySelector('.df-breadcrumb-btn')!);
|
||
expect(document.querySelectorAll('.df-file-row.selected').length).toBe(0);
|
||
});
|
||
|
||
it('resets currentDir automatically when all files in the current subdirectory are removed', () => {
|
||
function makePanel(files: ProjectFile[]) {
|
||
return (
|
||
<DesignFilesPanel
|
||
projectId="test-project"
|
||
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()}
|
||
/>
|
||
);
|
||
}
|
||
|
||
const { rerender } = render(
|
||
makePanel([
|
||
file({ name: 'assets/logo.png', kind: 'image' }),
|
||
file({ name: 'top.html', kind: 'html' }),
|
||
]),
|
||
);
|
||
|
||
fireEvent.click(document.querySelector('.df-dir-row .df-row-name-btn')!);
|
||
expect(document.querySelector('.df-breadcrumb-current')?.textContent).toBe('assets');
|
||
|
||
rerender(makePanel([file({ name: 'top.html', kind: 'html' })]));
|
||
|
||
expect(document.querySelector('.df-breadcrumbs')).toBeNull();
|
||
expect(screen.getByTestId('design-file-row-top.html')).toBeTruthy();
|
||
});
|
||
|
||
it('does not show the select-all header as checked when the page contains only directory rows', () => {
|
||
renderPanel([
|
||
file({ name: 'assets/logo.png', kind: 'image' }),
|
||
]);
|
||
|
||
const headerCheck = document.querySelector('.df-th-check .df-row-check');
|
||
expect(headerCheck?.textContent).toBe('☐');
|
||
});
|
||
});
|