PluginProbe
Extendify / 3.1.0
Extendify v3.1.0
3.2.1 3.2.0 3.1.6 3.1.5 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 3.0.6 3.0.5 3.0.4 trunk 0.1.0 0.10.0 0.10.1 0.10.2 0.11.0 0.11.1 0.2.0 0.3.0 0.3.1 0.4.0 0.5.0 0.6.0 All 127 releases
extendify / tests / unit / QuickEdit / lib / dom.test.js

dom.test.js in Extendify 3.1.0, at tests/unit/QuickEdit/lib/dom.test.js

415 lines 13.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 // Pins resolveTarget's priority ladder (WPForms → product → post-block →
2 // template-part → nav-ref), detectBlockType's generic wp-block-* derivation,
3 // and splice's DOM swap (variant-class patching, attribute carry-forward,
4 // ext-animate removal, data-extendify-* identity preservation).
5
6 import { resolveTarget, splice } from '@quick-edit/lib/dom';
7
8 beforeEach(() => {
9 delete window.extQuickEditData;
10 document.body.innerHTML = '';
11 });
12
13 const make = (tag, attrs = {}, classes = []) => {
14 const el = document.createElement(tag);
15 for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v);
16 for (const cls of classes) el.classList.add(cls);
17 return el;
18 };
19
20 describe('resolveTarget — null safety', () => {
21 it('returns null for a null node', () => {
22 expect(resolveTarget(null)).toBeNull();
23 });
24
25 it('returns null for a node with no matching ancestor', () => {
26 const orphan = make('p', {}, ['some-class']);
27 document.body.appendChild(orphan);
28 expect(resolveTarget(orphan)).toBeNull();
29 });
30 });
31
32 describe('resolveTarget — priority ladder (WPForms takes precedence)', () => {
33 it('returns wpforms:field when wpform-field-id + ancestor wpform-id both exist on top of an agent-block-id', () => {
34 const form = make('div', { 'data-extendify-quick-edit-wpform-id': '5' });
35 const field = make('div', {
36 'data-extendify-quick-edit-wpform-field-id': '2',
37 'data-extendify-agent-block-id': '11',
38 });
39 form.appendChild(field);
40 document.body.appendChild(form);
41
42 expect(resolveTarget(field)).toEqual({
43 el: field,
44 blockType: 'wpforms:field',
45 formId: 5,
46 fieldId: 2,
47 source: { kind: 'wpforms', formId: 5, fieldId: 2 },
48 });
49 });
50
51 it('skips the wpforms branch when no ancestor carries wpform-id', () => {
52 // Plain <p> with no class — tag-name fallback kicks in.
53 const orphan = make('p', {
54 'data-extendify-quick-edit-wpform-field-id': '2',
55 'data-extendify-agent-block-id': '11',
56 });
57 document.body.appendChild(orphan);
58 expect(resolveTarget(orphan)).toMatchObject({
59 blockId: 11,
60 blockType: 'core/paragraph',
61 });
62 });
63 });
64
65 describe('resolveTarget — product field', () => {
66 it('returns product:<field> over agent-block-id', () => {
67 const el = make('div', {
68 'data-extendify-quick-edit-product-id': '42',
69 'data-extendify-quick-edit-product-field': 'name',
70 'data-extendify-agent-block-id': '11',
71 });
72 document.body.appendChild(el);
73 expect(resolveTarget(el)).toEqual({
74 el,
75 productId: 42,
76 productField: 'name',
77 blockType: 'product:name',
78 source: { kind: 'product', id: 42 },
79 });
80 });
81 });
82
83 describe('resolveTarget — agent-block-id (post-content)', () => {
84 it('detects blockType from the first wp-block-* class', () => {
85 const el = make('h2', { 'data-extendify-agent-block-id': '7' }, [
86 'wp-block-heading',
87 ]);
88 document.body.appendChild(el);
89 const result = resolveTarget(el);
90 expect(result).toMatchObject({
91 blockId: 7,
92 blockType: 'core/heading',
93 });
94 expect(result.el).toBe(el);
95 });
96
97 it('resolves wp-block-paragraph to core/paragraph (no special-case needed)', () => {
98 const el = make('p', { 'data-extendify-agent-block-id': '7' }, [
99 'wp-block-paragraph',
100 ]);
101 document.body.appendChild(el);
102 expect(resolveTarget(el)).toMatchObject({
103 blockId: 7,
104 blockType: 'core/paragraph',
105 });
106 });
107
108 it.each([
109 ['wp-block-group', 'core/group'],
110 ['wp-block-columns', 'core/columns'],
111 ['wp-block-media-text', 'core/media-text'],
112 ['wp-block-gallery', 'core/gallery'],
113 ['wp-block-quote', 'core/quote'],
114 ['wp-block-list', 'core/list'],
115 ['wp-block-table', 'core/table'],
116 ['wp-block-buttons', 'core/buttons'],
117 ])('derives %s → %s for any tagged container', (cls, expected) => {
118 const el = make('div', { 'data-extendify-agent-block-id': '9' }, [cls]);
119 document.body.appendChild(el);
120 expect(resolveTarget(el)).toMatchObject({
121 blockId: 9,
122 blockType: expected,
123 });
124 });
125
126 it.each([
127 'wp-block-acme-testimonial',
128 'wp-block-jetpack-contact-form',
129 'wp-block-woocommerce-product-price',
130 ])('returns blockType null for the third-party class %s instead of a fabricated core/* type', (cls) => {
131 const el = make('div', { 'data-extendify-agent-block-id': '9' }, [cls]);
132 document.body.appendChild(el);
133 expect(resolveTarget(el).blockType).toBeNull();
134 });
135
136 it('keeps the tagged ancestor as el even when the hovered child has its own type', () => {
137 const wrapper = make('div', { 'data-extendify-agent-block-id': '7' }, [
138 'wp-block-group',
139 ]);
140 const child = make('h2', {}, ['wp-block-heading']);
141 wrapper.appendChild(child);
142 document.body.appendChild(wrapper);
143
144 // Selection lands on the tagged ancestor — the pill renderer decides
145 // what to offer. (Recognition-based child preference was dropped in
146 // the selector-unification refactor.)
147 const result = resolveTarget(child);
148 expect(result.el).toBe(wrapper);
149 expect(result.blockType).toBe('core/group');
150 expect(result.blockId).toBe(7);
151 });
152
153 it('returns blockType null when the tagged ancestor is in KNOWN_UNSUPPORTED', () => {
154 const el = make('h1', { 'data-extendify-agent-block-id': '7' }, [
155 'wp-block-post-title',
156 ]);
157 document.body.appendChild(el);
158 expect(resolveTarget(el).blockType).toBeNull();
159 });
160
161 it('reads source from window.extQuickEditData.context.currentSource', () => {
162 window.extQuickEditData = {
163 context: { currentSource: { kind: 'post', id: 42 } },
164 };
165 const el = make('p', { 'data-extendify-agent-block-id': '1' }, [
166 'wp-block-paragraph',
167 ]);
168 document.body.appendChild(el);
169 expect(resolveTarget(el).source).toEqual({ kind: 'post', id: 42 });
170 });
171
172 it('defaults source to null when no currentSource is set', () => {
173 const el = make('p', { 'data-extendify-agent-block-id': '1' }, [
174 'wp-block-paragraph',
175 ]);
176 document.body.appendChild(el);
177 expect(resolveTarget(el).source).toBeNull();
178 });
179 });
180
181 describe('resolveTarget — template part', () => {
182 it('returns kind=template-part with the part slug and the ancestor blockType', () => {
183 const part = make(
184 'div',
185 {
186 'data-extendify-part-block-id': '3',
187 'data-extendify-part-slug': 'header',
188 },
189 ['wp-block-group'],
190 );
191 const child = make('p', {}, ['wp-block-paragraph']);
192 part.appendChild(child);
193 document.body.appendChild(part);
194
195 const result = resolveTarget(child);
196 expect(result).toMatchObject({
197 blockId: 3,
198 blockType: 'core/group',
199 source: { kind: 'template-part', partSlug: 'header' },
200 });
201 });
202
203 it('routes ref-based nav items through wp-navigation', () => {
204 const nav = make('nav', { 'data-extendify-quick-edit-nav-ref': '12' });
205 const item = make(
206 'li',
207 {
208 'data-extendify-part-block-id': '5',
209 'data-extendify-quick-edit-nav-item-index': '2',
210 },
211 ['wp-block-navigation-item'],
212 );
213 nav.appendChild(item);
214 document.body.appendChild(nav);
215
216 expect(resolveTarget(item)).toEqual({
217 el: item,
218 blockType: 'core/navigation-link',
219 navPostId: 12,
220 itemIndex: 2,
221 source: { kind: 'wp-navigation', id: 12, itemIndex: 2 },
222 });
223 });
224
225 it('falls back to template-part for nav items without a nav-ref ancestor (inline nav)', () => {
226 const part = make('header', { 'data-extendify-part-block-id': '7' });
227 const item = make(
228 'li',
229 {
230 'data-extendify-part-block-id': '5',
231 'data-extendify-quick-edit-nav-item-index': '2',
232 },
233 ['wp-block-navigation-item'],
234 );
235 part.appendChild(item);
236 document.body.appendChild(part);
237
238 const result = resolveTarget(item);
239 expect(result.source.kind).toBe('template-part');
240 });
241
242 it('lands on the tagged template-part ancestor regardless of which child was hovered', () => {
243 const buttons = make(
244 'div',
245 {
246 'data-extendify-part-block-id': '4',
247 'data-extendify-part-slug': 'header',
248 },
249 ['wp-block-buttons'],
250 );
251 const button = make('div', {}, ['wp-block-button']);
252 buttons.appendChild(button);
253 document.body.appendChild(buttons);
254
255 const result = resolveTarget(button);
256 expect(result.el).toBe(buttons);
257 expect(result.blockType).toBe('core/buttons');
258 expect(result.blockId).toBe(4);
259 expect(result.source).toEqual({
260 kind: 'template-part',
261 partSlug: 'header',
262 });
263 });
264 });
265
266 describe('splice — null safety', () => {
267 it('returns null when liveEl is missing', () => {
268 expect(splice(null, '<p>x</p>')).toBeNull();
269 });
270
271 it('returns null when renderedHtml is missing', () => {
272 expect(splice(make('p'), '')).toBeNull();
273 });
274 });
275
276 describe('splice — DOM swap', () => {
277 it('replaces liveEl with the parsed first element of renderedHtml', () => {
278 const parent = document.createElement('div');
279 const live = make('p', { 'data-x': '1' });
280 live.textContent = 'old';
281 parent.appendChild(live);
282 document.body.appendChild(parent);
283
284 const result = splice(live, '<p class="new">new</p>');
285 expect(result).not.toBeNull();
286 expect(parent.firstElementChild).toBe(result);
287 expect(parent.firstElementChild.textContent).toBe('new');
288 expect(parent.firstElementChild.classList.contains('new')).toBe(true);
289 });
290
291 it('carries forward data-extendify-* attributes from the live element', () => {
292 const parent = document.createElement('div');
293 const live = make('p', {
294 'data-extendify-agent-block-id': '7',
295 'data-extendify-part-block-id': '3',
296 'data-x': 'not-carried',
297 });
298 parent.appendChild(live);
299
300 const result = splice(live, '<p>new</p>');
301 expect(result.getAttribute('data-extendify-agent-block-id')).toBe('7');
302 expect(result.getAttribute('data-extendify-part-block-id')).toBe('3');
303 expect(result.getAttribute('data-x')).toBeNull();
304 });
305
306 // The save re-renders the block in an isolated scope, so the returned HTML
307 // carries a fresh agent/post tag (data-extendify-agent-block-id) numbered
308 // for that scope. A template-part block's live element has only the part
309 // tag, so without dropping the stray agent tag the spliced node ends up
310 // with BOTH — and resolveTarget (agent tag checked before part tag)
311 // resolves the re-edit to the wrong (post) block (wrong content / empty toolbar).
312 it('drops the re-render stray identity tags, keeping only the live element identity', () => {
313 const parent = document.createElement('div');
314 const live = make('div', {
315 'data-extendify-part-block-id': '5',
316 'data-extendify-part-slug': 'header',
317 });
318 parent.appendChild(live);
319
320 const result = splice(
321 live,
322 '<div data-extendify-agent-block-id="1">new</div>',
323 );
324 expect(result.getAttribute('data-extendify-agent-block-id')).toBeNull();
325 expect(result.getAttribute('data-extendify-part-block-id')).toBe('5');
326 expect(result.getAttribute('data-extendify-part-slug')).toBe('header');
327 });
328
329 it('carries forward color / font-size / background attribute classes that lose round-trip', () => {
330 const parent = document.createElement('div');
331 const live = make('p', {}, [
332 'has-text-color',
333 'has-primary-color',
334 'has-background',
335 'has-large-font-size',
336 ]);
337 parent.appendChild(live);
338
339 const result = splice(live, '<p class="new">x</p>');
340 for (const cls of [
341 'has-text-color',
342 'has-primary-color',
343 'has-background',
344 'has-large-font-size',
345 ]) {
346 expect(result.classList.contains(cls)).toBe(true);
347 }
348 });
349
350 it('strips ext-animate--on from the new node + descendants', () => {
351 const parent = document.createElement('div');
352 const live = make('p');
353 parent.appendChild(live);
354
355 const result = splice(
356 live,
357 '<div class="ext-animate--on"><span class="ext-animate--on">x</span></div>',
358 );
359 expect(result.classList.contains('ext-animate--on')).toBe(false);
360 expect(result.querySelectorAll('.ext-animate--on').length).toBe(0);
361 });
362
363 it('returns null when renderedHtml has no element (whitespace-only)', () => {
364 const parent = document.createElement('div');
365 const live = make('p');
366 parent.appendChild(live);
367 expect(splice(live, ' ')).toBeNull();
368 });
369
370 it('carries the live element variation-instance class when the re-render omits it', () => {
371 const parent = document.createElement('div');
372 const live = make('div', {}, [
373 'wp-block-button',
374 'is-style-ext-preset--button--soft-1--button-1',
375 'is-style-ext-preset--button--soft-1--button-1--3',
376 ]);
377 parent.appendChild(live);
378
379 const result = splice(
380 live,
381 '<div class="wp-block-button is-style-ext-preset--button--soft-1--button-1"><a class="wp-block-button__link">x</a></div>',
382 );
383 expect(
384 result.classList.contains(
385 'is-style-ext-preset--button--soft-1--button-1--3',
386 ),
387 ).toBe(true);
388 });
389
390 it('lets patchVariantClasses win when the re-render already carries an instance class', () => {
391 const parent = document.createElement('div');
392 const live = make('div', {}, [
393 'wp-block-button',
394 'is-style-ext-preset--button--soft-1--button-1',
395 'is-style-ext-preset--button--soft-1--button-1--3',
396 ]);
397 parent.appendChild(live);
398
399 const result = splice(
400 live,
401 '<div class="wp-block-button is-style-ext-preset--button--soft-1--button-1 is-style-ext-preset--button--soft-1--button-1--7"><a class="wp-block-button__link">x</a></div>',
402 );
403 expect(
404 result.classList.contains(
405 'is-style-ext-preset--button--soft-1--button-1--3',
406 ),
407 ).toBe(true);
408 expect(
409 result.classList.contains(
410 'is-style-ext-preset--button--soft-1--button-1--7',
411 ),
412 ).toBe(false);
413 });
414 });
415