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 / modals / NavItemModal.test.jsx

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

265 lines 7.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 // NavItemModal saves a nav item's label + url through one of two endpoints
2 // depending on `selected.source.kind`:
3 // - kind !== 'wp-navigation' → SaveController via save() (inline items)
4 // - kind === 'wp-navigation' → saveWpNavigationItem (ref-based items)
5 // The undo entry shape mirrors the FORWARD endpoint so performUndo dispatches
6 // on the *Replay flag. Empty URL keeps the existing href.
7
8 import { act, fireEvent, render } from '@testing-library/react';
9
10 const mockSave = jest.fn();
11 const mockSaveWpNavigationItem = jest.fn();
12 const mockTrack = jest.fn();
13 const mockPushUndo = jest.fn();
14
15 jest.mock('@quick-edit/lib/api', () => ({
16 save: (...args) => mockSave(...args),
17 saveWpNavigationItem: (...args) => mockSaveWpNavigationItem(...args),
18 }));
19 jest.mock('@quick-edit/lib/cmd-enter-save', () => ({
20 useCmdEnterSave: jest.fn(),
21 }));
22 jest.mock('@quick-edit/lib/insights', () => ({
23 track: (...args) => mockTrack(...args),
24 }));
25 jest.mock('@quick-edit/lib/modal-root', () => ({
26 closeModal: jest.fn(),
27 }));
28 jest.mock('@quick-edit/state/undo', () => ({
29 pushUndo: (...args) => mockPushUndo(...args),
30 }));
31
32 // LinkControl is heavy. We use the TextControl fallback by exporting undefined.
33 jest.mock('@wordpress/block-editor', () => ({
34 __experimentalLinkControl: undefined,
35 }));
36
37 jest.mock('@wordpress/components', () => ({
38 Modal: ({ title, onRequestClose, children }) => (
39 <div role="dialog" aria-label={title}>
40 <button
41 type="button"
42 data-testid="modal-close"
43 onClick={onRequestClose}
44 />
45 {children}
46 </div>
47 ),
48 Button: ({ children, onClick, disabled, isBusy }) => (
49 <button
50 type="button"
51 onClick={onClick}
52 disabled={disabled}
53 data-busy={String(!!isBusy)}
54 >
55 {children}
56 </button>
57 ),
58 Notice: ({ children, status }) => (
59 <div role="alert" data-status={status}>
60 {children}
61 </div>
62 ),
63 TextControl: ({ label, value, onChange, placeholder }) => (
64 <label>
65 {label}
66 <input
67 aria-label={label}
68 value={value || ''}
69 placeholder={placeholder}
70 onChange={(e) => onChange(e.target.value)}
71 />
72 </label>
73 ),
74 }));
75
76 const importComponent = () =>
77 require('@quick-edit/components/modals/NavItemModal');
78
79 const liveEl = (label, url) => {
80 const wrap = document.createElement('div');
81 const a = document.createElement('a');
82 a.setAttribute('href', url);
83 const labelEl = document.createElement('span');
84 labelEl.classList.add('wp-block-navigation-item__label');
85 labelEl.textContent = label;
86 a.appendChild(labelEl);
87 wrap.appendChild(a);
88 document.body.appendChild(wrap);
89 return wrap;
90 };
91
92 const fillURL = (value) => {
93 fireEvent.change(document.querySelector('input[aria-label="URL"]'), {
94 target: { value },
95 });
96 };
97
98 const fillLabel = (value) => {
99 fireEvent.change(document.querySelector('input[aria-label="Label"]'), {
100 target: { value },
101 });
102 };
103
104 const clickByText = (text) => {
105 const btn = Array.from(document.querySelectorAll('button')).find(
106 (b) => b.textContent === text,
107 );
108 fireEvent.click(btn);
109 };
110
111 beforeEach(() => {
112 jest.clearAllMocks();
113 document.body.innerHTML = '';
114 });
115
116 describe('NavItemModal — inline (non-wp-navigation) save', () => {
117 const renderModal = (overrides = {}) => {
118 const el = liveEl('Old Label', 'https://example.test/old');
119 const selected = {
120 el,
121 blockId: 'n-1',
122 blockType: 'core/navigation-link',
123 source: { kind: 'post', id: 1 },
124 ...overrides,
125 };
126 const onAfterSave = jest.fn();
127 const { NavItemModal } = importComponent();
128 render(<NavItemModal selected={selected} onAfterSave={onAfterSave} />);
129 return { selected, onAfterSave };
130 };
131
132 it('seeds label + (cleared) URL field from the live element', () => {
133 renderModal();
134 expect(document.querySelector('input[aria-label="Label"]').value).toBe(
135 'Old Label',
136 );
137 // URL is intentionally blank to start — "empty URL keeps the existing link"
138 expect(document.querySelector('input[aria-label="URL"]').value).toBe('');
139 });
140
141 it('save() with both patches when both fields changed; undo carries old values', async () => {
142 mockSave.mockResolvedValue({});
143 const { onAfterSave } = renderModal();
144 fillLabel('New Label');
145 fillURL('https://example.test/new');
146 await act(async () => clickByText('Save'));
147 expect(mockSave).toHaveBeenCalledWith({
148 source: { kind: 'post', id: 1 },
149 blockId: 'n-1',
150 blockType: 'core/navigation-link',
151 fingerprint: { text: 'Old Label' },
152 patches: [
153 { fieldKey: 'label', value: 'New Label' },
154 { fieldKey: 'url', value: 'https://example.test/new' },
155 ],
156 });
157 expect(mockPushUndo).toHaveBeenCalledWith({
158 kind: 'nav-item',
159 source: { kind: 'post', id: 1 },
160 blockId: 'n-1',
161 blockType: 'core/navigation-link',
162 patches: [
163 { fieldKey: 'label', value: 'Old Label' },
164 { fieldKey: 'url', value: 'https://example.test/old' },
165 ],
166 });
167 expect(mockTrack).toHaveBeenCalledWith('save', { kind: 'nav_item' });
168 expect(onAfterSave).toHaveBeenCalledWith(true);
169 });
170
171 it('label-only change skips the url patch (empty URL keeps existing href)', async () => {
172 mockSave.mockResolvedValue({});
173 renderModal();
174 fillLabel('Renamed');
175 await act(async () => clickByText('Save'));
176 expect(mockSave).toHaveBeenCalledWith(
177 expect.objectContaining({
178 patches: [{ fieldKey: 'label', value: 'Renamed' }],
179 }),
180 );
181 });
182
183 it('no patches → onAfterSave(false); no save', async () => {
184 const { onAfterSave } = renderModal();
185 await act(async () => clickByText('Save'));
186 expect(mockSave).not.toHaveBeenCalled();
187 expect(onAfterSave).toHaveBeenCalledWith(false);
188 });
189
190 it('save rejecting → save_failed + error Notice', async () => {
191 mockSave.mockRejectedValueOnce(new Error('rejected'));
192 renderModal();
193 fillLabel('Renamed');
194 await act(async () => clickByText('Save'));
195 expect(mockTrack).toHaveBeenCalledWith('save_failed', { kind: 'nav_item' });
196 expect(document.querySelector('[role="alert"]')?.textContent).toMatch(
197 /Sorry, something went wrong/i,
198 );
199 });
200 });
201
202 describe('NavItemModal — wp-navigation (ref-based) save', () => {
203 it('routes through saveWpNavigationItem with navPostId + itemIndex + patches', async () => {
204 mockSaveWpNavigationItem.mockResolvedValue({});
205 const el = liveEl('About', 'https://example.test/about');
206 const selected = {
207 el,
208 blockId: 'n-2',
209 blockType: 'core/navigation-link',
210 source: { kind: 'wp-navigation', id: 42 },
211 navPostId: 42,
212 itemIndex: 3,
213 };
214 const onAfterSave = jest.fn();
215 const { NavItemModal } = importComponent();
216 render(<NavItemModal selected={selected} onAfterSave={onAfterSave} />);
217 fillLabel('About Us');
218 fillURL('https://example.test/about-us');
219 await act(async () => clickByText('Save'));
220 expect(mockSave).not.toHaveBeenCalled();
221 expect(mockSaveWpNavigationItem).toHaveBeenCalledWith({
222 navPostId: 42,
223 itemIndex: 3,
224 blockType: 'core/navigation-link',
225 fingerprint: { text: 'About' },
226 patches: [
227 { fieldKey: 'label', value: 'About Us' },
228 { fieldKey: 'url', value: 'https://example.test/about-us' },
229 ],
230 });
231 expect(mockPushUndo).toHaveBeenCalledWith({
232 kind: 'nav-item',
233 navReplay: true,
234 navPostId: 42,
235 itemIndex: 3,
236 blockType: 'core/navigation-link',
237 patches: [
238 { fieldKey: 'label', value: 'About' },
239 { fieldKey: 'url', value: 'https://example.test/about' },
240 ],
241 });
242 expect(onAfterSave).toHaveBeenCalledWith(true);
243 });
244 });
245
246 describe('NavItemModal — close', () => {
247 it('Cancel calls onAfterSave(false)', () => {
248 const el = liveEl('x', 'https://example.test');
249 const selected = {
250 el,
251 blockId: 'n-1',
252 blockType: 'core/navigation-link',
253 source: { kind: 'post', id: 1 },
254 };
255 const onAfterSave = jest.fn();
256 const { NavItemModal } = importComponent();
257 render(<NavItemModal selected={selected} onAfterSave={onAfterSave} />);
258 const cancel = Array.from(document.querySelectorAll('button')).find(
259 (b) => b.textContent === 'Cancel',
260 );
261 fireEvent.click(cancel);
262 expect(onAfterSave).toHaveBeenCalledWith(false);
263 });
264 });
265