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:
@@ -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('');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user