PluginProbe
Extendify / 3.1.1
Extendify v3.1.1
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 / components / InlineEditor.test.jsx

InlineEditor.test.jsx in Extendify 3.1.1, at tests/unit/QuickEdit/components/InlineEditor.test.jsx

382 lines 11.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 // InlineEditor is the orchestrator: it reads `selected` from the
2 // QuickEdit store and dispatches to one of three branches —
3 // (a) text blocks → BlockTextEditor,
4 // (b) image blocks → ImagePicker (inline menu),
5 // (c) modal block types → mountModal(<TheRightModal …/>).
6 // Tests pin the routing decision per block type, the props handed to each
7 // modal, and the onAfterSave reload-on-true contract.
8
9 import { render } from '@testing-library/react';
10
11 const mockMountModal = jest.fn();
12 const mockCloseModal = jest.fn();
13 const mockClearSelected = jest.fn();
14 let mockSelected = null;
15
16 jest.mock('@quick-edit/lib/api', () => ({
17 loadProduct: jest.fn(),
18 save: jest.fn(),
19 saveProduct: jest.fn(),
20 }));
21 jest.mock('@quick-edit/lib/block-source-cache', () => ({
22 invalidateBlockSource: jest.fn(),
23 }));
24 jest.mock('@quick-edit/lib/dom', () => ({
25 splice: jest.fn(),
26 }));
27 jest.mock('@quick-edit/lib/insights', () => ({
28 track: jest.fn(),
29 }));
30 jest.mock('@quick-edit/lib/modal-root', () => ({
31 mountModal: (...args) => mockMountModal(...args),
32 closeModal: (...args) => mockCloseModal(...args),
33 }));
34 jest.mock('@quick-edit/state/store', () => ({
35 useQuickEditStore: (selector) =>
36 selector({ selected: mockSelected, clearSelected: mockClearSelected }),
37 }));
38 jest.mock('@quick-edit/state/undo', () => ({
39 pushUndo: jest.fn(),
40 }));
41
42 jest.mock('@quick-edit/components/BlockTextEditor', () => ({
43 BlockTextEditor: (props) => (
44 <div
45 data-testid="block-text-editor"
46 data-block-type={props.selected.blockType}
47 />
48 ),
49 }));
50
51 const stubModal = (name) => (props) => (
52 <div
53 data-testid={name}
54 data-props={JSON.stringify(
55 Object.fromEntries(
56 Object.entries(props).filter(([, v]) => typeof v !== 'function'),
57 ),
58 )}
59 />
60 );
61
62 jest.mock('@quick-edit/components/modals/AiImagePickerModal', () => ({
63 AiImagePickerModal: stubModal('AiImagePickerModal'),
64 }));
65 jest.mock('@quick-edit/components/modals/NavItemModal', () => ({
66 NavItemModal: stubModal('NavItemModal'),
67 }));
68 jest.mock('@quick-edit/components/modals/ProductPriceModal', () => ({
69 ProductPriceModal: stubModal('ProductPriceModal'),
70 }));
71 jest.mock('@quick-edit/components/modals/ProductTextModal', () => ({
72 ProductTextModal: stubModal('ProductTextModal'),
73 }));
74 jest.mock('@quick-edit/components/modals/SiteIdentityModal', () => ({
75 SiteIdentityModal: stubModal('SiteIdentityModal'),
76 }));
77 jest.mock('@quick-edit/components/modals/SocialLinkModal', () => ({
78 SocialLinkModal: stubModal('SocialLinkModal'),
79 }));
80 jest.mock('@quick-edit/components/modals/UnsplashImagePickerModal', () => ({
81 UnsplashImagePickerModal: stubModal('UnsplashImagePickerModal'),
82 }));
83 jest.mock('@quick-edit/components/modals/WPFormsFieldModal', () => ({
84 WPFormsFieldModal: stubModal('WPFormsFieldModal'),
85 }));
86
87 beforeEach(() => {
88 jest.clearAllMocks();
89 mockSelected = null;
90 document.body.innerHTML = '';
91 });
92
93 const importComponent = () => require('@quick-edit/components/InlineEditor');
94
95 const renderWithSelected = (sel) => {
96 mockSelected = sel;
97 const { InlineEditor } = importComponent();
98 return render(<InlineEditor />);
99 };
100
101 const mountedElement = () => {
102 expect(mockMountModal).toHaveBeenCalledTimes(1);
103 return mockMountModal.mock.calls[0][0];
104 };
105
106 describe('InlineEditor — null selection', () => {
107 it('renders nothing and mounts no modal when selected is null', () => {
108 const { container } = renderWithSelected(null);
109 expect(container.firstChild).toBeNull();
110 expect(mockMountModal).not.toHaveBeenCalled();
111 });
112 });
113
114 describe('InlineEditor — text routing (TEXT_STRATEGIES)', () => {
115 const cases = ['core/paragraph', 'core/heading', 'core/button'];
116 for (const blockType of cases) {
117 it(`renders BlockTextEditor for ${blockType}`, () => {
118 const { getByTestId } = renderWithSelected({
119 blockType,
120 blockId: 'b-1',
121 el: document.createElement('p'),
122 source: { kind: 'post', id: 1 },
123 });
124 expect(
125 getByTestId('block-text-editor').getAttribute('data-block-type'),
126 ).toBe(blockType);
127 expect(mockMountModal).not.toHaveBeenCalled();
128 });
129 }
130 });
131
132 describe('InlineEditor — image routing (PICKER_STRATEGIES)', () => {
133 const cases = ['core/image', 'core/cover', 'product:image'];
134 for (const blockType of cases) {
135 it(`renders the ImagePicker menu for ${blockType} (no modal mount)`, () => {
136 renderWithSelected({
137 blockType,
138 blockId: 'b-img',
139 el: document.createElement('div'),
140 source: { kind: 'post', id: 1 },
141 });
142 expect(
143 document.getElementById('extendify-quick-edit-image-menu'),
144 ).not.toBeNull();
145 expect(mockMountModal).not.toHaveBeenCalled();
146 });
147 }
148 });
149
150 describe('InlineEditor — image menu drops from the hover bar', () => {
151 // positionBar (lib/hover-bar.js) places the pill above OR below the image
152 // depending on viewport room and leaves it mounted for picker blocks. The
153 // menu must follow the pill, not assume it sits at the image's top edge.
154 const rect = (top, bottom, left = 400, width = 200) => ({
155 top,
156 bottom,
157 left,
158 right: left + width,
159 width,
160 height: bottom - top,
161 });
162 const imageEl = (r) => {
163 const el = document.createElement('div');
164 el.getBoundingClientRect = () => r;
165 return el;
166 };
167 const mountBar = (r) => {
168 const bar = document.createElement('div');
169 bar.className = 'extendify-quick-edit-bar';
170 bar.getBoundingClientRect = () => r;
171 document.body.appendChild(bar);
172 };
173
174 beforeEach(() => {
175 Object.defineProperty(window, 'innerWidth', {
176 value: 1280,
177 configurable: true,
178 });
179 Object.defineProperty(window, 'innerHeight', {
180 value: 900,
181 configurable: true,
182 });
183 });
184
185 it('drops below the pill when the bar sits below the image', () => {
186 mountBar(rect(608, 644)); // pill pushed below a top-anchored image
187 renderWithSelected({
188 blockType: 'core/image',
189 blockId: 'b-below',
190 el: imageEl(rect(40, 600, 300, 600)),
191 source: { kind: 'post', id: 1 },
192 });
193 const menu = document.getElementById('extendify-quick-edit-image-menu');
194 // bar.bottom (644) + GAP (6) — NOT the image's top edge (40).
195 expect(menu.style.top).toBe('650px');
196 });
197
198 it('drops just under the pill when the bar sits above the image', () => {
199 mountBar(rect(256, 292));
200 renderWithSelected({
201 blockType: 'core/image',
202 blockId: 'b-above',
203 el: imageEl(rect(300, 700, 300, 600)),
204 source: { kind: 'post', id: 1 },
205 });
206 const menu = document.getElementById('extendify-quick-edit-image-menu');
207 expect(menu.style.top).toBe('298px'); // 292 + 6
208 });
209
210 it('falls back to the image top edge when no bar is mounted', () => {
211 renderWithSelected({
212 blockType: 'core/image',
213 blockId: 'b-nobar',
214 el: imageEl(rect(120, 520, 300, 600)),
215 source: { kind: 'post', id: 1 },
216 });
217 const menu = document.getElementById('extendify-quick-edit-image-menu');
218 expect(menu.style.top).toBe('120px');
219 });
220 });
221
222 describe('InlineEditor — modal routing (MODAL_BLOCK_TYPES)', () => {
223 it('site-title → SiteIdentityModal with kind="title"', () => {
224 renderWithSelected({ blockType: 'core/site-title' });
225 const el = mountedElement();
226 expect(el.props.kind).toBe('title');
227 });
228
229 it('site-tagline → SiteIdentityModal with kind="tagline"', () => {
230 renderWithSelected({ blockType: 'core/site-tagline' });
231 const el = mountedElement();
232 expect(el.props.kind).toBe('tagline');
233 });
234
235 it('site-logo → SiteIdentityModal with kind="logo"', () => {
236 renderWithSelected({ blockType: 'core/site-logo' });
237 const el = mountedElement();
238 expect(el.props.kind).toBe('logo');
239 });
240
241 it('social-link → SocialLinkModal carrying the selection', () => {
242 const sel = { blockType: 'core/social-link', blockId: 'sl-1' };
243 renderWithSelected(sel);
244 const el = mountedElement();
245 expect(el.props.selected).toBe(sel);
246 });
247
248 it('navigation-link → NavItemModal', () => {
249 const sel = { blockType: 'core/navigation-link', blockId: 'n-1' };
250 renderWithSelected(sel);
251 const el = mountedElement();
252 expect(el.props.selected).toBe(sel);
253 });
254
255 it('navigation-submenu also routes to NavItemModal', () => {
256 const sel = { blockType: 'core/navigation-submenu', blockId: 'n-2' };
257 renderWithSelected(sel);
258 const el = mountedElement();
259 expect(el.props.selected).toBe(sel);
260 });
261
262 it('product:name → ProductTextModal with productId + productField', () => {
263 renderWithSelected({
264 blockType: 'product:name',
265 productId: 99,
266 productField: 'name',
267 });
268 const el = mountedElement();
269 expect(el.props.productId).toBe(99);
270 expect(el.props.field).toBe('name');
271 });
272
273 it('product:short_description → ProductTextModal with productId + productField', () => {
274 renderWithSelected({
275 blockType: 'product:short_description',
276 productId: 99,
277 productField: 'short_description',
278 });
279 const el = mountedElement();
280 expect(el.props.productId).toBe(99);
281 expect(el.props.field).toBe('short_description');
282 });
283
284 it('product:price → ProductPriceModal with productId', () => {
285 renderWithSelected({ blockType: 'product:price', productId: 7 });
286 const el = mountedElement();
287 expect(el.props.productId).toBe(7);
288 });
289
290 it('wpforms:field → WPFormsFieldModal with formId + fieldId', () => {
291 renderWithSelected({
292 blockType: 'wpforms:field',
293 formId: 12,
294 fieldId: 'field-3',
295 });
296 const el = mountedElement();
297 expect(el.props.formId).toBe(12);
298 expect(el.props.fieldId).toBe('field-3');
299 });
300
301 it('routes nothing through BlockTextEditor or the image menu while a modal is active', () => {
302 const { container } = renderWithSelected({
303 blockType: 'core/site-title',
304 });
305 expect(container.firstChild).toBeNull();
306 expect(
307 document.getElementById('extendify-quick-edit-image-menu'),
308 ).toBeNull();
309 });
310 });
311
312 describe('InlineEditor — modal onAfterSave reload contract', () => {
313 // jsdom locks window.location.reload (configurable: false, writable: false),
314 // same wrinkle the undo test calls out. Spy on console.error so the jsdom
315 // "Not implemented: navigation" log doesn't trip @wordpress/jest-console,
316 // then key off whether that log appeared to detect the reload call.
317 let errorSpy;
318 beforeEach(() => {
319 errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
320 });
321 afterEach(() => {
322 errorSpy.mockRestore();
323 });
324
325 const navigationLogged = () =>
326 errorSpy.mock.calls.some((c) =>
327 c.some((arg) => String(arg).includes('Not implemented: navigation')),
328 );
329
330 it('onAfterSave(true) closes the modal, clears selection, then attempts reload', () => {
331 renderWithSelected({ blockType: 'core/site-title' });
332 const el = mountedElement();
333 el.props.onAfterSave(true);
334 expect(mockCloseModal).toHaveBeenCalledWith(false);
335 expect(mockClearSelected).toHaveBeenCalledTimes(1);
336 expect(navigationLogged()).toBe(true);
337 });
338
339 it('onAfterSave(false) closes the modal + clears selection but skips reload', () => {
340 renderWithSelected({ blockType: 'core/site-title' });
341 const el = mountedElement();
342 el.props.onAfterSave(false);
343 expect(mockCloseModal).toHaveBeenCalledWith(false);
344 expect(mockClearSelected).toHaveBeenCalledTimes(1);
345 expect(navigationLogged()).toBe(false);
346 });
347 });
348
349 describe('InlineEditor — modal unmount cleanup', () => {
350 it('unmount closes any active modal (closeModal(false))', () => {
351 const { unmount } = renderWithSelected({ blockType: 'core/site-title' });
352 expect(mockCloseModal).not.toHaveBeenCalled();
353 unmount();
354 expect(mockCloseModal).toHaveBeenCalledWith(false);
355 });
356 });
357
358 describe('InlineEditor — unsupported block type', () => {
359 it('renders the UnsupportedNotice error pill with the block type', () => {
360 renderWithSelected({
361 blockType: 'core/something-weird',
362 el: document.createElement('div'),
363 });
364 expect(document.body.textContent).toContain('core/something-weird');
365 expect(mockMountModal).not.toHaveBeenCalled();
366 });
367
368 // The error pill announces assertively
369 // and its × dismiss control carries an accessible name.
370 it('error pill is role="alert" with an accessible Dismiss button', () => {
371 renderWithSelected({
372 blockType: 'core/something-weird',
373 el: document.createElement('div'),
374 });
375 const pill = document.querySelector('[role="alert"]');
376 expect(pill).not.toBeNull();
377 expect(pill.querySelector('button').getAttribute('aria-label')).toBe(
378 'Dismiss',
379 );
380 });
381 });
382