Site builder: security & data-loss hardening + unified asset picker + audit backlog #3
@@ -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('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,337 @@
|
||||
import React, { useState, useCallback, useRef, useEffect, CSSProperties } from 'react';
|
||||
import { uploadAsset, listAssets, Asset } from '../utils/assets';
|
||||
|
||||
export interface AssetPickerProps {
|
||||
value: string;
|
||||
onChange: (url: string) => void;
|
||||
/** Drives the file `<input accept>` and the `listAssets` grid filter. Default 'image'. */
|
||||
mediaType?: 'image' | 'video' | 'any';
|
||||
/** 'full' mirrors ImageStylePanel's source UI; 'compact' is a single tight row for array-editor cards. */
|
||||
variant?: 'full' | 'compact';
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
/* ---------- Pure helpers (exported for unit testing) ---------- */
|
||||
|
||||
/** File `<input accept>` value for a given mediaType; `undefined` (any file) for 'any'. */
|
||||
export function acceptFor(mediaType: 'image' | 'video' | 'any'): string | undefined {
|
||||
if (mediaType === 'image') return 'image/*';
|
||||
if (mediaType === 'video') return 'video/*';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Whether a dropped/selected file's MIME type is acceptable for the given mediaType. */
|
||||
export function matchesMediaType(fileType: string, mediaType: 'image' | 'video' | 'any'): boolean {
|
||||
if (mediaType === 'any') return true;
|
||||
return fileType.startsWith(mediaType);
|
||||
}
|
||||
|
||||
/** True when running outside WHP (no `window.WHP_CONFIG`) -- Browse should be hidden. */
|
||||
export function isStandalone(): boolean {
|
||||
return typeof window === 'undefined' || !(window as any).WHP_CONFIG;
|
||||
}
|
||||
|
||||
const previewBoxStyle: CSSProperties = {
|
||||
marginBottom: 8,
|
||||
borderRadius: 6,
|
||||
overflow: 'hidden',
|
||||
border: '1px solid #3f3f46',
|
||||
position: 'relative',
|
||||
};
|
||||
|
||||
const removeBtnStyle: CSSProperties = {
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
right: 4,
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(0,0,0,0.7)',
|
||||
border: 'none',
|
||||
color: '#fff',
|
||||
cursor: 'pointer',
|
||||
fontSize: 12,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
};
|
||||
|
||||
const dropZoneStyle = (active: boolean): CSSProperties => ({
|
||||
padding: '20px 12px',
|
||||
border: `2px dashed ${active ? '#3b82f6' : '#3f3f46'}`,
|
||||
borderRadius: 6,
|
||||
textAlign: 'center',
|
||||
color: '#71717a',
|
||||
fontSize: 12,
|
||||
cursor: 'pointer',
|
||||
marginBottom: 8,
|
||||
transition: 'border-color 0.15s',
|
||||
});
|
||||
|
||||
const uploadBtnStyle: CSSProperties = {
|
||||
flex: 1,
|
||||
padding: '8px 10px',
|
||||
fontSize: 12,
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: '#3b82f6',
|
||||
color: '#fff',
|
||||
fontWeight: 500,
|
||||
};
|
||||
|
||||
const browseBtnStyle = (active: boolean): CSSProperties => ({
|
||||
flex: 1,
|
||||
padding: '8px 10px',
|
||||
fontSize: 12,
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: active ? '#3b82f6' : '#27272a',
|
||||
color: active ? '#fff' : '#e4e4e7',
|
||||
});
|
||||
|
||||
const gridStyle: CSSProperties = {
|
||||
maxHeight: 200,
|
||||
overflowY: 'auto',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||
gap: 4,
|
||||
marginTop: 8,
|
||||
background: '#18181b',
|
||||
borderRadius: 6,
|
||||
padding: 4,
|
||||
};
|
||||
|
||||
export const AssetPicker: React.FC<AssetPickerProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
mediaType = 'image',
|
||||
variant = 'full',
|
||||
placeholder = 'Or paste URL...',
|
||||
}) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [showBrowser, setShowBrowser] = useState(false);
|
||||
const [browserAssets, setBrowserAssets] = useState<Asset[]>([]);
|
||||
const [browserLoading, setBrowserLoading] = useState(false);
|
||||
const [urlValue, setUrlValue] = useState(value || '');
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const standalone = isStandalone();
|
||||
|
||||
useEffect(() => {
|
||||
setUrlValue(value || '');
|
||||
}, [value]);
|
||||
|
||||
const handleUpload = useCallback(async (file: File) => {
|
||||
const url = await uploadAsset(file);
|
||||
if (url) {
|
||||
onChange(url);
|
||||
setUrlValue(url);
|
||||
}
|
||||
}, [onChange]);
|
||||
|
||||
const handleBrowseToggle = useCallback(async () => {
|
||||
if (showBrowser) {
|
||||
setShowBrowser(false);
|
||||
return;
|
||||
}
|
||||
setBrowserLoading(true);
|
||||
try {
|
||||
const assets = await listAssets(mediaType);
|
||||
setBrowserAssets(assets);
|
||||
setShowBrowser(true);
|
||||
} finally {
|
||||
setBrowserLoading(false);
|
||||
}
|
||||
}, [showBrowser, mediaType]);
|
||||
|
||||
const handleSelectAsset = useCallback((asset: Asset) => {
|
||||
onChange(asset.url);
|
||||
setUrlValue(asset.url);
|
||||
setShowBrowser(false);
|
||||
}, [onChange]);
|
||||
|
||||
const handleApplyUrl = useCallback(() => {
|
||||
onChange(urlValue);
|
||||
}, [urlValue, onChange]);
|
||||
|
||||
const handleRemove = useCallback(() => {
|
||||
onChange('');
|
||||
setUrlValue('');
|
||||
}, [onChange]);
|
||||
|
||||
const handleDrop = useCallback(async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file && matchesMediaType(file.type, mediaType)) {
|
||||
await handleUpload(file);
|
||||
}
|
||||
}, [handleUpload, mediaType]);
|
||||
|
||||
const accept = acceptFor(mediaType);
|
||||
const hasValue = !!value;
|
||||
|
||||
const fileInput = (
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
style={{ display: 'none' }}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleUpload(file);
|
||||
e.target.value = '';
|
||||
}}
|
||||
data-testid="asset-picker-file-input"
|
||||
/>
|
||||
);
|
||||
|
||||
const grid = showBrowser && (
|
||||
<div style={gridStyle} data-testid="asset-picker-grid">
|
||||
{browserAssets.map((asset) => (
|
||||
<div
|
||||
key={asset.name}
|
||||
onClick={() => handleSelectAsset(asset)}
|
||||
data-testid={`asset-picker-thumb-${asset.name}`}
|
||||
style={{ cursor: 'pointer', borderRadius: 4, overflow: 'hidden', border: '2px solid transparent', aspectRatio: '1' }}
|
||||
>
|
||||
<img src={asset.url} alt={asset.name} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
|
||||
</div>
|
||||
))}
|
||||
{browserAssets.length === 0 && (
|
||||
<p style={{ gridColumn: '1 / -1', textAlign: 'center', color: '#71717a', fontSize: 11, padding: '12px 0', margin: 0 }}>
|
||||
No assets uploaded yet. Use Upload above.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const browseButton = !standalone && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBrowseToggle}
|
||||
data-testid="asset-picker-browse"
|
||||
style={variant === 'full' ? browseBtnStyle(showBrowser) : { ...browseBtnStyle(showBrowser), flex: 'none', padding: '6px 8px' }}
|
||||
>
|
||||
<i className={`fa ${browserLoading ? 'fa-spinner fa-spin' : 'fa-folder-open'}`} style={{ marginRight: variant === 'full' ? 4 : 0 }} />
|
||||
{variant === 'full' && 'Browse'}
|
||||
</button>
|
||||
);
|
||||
|
||||
if (variant === 'compact') {
|
||||
return (
|
||||
<div data-testid="asset-picker-compact">
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
{hasValue && (
|
||||
<div style={{ width: 28, height: 28, borderRadius: 4, overflow: 'hidden', border: '1px solid #3f3f46', flex: 'none', position: 'relative' }}>
|
||||
<img src={value} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRemove}
|
||||
title="Remove"
|
||||
data-testid="asset-picker-remove"
|
||||
style={{ position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.5)', border: 'none', color: '#fff', fontSize: 9, cursor: 'pointer', opacity: 0 }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.opacity = '1'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.opacity = '0'; }}
|
||||
>
|
||||
<i className="fa fa-times" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
data-testid="asset-picker-upload"
|
||||
style={{ flex: 'none', padding: '6px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer', border: '1px solid #3f3f46', background: '#3b82f6', color: '#fff' }}
|
||||
title="Upload"
|
||||
>
|
||||
<i className="fa fa-upload" />
|
||||
</button>
|
||||
{browseButton}
|
||||
<input
|
||||
type="text"
|
||||
className="guided-input"
|
||||
value={urlValue}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => { setUrlValue(e.target.value); }}
|
||||
onBlur={handleApplyUrl}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleApplyUrl(); }}
|
||||
data-testid="asset-picker-url-input"
|
||||
style={{ flex: 1, fontSize: 11, minWidth: 0 }}
|
||||
/>
|
||||
</div>
|
||||
{grid}
|
||||
{fileInput}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="asset-picker-full">
|
||||
{hasValue ? (
|
||||
<div style={previewBoxStyle}>
|
||||
<img src={value} alt="" style={{ width: '100%', height: 'auto', display: 'block', maxHeight: 150, objectFit: 'cover' }} data-testid="asset-picker-preview" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRemove}
|
||||
title="Remove"
|
||||
data-testid="asset-picker-remove"
|
||||
style={removeBtnStyle}
|
||||
>
|
||||
<i className="fa fa-times" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={dropZoneStyle(dragOver)}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
data-testid="asset-picker-dropzone"
|
||||
>
|
||||
<i className="fa fa-cloud-upload" style={{ fontSize: 24, display: 'block', marginBottom: 6, color: '#3b82f6' }} />
|
||||
Drop {mediaType === 'video' ? 'video' : mediaType === 'any' ? 'file' : 'image'} here or click to upload
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
data-testid="asset-picker-upload"
|
||||
style={uploadBtnStyle}
|
||||
>
|
||||
<i className="fa fa-upload" style={{ marginRight: 4 }} /> Upload
|
||||
</button>
|
||||
{browseButton}
|
||||
</div>
|
||||
|
||||
{grid}
|
||||
{fileInput}
|
||||
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<div className="guided-input-row">
|
||||
<input
|
||||
type="text"
|
||||
className="guided-input"
|
||||
value={urlValue}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => setUrlValue(e.target.value)}
|
||||
data-testid="asset-picker-url-input"
|
||||
style={{ fontSize: 11 }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="preset-btn apply-btn"
|
||||
onClick={handleApplyUrl}
|
||||
data-testid="asset-picker-apply"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user