feat(builder): reusable AssetPicker (full/compact, media-typed)

Introduces src/ui/AssetPicker.tsx, a single component that replaces the
~6 copy-pasted image-source UIs (upload/browse/URL) with one that also
supports video via `mediaType`. Full variant mirrors ImageStylePanel's
existing look; compact variant is a tight single row for array-editor
item cards. Both share all state/handler logic, built on the C1
uploadAsset/listAssets util.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 12:49:34 -07:00
co-authored by Claude Opus 4.8
parent 296c10d019
commit c50b385661
2 changed files with 541 additions and 0 deletions
+204
View File
@@ -0,0 +1,204 @@
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
import { AssetPicker, acceptFor, matchesMediaType, isStandalone } from './AssetPicker';
import type { Asset } from '../utils/assets';
vi.mock('../utils/assets', () => ({
uploadAsset: vi.fn(),
listAssets: vi.fn(),
}));
import { uploadAsset, listAssets } from '../utils/assets';
const uploadAssetMock = uploadAsset as unknown as ReturnType<typeof vi.fn>;
const listAssetsMock = listAssets as unknown as ReturnType<typeof vi.fn>;
const GRID_ASSETS: Asset[] = [
{ name: 'a.png', url: '/storage/assets/a.png', type: 'image/png' },
{ name: 'b.mp4', url: '/storage/assets/b.mp4', type: 'video/mp4' },
];
/* ---------- DOM test harness (no @testing-library/react in this repo, see
src/utils/assets.test.ts / navColorFields.test.ts for the plain-logic
pattern used elsewhere; component-level interaction here uses
react-dom/client + react-dom/test-utils `act`, both already transitive
deps of `react-dom`, so no new devDependency is introduced). ---------- */
let container: HTMLDivElement;
let root: Root;
function render(ui: React.ReactElement) {
container = document.createElement('div');
document.body.appendChild(container);
act(() => {
root = createRoot(container);
root.render(ui);
});
}
function unmount() {
act(() => { root.unmount(); });
container.remove();
}
function click(el: Element | null) {
if (!el) throw new Error('element not found');
act(() => { (el as HTMLElement).dispatchEvent(new MouseEvent('click', { bubbles: true })); });
}
function setValue(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!;
setter.call(input, value);
input.dispatchEvent(new Event('input', { bubbles: true }));
}
function q<T extends Element = Element>(testId: string): T | null {
return container.querySelector(`[data-testid="${testId}"]`);
}
describe('AssetPicker pure helpers', () => {
test('acceptFor maps mediaType to the file input accept attribute', () => {
expect(acceptFor('image')).toBe('image/*');
expect(acceptFor('video')).toBe('video/*');
expect(acceptFor('any')).toBeUndefined();
});
test('matchesMediaType guards drag-drop uploads by mediaType', () => {
expect(matchesMediaType('image/png', 'image')).toBe(true);
expect(matchesMediaType('video/mp4', 'image')).toBe(false);
expect(matchesMediaType('video/mp4', 'video')).toBe(true);
expect(matchesMediaType('application/pdf', 'any')).toBe(true);
});
test('isStandalone reflects presence of window.WHP_CONFIG', () => {
delete (window as any).WHP_CONFIG;
expect(isStandalone()).toBe(true);
(window as any).WHP_CONFIG = { apiUrl: 'x', siteId: 1, csrfToken: 't' };
expect(isStandalone()).toBe(false);
delete (window as any).WHP_CONFIG;
});
});
describe('AssetPicker component', () => {
beforeEach(() => {
uploadAssetMock.mockReset();
listAssetsMock.mockReset();
listAssetsMock.mockResolvedValue(GRID_ASSETS);
delete (window as any).WHP_CONFIG;
});
afterEach(() => {
if (container) unmount();
delete (window as any).WHP_CONFIG;
});
test('renders the current value as a preview thumbnail (full variant)', () => {
render(<AssetPicker value="/storage/assets/existing.png" onChange={vi.fn()} />);
const preview = q<HTMLImageElement>('asset-picker-preview');
expect(preview).not.toBeNull();
expect(preview!.src).toContain('/storage/assets/existing.png');
expect(q('asset-picker-dropzone')).toBeNull();
});
test('renders a dropzone (no preview) when value is empty', () => {
render(<AssetPicker value="" onChange={vi.fn()} />);
expect(q('asset-picker-dropzone')).not.toBeNull();
expect(q('asset-picker-preview')).toBeNull();
});
test('remove button clears the value via onChange("")', () => {
const onChange = vi.fn();
render(<AssetPicker value="/storage/assets/existing.png" onChange={onChange} />);
click(q('asset-picker-remove'));
expect(onChange).toHaveBeenCalledWith('');
});
test('Browse control is absent when window.WHP_CONFIG is undefined (standalone)', () => {
render(<AssetPicker value="" onChange={vi.fn()} />);
expect(q('asset-picker-browse')).toBeNull();
});
test('Browse control is present and lists assets filtered by mediaType when WHP_CONFIG is set', async () => {
(window as any).WHP_CONFIG = { apiUrl: 'https://x', siteId: 1, csrfToken: 't' };
render(<AssetPicker value="" onChange={vi.fn()} mediaType="video" />);
const browseBtn = q('asset-picker-browse');
expect(browseBtn).not.toBeNull();
await act(async () => { click(browseBtn); await Promise.resolve(); });
expect(listAssetsMock).toHaveBeenCalledWith('video');
expect(q('asset-picker-grid')).not.toBeNull();
});
test('selecting a grid asset invokes onChange with that asset url and closes the grid', async () => {
(window as any).WHP_CONFIG = { apiUrl: 'https://x', siteId: 1, csrfToken: 't' };
const onChange = vi.fn();
render(<AssetPicker value="" onChange={onChange} mediaType="any" />);
await act(async () => { click(q('asset-picker-browse')); await Promise.resolve(); });
expect(q('asset-picker-grid')).not.toBeNull();
click(q(`asset-picker-thumb-${GRID_ASSETS[0].name}`));
expect(onChange).toHaveBeenCalledWith(GRID_ASSETS[0].url);
expect(q('asset-picker-grid')).toBeNull();
});
test('URL apply invokes onChange with the typed url (full variant Apply button)', () => {
const onChange = vi.fn();
render(<AssetPicker value="" onChange={onChange} />);
const input = q<HTMLInputElement>('asset-picker-url-input')!;
setValue(input, 'https://example.com/pic.jpg');
click(q('asset-picker-apply'));
expect(onChange).toHaveBeenCalledWith('https://example.com/pic.jpg');
});
test('uploading a file calls uploadAsset and applies the resolved url via onChange', async () => {
uploadAssetMock.mockResolvedValue('/storage/assets/uploaded.png');
const onChange = vi.fn();
render(<AssetPicker value="" onChange={onChange} />);
const fileInput = q<HTMLInputElement>('asset-picker-file-input')!;
const file = new File(['x'], 'photo.png', { type: 'image/png' });
const dt = { files: [file] };
Object.defineProperty(fileInput, 'files', { value: dt.files });
await act(async () => {
fileInput.dispatchEvent(new Event('change', { bubbles: true }));
await Promise.resolve();
await Promise.resolve();
});
expect(uploadAssetMock).toHaveBeenCalledWith(file);
expect(onChange).toHaveBeenCalledWith('/storage/assets/uploaded.png');
});
test('variant="compact" renders the compact single-row structure instead of the full structure', () => {
render(<AssetPicker value="" onChange={vi.fn()} variant="compact" />);
expect(q('asset-picker-compact')).not.toBeNull();
expect(q('asset-picker-full')).toBeNull();
expect(q('asset-picker-dropzone')).toBeNull();
expect(q('asset-picker-url-input')).not.toBeNull();
expect(q('asset-picker-upload')).not.toBeNull();
});
test('compact variant: URL input applies onChange on blur', () => {
const onChange = vi.fn();
render(<AssetPicker value="" onChange={onChange} variant="compact" />);
const input = q<HTMLInputElement>('asset-picker-url-input')!;
setValue(input, 'https://example.com/vid.mp4');
// React delegates onBlur via the bubbling 'focusout' event (blur itself doesn't bubble).
act(() => { input.dispatchEvent(new FocusEvent('focusout', { bubbles: true })); });
expect(onChange).toHaveBeenCalledWith('https://example.com/vid.mp4');
});
test('mediaType="video" sets file input accept to video/* and mediaType="any" omits accept', () => {
render(<AssetPicker value="" onChange={vi.fn()} mediaType="video" />);
expect(q<HTMLInputElement>('asset-picker-file-input')!.accept).toBe('video/*');
unmount();
render(<AssetPicker value="" onChange={vi.fn()} mediaType="any" />);
expect(q<HTMLInputElement>('asset-picker-file-input')!.accept).toBe('');
});
});