@elementor/editor-canvas 4.0.0-607 → 4.0.0-619

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,6 +8,7 @@ import { Portal } from '@elementor/ui';
8
8
 
9
9
  import { useDocumentsCssLinks } from '../hooks/use-documents-css-links';
10
10
  import { useStyleItems } from '../hooks/use-style-items';
11
+ import { type StyleItem } from '../renderers/create-styles-renderer';
11
12
 
12
13
  export function StyleRenderer() {
13
14
  const container = usePortalContainer();
@@ -21,8 +22,8 @@ export function StyleRenderer() {
21
22
 
22
23
  return (
23
24
  <Portal container={ container }>
24
- { styleItems.map( ( item, i ) => (
25
- <style key={ `${ item.id }-${ i }-${ item.breakpoint }` }>{ item.value }</style>
25
+ { filterUniqueStyleDefinitions( styleItems ).map( ( item ) => (
26
+ <style key={ `${ item.id }-${ item.breakpoint }-${ item.state ?? 'normal' }` }>{ item.value }</style>
26
27
  ) ) }
27
28
  { linksAttrs.map( ( attrs ) => (
28
29
  <link { ...attrs } key={ attrs.id } />
@@ -34,3 +35,29 @@ export function StyleRenderer() {
34
35
  function usePortalContainer() {
35
36
  return useListenTo( commandEndEvent( 'editor/documents/attach-preview' ), () => getCanvasIframeDocument()?.head );
36
37
  }
38
+
39
+ // we load local styles also from components, which are handled differently
40
+ // to avoid having "Encountered two children with the same key" - adding this filtering to avoid rendering the same style twice
41
+ function filterUniqueStyleDefinitions( styleItems: StyleItem[] ) {
42
+ const seen = new Map< string, StyleItem[] >();
43
+
44
+ return styleItems.filter( ( style ) => {
45
+ const existingStyle = seen.get( style.id );
46
+
47
+ if ( existingStyle ) {
48
+ const existingStyleVariant = existingStyle.find(
49
+ ( s ) => s.breakpoint === style.breakpoint && s.state === style.state
50
+ );
51
+
52
+ if ( existingStyleVariant ) {
53
+ return false;
54
+ }
55
+
56
+ existingStyle.push( style );
57
+ return true;
58
+ }
59
+
60
+ seen.set( style.id, [ style ] );
61
+ return true;
62
+ } );
63
+ }
@@ -28,7 +28,7 @@ jest.mock( '../use-style-renderer', () => ( {
28
28
  } ) );
29
29
 
30
30
  jest.mock( '@elementor/editor-responsive', () => ( {
31
- getBreakpoints: jest.fn().mockReturnValue( [
31
+ useBreakpoints: jest.fn().mockReturnValue( [
32
32
  { id: 'desktop', label: 'Desktop' },
33
33
  { id: 'tablet', label: 'Tablet' },
34
34
  { id: 'mobile', label: 'Mobile' },
@@ -39,10 +39,12 @@ describe( 'useStyleItems', () => {
39
39
  beforeEach( () => {
40
40
  jest.mocked( useStyleRenderer ).mockReturnValue(
41
41
  jest.fn().mockImplementation( ( { styles } ) =>
42
- styles.map( ( style: StyleDefinition ) => ( {
43
- id: style.id,
44
- breakpoint: style?.variants[ 0 ]?.meta.breakpoint || 'desktop',
45
- } ) )
42
+ Promise.resolve(
43
+ styles.map( ( style: StyleDefinition ) => ( {
44
+ id: style.id,
45
+ breakpoint: style?.variants[ 0 ]?.meta.breakpoint || 'desktop',
46
+ } ) )
47
+ )
46
48
  )
47
49
  );
48
50
  } );
@@ -229,4 +231,106 @@ describe( 'useStyleItems', () => {
229
231
  { id: 'style1', breakpoint: 'mobile' },
230
232
  ] );
231
233
  } );
234
+
235
+ it( 'should use cached items when no changes detected', async () => {
236
+ // Arrange.
237
+ const renderStylesMock = jest.fn().mockImplementation( ( { styles } ) =>
238
+ Promise.resolve(
239
+ styles.map( ( style: StyleDefinition ) => ( {
240
+ id: style.id,
241
+ breakpoint: style?.variants[ 0 ]?.meta.breakpoint || 'desktop',
242
+ } ) )
243
+ )
244
+ );
245
+
246
+ jest.mocked( useStyleRenderer ).mockReturnValue( renderStylesMock );
247
+
248
+ const mockProvider = createMockStylesProvider( { key: 'provider1', priority: 1 }, [
249
+ createMockStyleDefinition( { id: 'style1' } ),
250
+ createMockStyleDefinition( { id: 'style2' } ),
251
+ ] );
252
+
253
+ jest.mocked( stylesRepository ).getProviders.mockReturnValue( [ mockProvider ] );
254
+
255
+ // Act - initial render.
256
+ const { result } = renderHook( () => useStyleItems() );
257
+
258
+ await act( async () => {
259
+ mockProvider.actions.updateProps?.( {
260
+ id: 'style1',
261
+ meta: { breakpoint: null, state: null },
262
+ props: { a: 1 },
263
+ } );
264
+ } );
265
+
266
+ // Assert.
267
+ expect( renderStylesMock ).toHaveBeenCalledTimes( 1 );
268
+ expect( result.current ).toHaveLength( 2 );
269
+
270
+ // Act - trigger update with same props (updateProps mutates in place, same reference).
271
+ renderStylesMock.mockClear();
272
+
273
+ await act( async () => {
274
+ mockProvider.actions.updateProps?.( {
275
+ id: 'style1',
276
+ meta: { breakpoint: null, state: null },
277
+ props: { a: 1 },
278
+ } );
279
+ } );
280
+
281
+ // Assert - renderStyles should not be called when no changes detected.
282
+ expect( renderStylesMock ).not.toHaveBeenCalled();
283
+ expect( result.current ).toHaveLength( 2 );
284
+ } );
285
+
286
+ it( 'should only re-render changed styles on differential update', async () => {
287
+ // Arrange.
288
+ const renderStylesMock = jest.fn().mockImplementation( ( { styles } ) =>
289
+ Promise.resolve(
290
+ styles.map( ( style: StyleDefinition ) => ( {
291
+ id: style.id,
292
+ breakpoint: style?.variants[ 0 ]?.meta.breakpoint || 'desktop',
293
+ } ) )
294
+ )
295
+ );
296
+
297
+ jest.mocked( useStyleRenderer ).mockReturnValue( renderStylesMock );
298
+
299
+ const mockProvider = createMockStylesProvider( { key: 'provider1', priority: 1 }, [
300
+ createMockStyleDefinition( { id: 'style1' } ),
301
+ createMockStyleDefinition( { id: 'style2' } ),
302
+ createMockStyleDefinition( { id: 'style3' } ),
303
+ ] );
304
+
305
+ jest.mocked( stylesRepository ).getProviders.mockReturnValue( [ mockProvider ] );
306
+
307
+ // Act - initial render.
308
+ const { result } = renderHook( () => useStyleItems() );
309
+
310
+ await act( async () => {
311
+ mockProvider.actions.updateProps?.( {
312
+ id: 'style1',
313
+ meta: { breakpoint: null, state: null },
314
+ props: { a: 1 },
315
+ } );
316
+ } );
317
+
318
+ // Assert - initial render includes all styles.
319
+ expect( renderStylesMock ).toHaveBeenCalledTimes( 1 );
320
+ expect( renderStylesMock.mock.calls[ 0 ][ 0 ].styles ).toHaveLength( 3 );
321
+ expect( result.current ).toHaveLength( 3 );
322
+
323
+ // Act - update style2 using update action (creates new object reference).
324
+ renderStylesMock.mockClear();
325
+
326
+ await act( async () => {
327
+ mockProvider.actions.update?.( { id: 'style2', label: 'Updated Style 2' } );
328
+ } );
329
+
330
+ // Assert - only the changed style should be rendered.
331
+ expect( renderStylesMock ).toHaveBeenCalledTimes( 1 );
332
+ expect( renderStylesMock.mock.calls[ 0 ][ 0 ].styles ).toHaveLength( 1 );
333
+ expect( renderStylesMock.mock.calls[ 0 ][ 0 ].styles[ 0 ].id ).toBe( 'style2' );
334
+ expect( result.current ).toHaveLength( 3 );
335
+ } );
232
336
  } );
@@ -1,6 +1,6 @@
1
- import { type Dispatch, type SetStateAction, useEffect, useMemo, useState } from 'react';
2
- import { type BreakpointId, getBreakpoints } from '@elementor/editor-responsive';
3
- import { isClassState, type StyleDefinitionClassState } from '@elementor/editor-styles';
1
+ import { type Dispatch, type SetStateAction, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { type BreakpointId, useBreakpoints } from '@elementor/editor-responsive';
3
+ import { isClassState, type StyleDefinition, type StyleDefinitionClassState } from '@elementor/editor-styles';
4
4
  import { type StylesProvider, stylesRepository } from '@elementor/editor-styles-repository';
5
5
  import { registerDataHook } from '@elementor/editor-v1-adapters';
6
6
 
@@ -11,26 +11,47 @@ import { useOnMount } from './use-on-mount';
11
11
  import { useStylePropResolver } from './use-style-prop-resolver';
12
12
  import { useStyleRenderer } from './use-style-renderer';
13
13
 
14
+ type StylesCollection = Record< string, StyleDefinition >;
15
+
16
+ type StyleItemsCache = {
17
+ orderedIds: string[];
18
+ itemsById: Map< string, StyleItem[] >;
19
+ };
20
+
14
21
  type ProviderAndStyleItems = { provider: StylesProvider; items: StyleItem[] };
15
22
 
16
- type ProviderAndSubscriber = { provider: StylesProvider; subscriber: () => Promise< void > };
23
+ type ProviderAndSubscriber = {
24
+ provider: StylesProvider;
25
+ subscriber: ( previous?: StylesCollection, current?: StylesCollection ) => Promise< void >;
26
+ };
17
27
 
18
28
  type ProviderAndStyleItemsMap = Record< string, ProviderAndStyleItems >;
19
29
 
20
30
  export function useStyleItems() {
21
31
  const resolve = useStylePropResolver();
22
32
  const renderStyles = useStyleRenderer( resolve );
33
+ const breakpoints = useBreakpoints();
23
34
 
24
35
  const [ styleItems, setStyleItems ] = useState< ProviderAndStyleItemsMap >( {} );
36
+ const styleItemsCacheRef = useRef< Map< string, StyleItemsCache > >( new Map() );
25
37
 
26
38
  const providerAndSubscribers = useMemo( () => {
27
39
  return stylesRepository.getProviders().map( ( provider ): ProviderAndSubscriber => {
40
+ const providerKey = provider.getKey();
41
+
42
+ if ( ! styleItemsCacheRef.current.has( providerKey ) ) {
43
+ styleItemsCacheRef.current.set( providerKey, { orderedIds: [], itemsById: new Map() } );
44
+ }
45
+
46
+ const cache = styleItemsCacheRef.current.get( providerKey ) as StyleItemsCache;
47
+
28
48
  return {
29
49
  provider,
30
50
  subscriber: createProviderSubscriber( {
31
51
  provider,
32
52
  renderStyles,
33
53
  setStyleItems,
54
+ cache,
34
55
  } ),
35
56
  };
36
57
  } );
@@ -54,33 +75,30 @@ export function useStyleItems() {
54
75
  } );
55
76
  } );
56
77
 
57
- const breakpointsOrder = getBreakpoints().map( ( breakpoint ) => breakpoint.id );
78
+ const breakpointSorter = useMemo(
79
+ () => createBreakpointSorter( breakpoints.map( ( breakpoint ) => breakpoint.id ) ),
80
+ [ breakpoints ]
81
+ );
58
82
 
59
83
  return useMemo(
60
84
  () =>
61
85
  Object.values( styleItems )
62
- .sort( sortByProviderPriority )
86
+ .sort( prioritySorter )
63
87
  .flatMap( ( { items } ) => items )
64
- .sort( sortByStateType )
65
- .sort( sortByBreakpoint( breakpointsOrder ) ),
66
- // eslint-disable-next-line react-hooks/exhaustive-deps
67
- [ styleItems, breakpointsOrder.join( '-' ) ]
88
+ .sort( stateSorter )
89
+ .sort( breakpointSorter ),
90
+ [ styleItems, breakpointSorter ]
68
91
  );
69
92
  }
70
- function sortByProviderPriority(
93
+
94
+ function prioritySorter(
71
95
  { provider: providerA }: ProviderAndStyleItems,
72
96
  { provider: providerB }: ProviderAndStyleItems
73
97
  ) {
74
98
  return providerA.priority - providerB.priority;
75
99
  }
76
100
 
77
- function sortByBreakpoint( breakpointsOrder: BreakpointId[] ) {
78
- return ( { breakpoint: breakpointA }: StyleItem, { breakpoint: breakpointB }: StyleItem ) =>
79
- breakpointsOrder.indexOf( breakpointA as BreakpointId ) -
80
- breakpointsOrder.indexOf( breakpointB as BreakpointId );
81
- }
82
-
83
- function sortByStateType( { state: stateA }: StyleItem, { state: stateB }: StyleItem ) {
101
+ function stateSorter( { state: stateA }: StyleItem, { state: stateB }: StyleItem ) {
84
102
  if (
85
103
  isClassState( stateA as StyleDefinitionClassState ) &&
86
104
  ! isClassState( stateB as StyleDefinitionClassState )
@@ -98,28 +116,31 @@ function sortByStateType( { state: stateA }: StyleItem, { state: stateB }: Style
98
116
  return 0;
99
117
  }
100
118
 
119
+ function createBreakpointSorter( breakpointsOrder: BreakpointId[] ) {
120
+ return ( { breakpoint: breakpointA }: StyleItem, { breakpoint: breakpointB }: StyleItem ) =>
121
+ breakpointsOrder.indexOf( breakpointA as BreakpointId ) -
122
+ breakpointsOrder.indexOf( breakpointB as BreakpointId );
123
+ }
124
+
101
125
  type CreateProviderSubscriberArgs = {
102
126
  provider: StylesProvider;
103
127
  renderStyles: StyleRenderer;
104
128
  setStyleItems: Dispatch< SetStateAction< ProviderAndStyleItemsMap > >;
129
+ cache: StyleItemsCache;
105
130
  };
106
131
 
107
- function createProviderSubscriber( { provider, renderStyles, setStyleItems }: CreateProviderSubscriberArgs ) {
108
- return abortPreviousRuns( ( abortController ) =>
132
+ function createProviderSubscriber( { provider, renderStyles, setStyleItems, cache }: CreateProviderSubscriberArgs ) {
133
+ return abortPreviousRuns( ( abortController, previous?: StylesCollection, current?: StylesCollection ) =>
109
134
  signalizedProcess( abortController.signal )
110
135
  .then( ( _, signal ) => {
111
- const styles = provider.actions.all().map( ( __, index, items ) => {
112
- const lastPosition = items.length - 1;
113
-
114
- const style = items[ lastPosition - index ];
136
+ const hasDiffInfo = current !== undefined && previous !== undefined;
137
+ const hasCache = cache.orderedIds.length > 0;
115
138
 
116
- return {
117
- ...style,
118
- cssName: provider.actions.resolveCssName( style.id ),
119
- };
120
- } );
139
+ if ( hasDiffInfo && hasCache ) {
140
+ return updateItems( previous, current, signal );
141
+ }
121
142
 
122
- return renderStyles( { styles: breakToBreakpoints( styles ), signal } );
143
+ return createItems( signal );
123
144
  } )
124
145
  .then( ( items ) => {
125
146
  setStyleItems( ( prev ) => ( {
@@ -130,6 +151,50 @@ function createProviderSubscriber( { provider, renderStyles, setStyleItems }: Cr
130
151
  .execute()
131
152
  );
132
153
 
154
+ async function updateItems( previous: StylesCollection, current: StylesCollection, signal: AbortSignal ) {
155
+ const changedIds = getChangedStyleIds( previous, current );
156
+
157
+ cache.orderedIds = provider.actions
158
+ .all()
159
+ .map( ( style ) => style.id )
160
+ .reverse();
161
+
162
+ if ( changedIds.length > 0 ) {
163
+ const changedStyles = changedIds
164
+ .map( ( id ) => provider.actions.get( id ) )
165
+ .filter( ( style ): style is StyleDefinition => !! style )
166
+ .map( ( style ) => ( {
167
+ ...style,
168
+ cssName: provider.actions.resolveCssName( style.id ),
169
+ } ) );
170
+
171
+ return renderStyles( { styles: breakToBreakpoints( changedStyles ), signal } ).then( ( rendered ) => {
172
+ updateCacheItems( cache, rendered );
173
+
174
+ return getOrderedItems( cache );
175
+ } );
176
+ }
177
+
178
+ return getOrderedItems( cache );
179
+ }
180
+
181
+ async function createItems( signal: AbortSignal ) {
182
+ const allStyles = provider.actions.all();
183
+
184
+ const styles = allStyles.reverse().map( ( style ) => {
185
+ return {
186
+ ...style,
187
+ cssName: provider.actions.resolveCssName( style.id ),
188
+ };
189
+ } );
190
+
191
+ return renderStyles( { styles: breakToBreakpoints( styles ), signal } ).then( ( rendered ) => {
192
+ rebuildCache( cache, allStyles, rendered );
193
+
194
+ return rendered;
195
+ } );
196
+ }
197
+
133
198
  function breakToBreakpoints( styles: RendererStyleDefinition[] ) {
134
199
  return Object.values(
135
200
  styles.reduce(
@@ -157,3 +222,52 @@ function createProviderSubscriber( { provider, renderStyles, setStyleItems }: Cr
157
222
  ).flatMap( ( breakpointMap ) => Object.values( breakpointMap ) );
158
223
  }
159
224
  }
225
+
226
+ function getChangedStyleIds( previous: StylesCollection, current: StylesCollection ): string[] {
227
+ const changedIds: string[] = [];
228
+
229
+ for ( const id of Object.keys( current ) ) {
230
+ const currentStyle = current[ id ];
231
+ const previousStyle = previous[ id ];
232
+
233
+ if ( ! previousStyle || currentStyle !== previousStyle ) {
234
+ changedIds.push( id );
235
+ }
236
+ }
237
+
238
+ return changedIds;
239
+ }
240
+
241
+ function getOrderedItems( cache: StyleItemsCache ): StyleItem[] {
242
+ return cache.orderedIds
243
+ .map( ( id ) => cache.itemsById.get( id ) )
244
+ .filter( ( items ): items is StyleItem[] => items !== undefined )
245
+ .flat();
246
+ }
247
+
248
+ function updateCacheItems( cache: StyleItemsCache, changedItems: StyleItem[] ): void {
249
+ for ( const item of changedItems ) {
250
+ const existing = cache.itemsById.get( item.id );
251
+ if ( existing ) {
252
+ const idx = existing.findIndex( ( e ) => e.breakpoint === item.breakpoint && e.state === item.state );
253
+ if ( idx >= 0 ) {
254
+ existing[ idx ] = item;
255
+ } else {
256
+ existing.push( item );
257
+ }
258
+ } else {
259
+ cache.itemsById.set( item.id, [ item ] );
260
+ }
261
+ }
262
+ }
263
+
264
+ function rebuildCache( cache: StyleItemsCache, allStyles: StyleDefinition[], items: StyleItem[] ): void {
265
+ cache.orderedIds = allStyles.map( ( style ) => style.id ).reverse();
266
+ cache.itemsById.clear();
267
+
268
+ for ( const item of items ) {
269
+ const existing = cache.itemsById.get( item.id ) || [];
270
+ existing.push( item );
271
+ cache.itemsById.set( item.id, existing );
272
+ }
273
+ }
@@ -9,6 +9,7 @@ import { queryTransformer } from './transformers/settings/query-transformer';
9
9
  import { imageSrcTransformer } from './transformers/shared/image-src-transformer';
10
10
  import { imageTransformer } from './transformers/shared/image-transformer';
11
11
  import { plainTransformer } from './transformers/shared/plain-transformer';
12
+ import { videoSrcTransformer } from './transformers/shared/video-src-transformer';
12
13
 
13
14
  export function initSettingsTransformers() {
14
15
  settingsTransformersRegistry
@@ -17,6 +18,7 @@ export function initSettingsTransformers() {
17
18
  .register( 'query', queryTransformer )
18
19
  .register( 'image', imageTransformer )
19
20
  .register( 'image-src', imageSrcTransformer )
21
+ .register( 'video-src', videoSrcTransformer )
20
22
  .register( 'attributes', attributesTransformer )
21
23
  .register( 'date-time', dateTimeTransformer )
22
24
  .register( 'html-v2', htmlV2Transformer )
package/src/init.tsx CHANGED
@@ -1,5 +1,4 @@
1
1
  import { injectIntoLogic, injectIntoTop } from '@elementor/editor';
2
- import { init as initInteractionsRepository } from '@elementor/editor-interactions';
3
2
  import { getMCPByDomain } from '@elementor/editor-mcp';
4
3
 
5
4
  import { ClassesRename } from './components/classes-rename';
@@ -32,8 +31,6 @@ export function init() {
32
31
 
33
32
  initSettingsTransformers();
34
33
 
35
- initInteractionsRepository();
36
-
37
34
  injectIntoTop( {
38
35
  id: 'elements-overlays',
39
36
  component: ElementsOverlays,
@@ -47,6 +47,9 @@ export type LegacyWindow = Window & {
47
47
  },
48
48
  ];
49
49
  $previewWrapper: JQueryElement;
50
+ helpers: {
51
+ hasPro: () => boolean;
52
+ };
50
53
  };
51
54
  };
52
55
 
@@ -80,6 +83,7 @@ export declare class ElementView {
80
83
  length: number;
81
84
  findByIndex: ( index: number ) => ElementView;
82
85
  each: ( callback: ( view: ElementView ) => void ) => void;
86
+ map: < T >( callback: ( view: ElementView ) => T ) => T[];
83
87
  };
84
88
 
85
89
  constructor( ...args: unknown[] );
@@ -216,7 +220,16 @@ type ToJSON< T > = {
216
220
 
217
221
  type ContextMenuGroup = {
218
222
  name: string;
219
- actions: unknown[];
223
+ actions: ContextMenuAction[];
224
+ };
225
+
226
+ export type ContextMenuAction = {
227
+ name: string;
228
+ icon: string;
229
+ title: string | ( () => string );
230
+ shortcut?: string;
231
+ isEnabled: () => boolean;
232
+ callback: ( _: unknown, eventData: unknown ) => void;
220
233
  };
221
234
 
222
235
  export type ReplacementSettings = {
@@ -48,7 +48,16 @@ const SELECTORS_MAP: Record< StyleDefinitionType, string > = {
48
48
 
49
49
  export function createStylesRenderer( { resolve, breakpoints, selectorPrefix = '' }: CreateStyleRendererArgs ) {
50
50
  return async ( { styles, signal }: StyleRendererArgs ): Promise< StyleItem[] > => {
51
- const stylesCssPromises = styles.map( async ( style ) => {
51
+ const seenIds = new Set< string >();
52
+ const uniqueStyles = styles.filter( ( style ) => {
53
+ if ( seenIds.has( style.id ) ) {
54
+ return false;
55
+ }
56
+ seenIds.add( style.id );
57
+ return true;
58
+ } );
59
+
60
+ const stylesCssPromises = uniqueStyles.map( async ( style ) => {
52
61
  const variantCssPromises = Object.values( style.variants ).map( async ( variant ) => {
53
62
  const css = await propsToCss( { props: variant.props, resolve, signal } );
54
63
  const customCss = customCssToString( variant.custom_css );
@@ -0,0 +1,159 @@
1
+ import { getMediaAttachment } from '@elementor/wp-media';
2
+
3
+ import { videoSrcTransformer } from '../video-src-transformer';
4
+
5
+ jest.mock( '@elementor/wp-media' );
6
+
7
+ const mockedGetMediaAttachment = jest.mocked( getMediaAttachment );
8
+
9
+ describe( 'videoSrcTransformer', () => {
10
+ beforeEach( () => {
11
+ jest.clearAllMocks();
12
+ } );
13
+
14
+ it( 'returns url when only url is provided', async () => {
15
+ // Arrange.
16
+ const value = {
17
+ id: null,
18
+ url: 'https://example.com/video.mp4',
19
+ };
20
+
21
+ // Act.
22
+ const result = await videoSrcTransformer( value, { key: 'source' } );
23
+
24
+ // Assert.
25
+ expect( result ).toEqual( {
26
+ id: null,
27
+ url: 'https://example.com/video.mp4',
28
+ } );
29
+ expect( mockedGetMediaAttachment ).not.toHaveBeenCalled();
30
+ } );
31
+
32
+ it( 'resolves url from attachment when id is provided', async () => {
33
+ // Arrange.
34
+ mockedGetMediaAttachment.mockResolvedValue( {
35
+ url: 'https://example.com/resolved-video.mp4',
36
+ } as never );
37
+ const value = {
38
+ id: 123,
39
+ url: null,
40
+ };
41
+
42
+ // Act.
43
+ const result = await videoSrcTransformer( value, { key: 'source' } );
44
+
45
+ // Assert.
46
+ expect( result ).toEqual( {
47
+ id: 123,
48
+ url: 'https://example.com/resolved-video.mp4',
49
+ } );
50
+ expect( mockedGetMediaAttachment ).toHaveBeenCalledWith( { id: 123 } );
51
+ } );
52
+
53
+ it( 'uses attachment url over provided url when id exists', async () => {
54
+ // Arrange.
55
+ mockedGetMediaAttachment.mockResolvedValue( {
56
+ url: 'https://example.com/attachment-url.mp4',
57
+ } as never );
58
+ const value = {
59
+ id: 456,
60
+ url: 'https://example.com/original-url.mp4',
61
+ };
62
+
63
+ // Act.
64
+ const result = await videoSrcTransformer( value, { key: 'source' } );
65
+
66
+ // Assert.
67
+ expect( result ).toEqual( {
68
+ id: 456,
69
+ url: 'https://example.com/attachment-url.mp4',
70
+ } );
71
+ } );
72
+
73
+ it( 'falls back to provided url when attachment lookup fails', async () => {
74
+ // Arrange.
75
+ mockedGetMediaAttachment.mockResolvedValue( null as never );
76
+ const value = {
77
+ id: 789,
78
+ url: 'https://example.com/fallback.mp4',
79
+ };
80
+
81
+ // Act.
82
+ const result = await videoSrcTransformer( value, { key: 'source' } );
83
+
84
+ // Assert.
85
+ expect( result ).toEqual( {
86
+ id: 789,
87
+ url: 'https://example.com/fallback.mp4',
88
+ } );
89
+ } );
90
+
91
+ it( 'handles empty object', async () => {
92
+ // Arrange.
93
+ const value = {} as { id: null; url: null };
94
+
95
+ // Act.
96
+ const result = await videoSrcTransformer( value, { key: 'source' } );
97
+
98
+ // Assert.
99
+ expect( result ).toEqual( {
100
+ id: null,
101
+ url: undefined,
102
+ } );
103
+ } );
104
+
105
+ it( 'handles both id and url being null', async () => {
106
+ // Arrange.
107
+ const value = {
108
+ id: null,
109
+ url: null,
110
+ };
111
+
112
+ // Act.
113
+ const result = await videoSrcTransformer( value, { key: 'source' } );
114
+
115
+ // Assert.
116
+ expect( result ).toEqual( {
117
+ id: null,
118
+ url: null,
119
+ } );
120
+ } );
121
+
122
+ it( 'handles id of 0 as falsy (no attachment lookup)', async () => {
123
+ // Arrange.
124
+ const value = {
125
+ id: 0,
126
+ url: 'https://example.com/video.mp4',
127
+ };
128
+
129
+ // Act.
130
+ const result = await videoSrcTransformer( value, { key: 'source' } );
131
+
132
+ // Assert.
133
+ expect( result ).toEqual( {
134
+ id: null,
135
+ url: 'https://example.com/video.mp4',
136
+ } );
137
+ expect( mockedGetMediaAttachment ).not.toHaveBeenCalled();
138
+ } );
139
+
140
+ it( 'handles undefined url with valid id', async () => {
141
+ // Arrange.
142
+ mockedGetMediaAttachment.mockResolvedValue( {
143
+ url: 'https://example.com/resolved.mp4',
144
+ } as never );
145
+ const value = {
146
+ id: 123,
147
+ url: undefined,
148
+ } as unknown as { id: number; url: null };
149
+
150
+ // Act.
151
+ const result = await videoSrcTransformer( value, { key: 'source' } );
152
+
153
+ // Assert.
154
+ expect( result ).toEqual( {
155
+ id: 123,
156
+ url: 'https://example.com/resolved.mp4',
157
+ } );
158
+ } );
159
+ } );