mirror of
https://github.com/strapi/strapi.git
synced 2026-05-03 16:22:30 +00:00
enhancement(content-manager): window.strapiPreview API + scoped refresh for media (phase 2)
Completes the media story introduced in phase 1. Editors can now swap between media kinds (image↔video) and clear media fields and see the preview update without a full page reload. The change also introduces the public window.strapiPreview surface so integrators can customize how field changes are reflected in the preview. Public API: - window.strapiPreview.version (numeric, starts at 1, under semver) - onType(type, handler) for all fields of a given type - onField(path, handler) for one specific path (takes precedence over onType) - off(key) to deregister by path or type Handler resolution order: onField → onType → built-in default → unhandled signal to the admin → existing full-page refresh. Handlers return false to pass through to the next handler in the chain. Built-in media default: - Same-kind: delegates to the phase-1 in-place attribute patch - Cross-kind: replaces the DOM element with a fresh <img>/<video>, preserving the source-map marker, class, and width/height so the wrapper stays locatable and layout is stable - Populated → empty: clears src/srcset/alt/poster - Empty → populated: no marker exists, falls through to full-page refresh (documented limitation; user stories 4/5 partially honored here) Handler registries live on window so user-registered handlers survive preview-script re-injection across admin navigation. Adds a new internal event STRAPI_FIELD_REPLACE_UNHANDLED (iframe → admin). The admin listens for it and posts strapiUpdate back to the iframe, reusing the existing full-page-refresh path. Tests added: 14 new cases covering the handler chain resolver/runner and the built-in media default (cross-kind, empty transitions, fall-through). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -36,7 +36,7 @@ import { createYupSchema } from '../../utils/validation';
|
||||
import { InputPopover } from '../components/InputPopover';
|
||||
import { PreviewHeader } from '../components/PreviewHeader';
|
||||
import { useGetPreviewUrlQuery } from '../services/preview';
|
||||
import { PUBLIC_EVENTS } from '../utils/constants';
|
||||
import { INTERNAL_EVENTS, PUBLIC_EVENTS } from '../utils/constants';
|
||||
import { getSendMessage } from '../utils/getSendMessage';
|
||||
import { previewScript } from '../utils/previewScript';
|
||||
|
||||
@@ -158,6 +158,17 @@ const PreviewPage = () => {
|
||||
const sendMessage = getSendMessage(iframeRef);
|
||||
sendMessage(PUBLIC_EVENTS.STRAPI_SCRIPT, { script });
|
||||
}
|
||||
|
||||
// A field change couldn't be resolved in place and no scoped-refresh handler was
|
||||
// registered. Fall back to the existing full-page refresh (strapiUpdate) so the preview
|
||||
// stays consistent with the admin form.
|
||||
if (event.data?.type === INTERNAL_EVENTS.STRAPI_FIELD_REPLACE_UNHANDLED) {
|
||||
iframeRef.current?.contentWindow?.postMessage(
|
||||
{ type: PUBLIC_EVENTS.STRAPI_UPDATE },
|
||||
// Safe to use because the iframe origin is pinned via allowedOrigins config
|
||||
new URL(iframeRef.current.src).origin
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handleMessage);
|
||||
|
||||
@@ -2,10 +2,43 @@
|
||||
declare global {
|
||||
interface Window {
|
||||
__strapi_previewCleanup?: () => void;
|
||||
/** Handler registries preserved across re-injections of the preview script. */
|
||||
__strapiPreviewRegistries?: {
|
||||
fieldHandlers: Map<string, StrapiPreviewFieldHandler>;
|
||||
typeHandlers: Map<string, StrapiPreviewFieldHandler>;
|
||||
};
|
||||
STRAPI_HIGHLIGHT_HOVER_COLOR?: string;
|
||||
STRAPI_HIGHLIGHT_ACTIVE_COLOR?: string;
|
||||
STRAPI_DISABLE_STEGA_DECODING?: boolean;
|
||||
/**
|
||||
* Public Strapi live-preview API exposed inside the preview iframe.
|
||||
*
|
||||
* Integrators register handlers to customize how field changes are reflected
|
||||
* in the preview. Resolution order for every field-change event:
|
||||
*
|
||||
* 1. onField(path) — most specific
|
||||
* 2. onType(type) — for all fields of a type
|
||||
* 3. built-in default for the type (Strapi ships one for `media`)
|
||||
* 4. full-page refresh fallback (signaled via an internal message)
|
||||
*
|
||||
* Handlers return `false` to pass through to the next handler in the chain.
|
||||
* Anything else means the change was handled.
|
||||
*/
|
||||
strapiPreview?: {
|
||||
/** Public API version. Integrators can check this to gate on capabilities. */
|
||||
version: number;
|
||||
onType(type: string, handler: StrapiPreviewFieldHandler): void;
|
||||
onField(path: string, handler: StrapiPreviewFieldHandler): void;
|
||||
/** Deregister a handler by its path (onField key) or type (onType key). */
|
||||
off(key: string): void;
|
||||
};
|
||||
}
|
||||
|
||||
type StrapiPreviewFieldHandler = (
|
||||
value: unknown,
|
||||
element: Element,
|
||||
meta: { path: string; type: string }
|
||||
) => boolean | void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -46,6 +79,12 @@ const previewScript = (config: PreviewScriptConfig) => {
|
||||
STRAPI_FIELD_CHANGE: 'strapiFieldChange',
|
||||
STRAPI_FIELD_FOCUS_INTENT: 'strapiFieldFocusIntent',
|
||||
STRAPI_FIELD_SINGLE_CLICK_HINT: 'strapiFieldSingleClickHint',
|
||||
/**
|
||||
* Iframe → admin. Signals that a field change could not be resolved in-place and no
|
||||
* scoped-refresh handler was registered. The admin responds with the existing strapiUpdate
|
||||
* full-page refresh so the preview never gets stuck in a stale state.
|
||||
*/
|
||||
STRAPI_FIELD_REPLACE_UNHANDLED: 'strapiFieldReplaceUnhandled',
|
||||
} as const;
|
||||
|
||||
/* -----------------------------------------------------------------------------------------------
|
||||
@@ -164,6 +203,143 @@ const previewScript = (config: PreviewScriptConfig) => {
|
||||
* a single source of truth for them. It's the only way to do this because this script can't
|
||||
* refer to any variables outside of its own scope, because it's stringified before it's run.
|
||||
*/
|
||||
/* -----------------------------------------------------------------------------------------------
|
||||
* Handler chain (scoped-refresh primitive)
|
||||
*
|
||||
* The dispatch chain is resolution-order independent of any registries — it takes them as an
|
||||
* argument. The runtime portion of the IIFE wires up the actual `fieldHandlers` / `typeHandlers`
|
||||
* Maps (preserved on `window` across re-injections) and the built-in type defaults.
|
||||
* ---------------------------------------------------------------------------------------------*/
|
||||
|
||||
type FieldHandler = StrapiPreviewFieldHandler;
|
||||
|
||||
/**
|
||||
* Built-in default for `media` fields. Handles:
|
||||
* - populated → populated, same-kind: delegates to patchMediaElement
|
||||
* - populated → populated, cross-kind (image↔video): replaces the DOM element, preserving
|
||||
* marker + class + width/height so the wrapper stays locatable and layout is stable
|
||||
* - populated → empty: clears src / srcset / alt / poster on the target
|
||||
*
|
||||
* Empty → populated cannot be handled here (no marker element exists in the DOM), and falls
|
||||
* through to the full-page refresh fallback.
|
||||
*/
|
||||
const BUILT_IN_MEDIA_HANDLER: FieldHandler = (value, element) => {
|
||||
if (!(element instanceof HTMLElement)) return false;
|
||||
const target = findMediaTarget(element);
|
||||
if (!target) return false;
|
||||
|
||||
const currentTag = target.tagName.toLowerCase();
|
||||
|
||||
// Populated → empty: clear attributes rather than removing the element so the marker stays
|
||||
// in the DOM and future changes can still find a target.
|
||||
if (value == null) {
|
||||
if (currentTag === 'img') {
|
||||
target.removeAttribute('src');
|
||||
target.removeAttribute('srcset');
|
||||
target.removeAttribute('alt');
|
||||
} else if (currentTag === 'video') {
|
||||
target.removeAttribute('src');
|
||||
target.removeAttribute('poster');
|
||||
} else if (currentTag === 'picture') {
|
||||
target.querySelectorAll('source').forEach((s) => s.removeAttribute('srcset'));
|
||||
const img = target.querySelector('img');
|
||||
if (img) {
|
||||
img.removeAttribute('src');
|
||||
img.removeAttribute('alt');
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof value !== 'object') return false;
|
||||
const mime = typeof (value as MediaValue).mime === 'string' ? (value as MediaValue).mime! : '';
|
||||
const mimePrefix = getMimePrefix(mime);
|
||||
|
||||
const desiredTag: 'img' | 'video' | null =
|
||||
mimePrefix === 'image' ? 'img' : mimePrefix === 'video' ? 'video' : null;
|
||||
if (!desiredTag) return false;
|
||||
|
||||
const currentKind: 'image' | 'video' | null =
|
||||
currentTag === 'img' || currentTag === 'picture'
|
||||
? 'image'
|
||||
: currentTag === 'video'
|
||||
? 'video'
|
||||
: null;
|
||||
|
||||
if (currentKind === mimePrefix) {
|
||||
// Same kind — in-place attribute patch
|
||||
return patchMediaElement(target, value as MediaValue);
|
||||
}
|
||||
|
||||
// Cross-kind: replace the element with a fresh one of the right tag.
|
||||
const newEl = document.createElement(desiredTag);
|
||||
const rawUrl = typeof (value as MediaValue).url === 'string' ? (value as MediaValue).url! : '';
|
||||
if (!rawUrl) return false;
|
||||
newEl.setAttribute('src', resolveMediaUrl(rawUrl, target.getAttribute('src')));
|
||||
|
||||
if (desiredTag === 'img') {
|
||||
const alt = (value as MediaValue).alternativeText;
|
||||
if (typeof alt === 'string') newEl.setAttribute('alt', alt);
|
||||
} else {
|
||||
(newEl as HTMLVideoElement).controls = true;
|
||||
const preview = (value as MediaValue).previewUrl;
|
||||
if (typeof preview === 'string') {
|
||||
newEl.setAttribute('poster', resolveMediaUrl(preview, target.getAttribute('poster')));
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve the marker so the new element keeps participating in live-preview.
|
||||
const marker = target.getAttribute(SOURCE_ATTRIBUTE);
|
||||
if (marker) newEl.setAttribute(SOURCE_ATTRIBUTE, marker);
|
||||
// Preserve class + layout attributes so the swap doesn't break the integrator's styling.
|
||||
const className = target.getAttribute('class');
|
||||
if (className) newEl.setAttribute('class', className);
|
||||
const width = target.getAttribute('width');
|
||||
if (width) newEl.setAttribute('width', width);
|
||||
const height = target.getAttribute('height');
|
||||
if (height) newEl.setAttribute('height', height);
|
||||
|
||||
target.replaceWith(newEl);
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the ordered list of handlers to try for a given (field, type) pair.
|
||||
* Order: onField (most specific) → onType → built-in type default.
|
||||
*/
|
||||
const resolveHandlerChain = (
|
||||
field: string,
|
||||
type: string,
|
||||
registries: {
|
||||
fieldHandlers: Map<string, FieldHandler>;
|
||||
typeHandlers: Map<string, FieldHandler>;
|
||||
builtInTypeHandlers: Map<string, FieldHandler>;
|
||||
}
|
||||
): FieldHandler[] => {
|
||||
return [
|
||||
registries.fieldHandlers.get(field),
|
||||
registries.typeHandlers.get(type),
|
||||
registries.builtInTypeHandlers.get(type),
|
||||
].filter(Boolean) as FieldHandler[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Walks the handler chain, stopping at the first one that doesn't return `false`.
|
||||
* Returns true if any handler claimed the change; false otherwise.
|
||||
*/
|
||||
const runHandlerChain = (
|
||||
handlers: FieldHandler[],
|
||||
value: unknown,
|
||||
element: Element,
|
||||
meta: { path: string; type: string }
|
||||
): boolean => {
|
||||
for (let i = 0; i < handlers.length; i += 1) {
|
||||
const result = handlers[i](value, element, meta);
|
||||
if (result !== false) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
if (!shouldRun) {
|
||||
return {
|
||||
INTERNAL_EVENTS,
|
||||
@@ -172,6 +348,9 @@ const previewScript = (config: PreviewScriptConfig) => {
|
||||
findMediaTarget,
|
||||
patchMediaElement,
|
||||
resolveMediaUrl,
|
||||
BUILT_IN_MEDIA_HANDLER,
|
||||
resolveHandlerChain,
|
||||
runHandlerChain,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -191,6 +370,66 @@ const previewScript = (config: PreviewScriptConfig) => {
|
||||
return document.querySelectorAll(`[${SOURCE_ATTRIBUTE}*="path=${path}"]`);
|
||||
};
|
||||
|
||||
/* -----------------------------------------------------------------------------------------------
|
||||
* Public API setup (window.strapiPreview)
|
||||
*
|
||||
* Registries live on `window` so user-registered handlers survive preview-script re-injection
|
||||
* (e.g. when the admin navigates between entries).
|
||||
* ---------------------------------------------------------------------------------------------*/
|
||||
|
||||
const registries = window.__strapiPreviewRegistries ?? {
|
||||
fieldHandlers: new Map<string, FieldHandler>(),
|
||||
typeHandlers: new Map<string, FieldHandler>(),
|
||||
};
|
||||
window.__strapiPreviewRegistries = registries;
|
||||
|
||||
// Built-in defaults are always re-bound from code (they come from this script's closure).
|
||||
const builtInTypeHandlers = new Map<string, FieldHandler>([['media', BUILT_IN_MEDIA_HANDLER]]);
|
||||
|
||||
window.strapiPreview = {
|
||||
version: 1,
|
||||
onType: (type, handler) => {
|
||||
registries.typeHandlers.set(type, handler);
|
||||
},
|
||||
onField: (path, handler) => {
|
||||
registries.fieldHandlers.set(path, handler);
|
||||
},
|
||||
off: (key) => {
|
||||
registries.fieldHandlers.delete(key);
|
||||
registries.typeHandlers.delete(key);
|
||||
},
|
||||
};
|
||||
|
||||
const signalUnhandled = (field: string, type: string) => {
|
||||
sendMessage(INTERNAL_EVENTS.STRAPI_FIELD_REPLACE_UNHANDLED, { field, type });
|
||||
};
|
||||
|
||||
const dispatchScopedRefresh = (
|
||||
field: string,
|
||||
value: unknown,
|
||||
type: string,
|
||||
elements: NodeListOf<Element>
|
||||
) => {
|
||||
const handlers = resolveHandlerChain(field, type, {
|
||||
...registries,
|
||||
builtInTypeHandlers,
|
||||
});
|
||||
if (handlers.length === 0 || elements.length === 0) {
|
||||
signalUnhandled(field, type);
|
||||
return;
|
||||
}
|
||||
const meta = { path: field, type };
|
||||
let anyHandled = false;
|
||||
elements.forEach((element) => {
|
||||
if (runHandlerChain(handlers, value, element, meta)) {
|
||||
anyHandled = true;
|
||||
}
|
||||
});
|
||||
if (!anyHandled) {
|
||||
signalUnhandled(field, type);
|
||||
}
|
||||
};
|
||||
|
||||
/* -----------------------------------------------------------------------------------------------
|
||||
* Functionality pieces
|
||||
* ---------------------------------------------------------------------------------------------*/
|
||||
@@ -663,16 +902,17 @@ const previewScript = (config: PreviewScriptConfig) => {
|
||||
|
||||
const elements = getElementsByPath(field);
|
||||
|
||||
// Media fields: patch attributes on the nearest media element in place.
|
||||
// Shape-changing edits (cross-kind swap, empty transitions) are handled by the full-page
|
||||
// refresh that runs on save, until the scoped-refresh primitive lands in a later phase.
|
||||
if (type === 'media') {
|
||||
if (value == null) return;
|
||||
elements.forEach((element) => {
|
||||
if (!(element instanceof HTMLElement)) return;
|
||||
const target = findMediaTarget(element);
|
||||
patchMediaElement(target, value as MediaValue);
|
||||
});
|
||||
// If any handler (user-registered or built-in) applies to this (field, type), route
|
||||
// through the scoped-refresh dispatcher. This covers media today; future phases will
|
||||
// add built-ins for blocks.
|
||||
const hasHandler =
|
||||
!!type &&
|
||||
(registries.fieldHandlers.has(field) ||
|
||||
registries.typeHandlers.has(type) ||
|
||||
builtInTypeHandlers.has(type));
|
||||
|
||||
if (hasHandler) {
|
||||
dispatchScopedRefresh(field, value, type!, elements);
|
||||
highlightManager.updateAllHighlights();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -249,4 +249,178 @@ describe('previewScript helpers', () => {
|
||||
expect(getHelpers().patchMediaElement(null, { url: 'x', mime: 'image/jpeg' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveHandlerChain', () => {
|
||||
const meta = { path: 'hero.image', type: 'media' };
|
||||
const makeRegistries = () => ({
|
||||
fieldHandlers: new Map<string, any>(),
|
||||
typeHandlers: new Map<string, any>(),
|
||||
builtInTypeHandlers: new Map<string, any>(),
|
||||
});
|
||||
|
||||
it('orders handlers: field → type → built-in', () => {
|
||||
const r = makeRegistries();
|
||||
const a = jest.fn();
|
||||
const b = jest.fn();
|
||||
const c = jest.fn();
|
||||
r.fieldHandlers.set('hero.image', a);
|
||||
r.typeHandlers.set('media', b);
|
||||
r.builtInTypeHandlers.set('media', c);
|
||||
|
||||
const chain = getHelpers().resolveHandlerChain('hero.image', 'media', r);
|
||||
expect(chain).toEqual([a, b, c]);
|
||||
});
|
||||
|
||||
it('omits handlers that are not registered', () => {
|
||||
const r = makeRegistries();
|
||||
const builtIn = jest.fn();
|
||||
r.builtInTypeHandlers.set('media', builtIn);
|
||||
|
||||
const chain = getHelpers().resolveHandlerChain('hero.image', 'media', r);
|
||||
expect(chain).toEqual([builtIn]);
|
||||
});
|
||||
|
||||
it('returns an empty chain when nothing is registered', () => {
|
||||
const chain = getHelpers().resolveHandlerChain('x', 'y', makeRegistries());
|
||||
expect(chain).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runHandlerChain', () => {
|
||||
const meta = { path: 'hero.image', type: 'media' };
|
||||
const element = document.createElement('img');
|
||||
|
||||
it('stops at the first handler that does not return false', () => {
|
||||
const first = jest.fn(() => true);
|
||||
const second = jest.fn();
|
||||
const handled = getHelpers().runHandlerChain([first, second], {}, element, meta);
|
||||
expect(handled).toBe(true);
|
||||
expect(first).toHaveBeenCalledTimes(1);
|
||||
expect(second).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats undefined return as handled (same as true)', () => {
|
||||
const h = jest.fn(() => undefined);
|
||||
const handled = getHelpers().runHandlerChain([h], {}, element, meta);
|
||||
expect(handled).toBe(true);
|
||||
});
|
||||
|
||||
it('falls through to the next handler when one returns false', () => {
|
||||
const first = jest.fn(() => false);
|
||||
const second = jest.fn(() => true);
|
||||
const handled = getHelpers().runHandlerChain([first, second], {}, element, meta);
|
||||
expect(handled).toBe(true);
|
||||
expect(first).toHaveBeenCalled();
|
||||
expect(second).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns false when every handler returns false', () => {
|
||||
const h = jest.fn(() => false);
|
||||
const handled = getHelpers().runHandlerChain([h, h, h], {}, element, meta);
|
||||
expect(handled).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for an empty chain', () => {
|
||||
const handled = getHelpers().runHandlerChain([], {}, element, meta);
|
||||
expect(handled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BUILT_IN_MEDIA_HANDLER', () => {
|
||||
const meta = { path: 'hero.image', type: 'media' };
|
||||
|
||||
it('delegates to the in-place patch when old and new are the same kind', () => {
|
||||
const img = document.createElement('img');
|
||||
img.setAttribute('src', 'https://example.com/old.jpg');
|
||||
const handled = getHelpers().BUILT_IN_MEDIA_HANDLER(
|
||||
{ url: 'https://example.com/new.jpg', mime: 'image/jpeg' },
|
||||
img,
|
||||
meta
|
||||
);
|
||||
expect(handled).toBe(true);
|
||||
expect(img).toHaveAttribute('src', 'https://example.com/new.jpg');
|
||||
});
|
||||
|
||||
it('swaps <img> to <video> on image → video cross-kind change, preserving marker', () => {
|
||||
const wrapper = document.createElement('div');
|
||||
const img = document.createElement('img');
|
||||
img.setAttribute('src', 'https://example.com/old.jpg');
|
||||
img.setAttribute('data-strapi-source', 'path=hero.image&type=media');
|
||||
img.setAttribute('class', 'hero-class');
|
||||
img.setAttribute('width', '640');
|
||||
wrapper.appendChild(img);
|
||||
|
||||
const handled = getHelpers().BUILT_IN_MEDIA_HANDLER(
|
||||
{ url: 'https://example.com/clip.mp4', mime: 'video/mp4' },
|
||||
wrapper,
|
||||
meta
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
const video = wrapper.querySelector('video');
|
||||
expect(video).not.toBeNull();
|
||||
expect(video!).toHaveAttribute('src', 'https://example.com/clip.mp4');
|
||||
expect(video!).toHaveAttribute('data-strapi-source', 'path=hero.image&type=media');
|
||||
expect(video!).toHaveAttribute('class', 'hero-class');
|
||||
expect(video!).toHaveAttribute('width', '640');
|
||||
expect(wrapper.querySelector('img')).toBeNull();
|
||||
});
|
||||
|
||||
it('swaps <video> to <img> on video → image cross-kind change', () => {
|
||||
const wrapper = document.createElement('div');
|
||||
const video = document.createElement('video');
|
||||
video.setAttribute('src', 'https://example.com/old.mp4');
|
||||
video.setAttribute('data-strapi-source', 'path=hero.media&type=media');
|
||||
wrapper.appendChild(video);
|
||||
|
||||
const handled = getHelpers().BUILT_IN_MEDIA_HANDLER(
|
||||
{ url: 'https://example.com/photo.jpg', mime: 'image/jpeg', alternativeText: 'A photo' },
|
||||
wrapper,
|
||||
meta
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
const img = wrapper.querySelector('img');
|
||||
expect(img).not.toBeNull();
|
||||
expect(img!).toHaveAttribute('src', 'https://example.com/photo.jpg');
|
||||
expect(img!).toHaveAttribute('alt', 'A photo');
|
||||
expect(wrapper.querySelector('video')).toBeNull();
|
||||
});
|
||||
|
||||
it('clears attributes on populated → empty', () => {
|
||||
const img = document.createElement('img');
|
||||
img.setAttribute('src', 'https://example.com/old.jpg');
|
||||
img.setAttribute('alt', 'old');
|
||||
img.setAttribute('srcset', 'https://example.com/old.jpg 1x');
|
||||
|
||||
const handled = getHelpers().BUILT_IN_MEDIA_HANDLER(null, img, meta);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(img).not.toHaveAttribute('src');
|
||||
expect(img).not.toHaveAttribute('srcset');
|
||||
expect(img).not.toHaveAttribute('alt');
|
||||
});
|
||||
|
||||
it('returns false when no media target is in the subtree', () => {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.appendChild(document.createElement('span'));
|
||||
const handled = getHelpers().BUILT_IN_MEDIA_HANDLER(
|
||||
{ url: 'https://example.com/new.jpg', mime: 'image/jpeg' },
|
||||
wrapper,
|
||||
meta
|
||||
);
|
||||
expect(handled).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for unknown mime types (fall through to unhandled)', () => {
|
||||
const img = document.createElement('img');
|
||||
img.setAttribute('src', 'https://example.com/old.jpg');
|
||||
const handled = getHelpers().BUILT_IN_MEDIA_HANDLER(
|
||||
{ url: 'https://example.com/file.pdf', mime: 'application/pdf' },
|
||||
img,
|
||||
meta
|
||||
);
|
||||
expect(handled).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user