@akinon/pz-theme 2.0.58-rc.0 → 2.0.58

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.
@@ -0,0 +1,316 @@
1
+ import { act, fireEvent, render, screen } from '@testing-library/react';
2
+ import React from 'react';
3
+
4
+ jest.mock('next/navigation', () => ({
5
+ usePathname: () => '/',
6
+ useSearchParams: () => new URLSearchParams()
7
+ }));
8
+
9
+ jest.mock('next-auth/react', () => ({
10
+ useSession: () => ({ status: 'unauthenticated' })
11
+ }));
12
+
13
+ jest.mock('@akinon/next/hooks/use-localization', () => ({
14
+ useLocalization: () => ({ locale: 'en', currency: 'USD' })
15
+ }));
16
+
17
+ /**
18
+ * Stood in for so the assertions can be about WHICH region the canvas opens
19
+ * for the modal. The real wrapper fetches, registers and listens; none of that
20
+ * is what this file is pinning.
21
+ */
22
+ jest.mock('../../theme-placeholder-wrapper', () => ({
23
+ __esModule: true,
24
+ default: ({ slug }: { slug: string }) => (
25
+ <div data-testid="composition-region" data-slug={slug} />
26
+ )
27
+ }));
28
+
29
+ import ModalPreviewHost from '../modal-preview-host';
30
+ import ThemeModalMount from '../theme-modal-mount';
31
+ import { __resetModalPreviewForTests } from '../use-modal-preview';
32
+ import { __resetEditorModalsForTests } from '../use-editor-modals';
33
+ import {
34
+ __resetOverlayBusForTests,
35
+ openModal,
36
+ registerModalHost
37
+ } from '../overlay-bus';
38
+ import { __resetEditorBridgeForTests } from '../../editor-bridge';
39
+ import { readThemeModals, ThemeModal } from '../modal-registry';
40
+
41
+ const EDITOR_ORIGIN = 'http://localhost:5174';
42
+
43
+ const mkModal = (overrides: Record<string, unknown> = {}): ThemeModal =>
44
+ readThemeModals([{ id: 'newsletter', name: 'Newsletter', ...overrides }])[0];
45
+
46
+ const hold = (modal: ThemeModal) =>
47
+ act(() => {
48
+ fireEvent(
49
+ window,
50
+ new MessageEvent('message', {
51
+ origin: EDITOR_ORIGIN,
52
+ data: {
53
+ type: 'PREVIEW_OPEN_MODAL',
54
+ data: { modalId: modal.id, modal }
55
+ }
56
+ })
57
+ );
58
+ });
59
+
60
+ const release = () =>
61
+ act(() => {
62
+ fireEvent(
63
+ window,
64
+ new MessageEvent('message', {
65
+ origin: EDITOR_ORIGIN,
66
+ data: { type: 'PREVIEW_CLOSE_MODAL' }
67
+ })
68
+ );
69
+ });
70
+
71
+ beforeEach(() => {
72
+ __resetModalPreviewForTests();
73
+ __resetEditorModalsForTests();
74
+ __resetOverlayBusForTests();
75
+ __resetEditorBridgeForTests();
76
+ });
77
+
78
+ /**
79
+ * A designer creates a modal and has to be able to SEE it. Nothing on the page
80
+ * opens it — its trigger may be on another route, or be a timer — and it
81
+ * usually does not exist on the server yet, because it was made seconds ago
82
+ * and nothing has been saved.
83
+ *
84
+ * So the canvas draws it from what the editor pushes. These pin that the
85
+ * pushing works, that it keeps working while the modal's chrome is edited, and
86
+ * that the canvas surface never behaves like a real one.
87
+ */
88
+ describe('the modal a designer is composing', () => {
89
+ it('shows nothing until the editor asks for one', () => {
90
+ render(<ModalPreviewHost />);
91
+
92
+ expect(document.querySelector('[data-theme-modal]')).toBeNull();
93
+ });
94
+
95
+ it('draws a modal the server has never heard of', () => {
96
+ // The whole first-modal case: no theme-config entry exists anywhere.
97
+ render(<ModalPreviewHost />);
98
+
99
+ hold(mkModal());
100
+
101
+ expect(screen.getByRole('dialog')).toHaveAttribute(
102
+ 'aria-label',
103
+ 'Newsletter'
104
+ );
105
+ // Its interior is an ordinary editable region, addressed by the modal's
106
+ // own slug — that is what lets sections be dropped into it.
107
+ expect(screen.getByTestId('composition-region')).toHaveAttribute(
108
+ 'data-slug',
109
+ 'modal-newsletter'
110
+ );
111
+ });
112
+
113
+ it('follows a width edit without a save or a reload', () => {
114
+ render(<ModalPreviewHost />);
115
+
116
+ hold(mkModal());
117
+ expect(screen.getByRole('dialog').style.maxWidth).toBe('640px');
118
+
119
+ // The designer drags the width field.
120
+ hold(mkModal({ chrome: { width: 420 } }));
121
+
122
+ expect(screen.getByRole('dialog').style.maxWidth).toBe('420px');
123
+ });
124
+
125
+ it('follows a presentation edit without a save or a reload', () => {
126
+ render(<ModalPreviewHost />);
127
+
128
+ hold(mkModal());
129
+ // Centred: sized by max-width, pinned to no side.
130
+ expect(screen.getByRole('dialog').style.right).toBe('');
131
+
132
+ // The designer switches "Arrives As" to a drawer.
133
+ hold(mkModal({ presentation: 'drawer' }));
134
+
135
+ expect(screen.getByRole('dialog').style.right).toBe('0px');
136
+ });
137
+
138
+ it('lets go when the editor goes back to a page', () => {
139
+ render(<ModalPreviewHost />);
140
+
141
+ hold(mkModal());
142
+ release();
143
+
144
+ expect(document.querySelector('[data-theme-modal]')).toBeNull();
145
+ });
146
+
147
+ it('never takes the canvas over', () => {
148
+ render(<ModalPreviewHost />);
149
+
150
+ hold(mkModal());
151
+
152
+ const panel = screen.getByRole('dialog');
153
+ // The canvas is a workspace: claiming aria-modal would hide the page the
154
+ // designer is working on from the accessibility tree.
155
+ expect(panel).not.toHaveAttribute('aria-modal');
156
+ expect(document.body.style.overflow).not.toBe('hidden');
157
+ });
158
+
159
+ it('survives the backdrop and close button it is meant to be styling', () => {
160
+ render(<ModalPreviewHost />);
161
+
162
+ hold(mkModal());
163
+
164
+ fireEvent.click(screen.getByLabelText('Close'));
165
+ fireEvent.click(
166
+ document.querySelector('[data-part="overlay"]') as Element
167
+ );
168
+
169
+ // Still there: a designer colouring the backdrop must not lose the surface
170
+ // by clicking it.
171
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
172
+ });
173
+ });
174
+
175
+ /**
176
+ * Both renderers can draw the same modal, so exactly one of them must.
177
+ */
178
+ describe('the saved mount and the canvas host together', () => {
179
+ const renderBoth = (modal: ThemeModal) =>
180
+ render(
181
+ <>
182
+ <ThemeModalMount modal={modal}>
183
+ <div data-testid="composition-region" data-slug={modal.slug} />
184
+ </ThemeModalMount>
185
+ <ModalPreviewHost />
186
+ </>
187
+ );
188
+
189
+ it('opens the modal exactly once while the editor holds it', () => {
190
+ registerModalHost();
191
+ const modal = mkModal();
192
+ renderBoth(modal);
193
+
194
+ hold(modal);
195
+
196
+ /**
197
+ * Two surfaces would mean two `ThemePlaceholderWrapper`s claiming one
198
+ * slug, so the region registers twice and the editor shows the modal's
199
+ * contents doubled. The saved mount stands down; the host draws.
200
+ */
201
+ expect(screen.getAllByTestId('composition-region')).toHaveLength(1);
202
+ expect(
203
+ document.querySelectorAll('[data-theme-modal="newsletter"]')
204
+ ).toHaveLength(1);
205
+ expect(
206
+ document.querySelector('[data-theme-modal-state="preview"]')
207
+ ).not.toBeNull();
208
+ });
209
+
210
+ it('hands the modal back to the saved mount when the editor lets go', () => {
211
+ registerModalHost();
212
+ const modal = mkModal();
213
+ renderBoth(modal);
214
+
215
+ hold(modal);
216
+ release();
217
+
218
+ // Closed for a shopper, and no longer held: nothing on screen at all.
219
+ expect(document.querySelector('[data-theme-modal]')).toBeNull();
220
+ });
221
+ });
222
+
223
+ /**
224
+ * The loop that actually matters: create a modal, point a trigger at it, press
225
+ * the trigger. The saved mounts can only offer modals the backend has stored,
226
+ * so before the editor's registry was read this did nothing at all for a modal
227
+ * made minutes ago — and nothing told the difference between a modal that does
228
+ * not exist yet and a trigger that is broken.
229
+ */
230
+ describe('triggering a modal that was never saved', () => {
231
+ const pushEditorTheme = (modals: unknown[]) =>
232
+ act(() => {
233
+ fireEvent(
234
+ window,
235
+ new MessageEvent('message', {
236
+ origin: EDITOR_ORIGIN,
237
+ data: { type: 'UPDATE_THEME', data: { theme: { modals } } }
238
+ })
239
+ );
240
+ });
241
+
242
+ it('opens from the bus once the editor has pushed it', () => {
243
+ registerModalHost();
244
+ render(<ModalPreviewHost />);
245
+
246
+ // Nothing saved: the server rendered no mount for this id.
247
+ pushEditorTheme([{ id: 'newsletter', name: 'Newsletter' }]);
248
+
249
+ act(() => {
250
+ openModal('newsletter');
251
+ });
252
+
253
+ expect(screen.getByRole('dialog')).toHaveAttribute(
254
+ 'aria-label',
255
+ 'Newsletter'
256
+ );
257
+ expect(screen.getByTestId('composition-region')).toHaveAttribute(
258
+ 'data-slug',
259
+ 'modal-newsletter'
260
+ );
261
+ });
262
+
263
+ it('behaves like a real modal, not the inert editing surface', () => {
264
+ registerModalHost();
265
+ render(<ModalPreviewHost />);
266
+ pushEditorTheme([{ id: 'newsletter', name: 'Newsletter' }]);
267
+
268
+ act(() => {
269
+ openModal('newsletter');
270
+ });
271
+
272
+ // A shopper pressing close must actually close it — the inertness is for
273
+ // the surface being composed, not for one a trigger opened.
274
+ fireEvent.click(screen.getByLabelText('Close'));
275
+
276
+ expect(screen.queryByRole('dialog')).toBeNull();
277
+ });
278
+
279
+ it('stays shut until something opens it', () => {
280
+ registerModalHost();
281
+ render(<ModalPreviewHost />);
282
+ pushEditorTheme([{ id: 'newsletter', name: 'Newsletter' }]);
283
+
284
+ expect(document.querySelector('[data-theme-modal]')).toBeNull();
285
+ });
286
+
287
+ it('hands the modal back to the saved mount when the editor detaches', () => {
288
+ registerModalHost();
289
+ const modal = mkModal();
290
+ render(
291
+ <>
292
+ <ThemeModalMount modal={modal}>
293
+ <div data-testid="composition-region" data-slug={modal.slug} />
294
+ </ThemeModalMount>
295
+ <ModalPreviewHost />
296
+ </>
297
+ );
298
+
299
+ pushEditorTheme([{ id: 'newsletter', name: 'Newsletter' }]);
300
+ act(() => {
301
+ openModal('newsletter');
302
+ });
303
+
304
+ // One surface, not two: the saved mount stands down for a modal the editor
305
+ // knows, or the region would register twice.
306
+ expect(screen.getAllByTestId('composition-region')).toHaveLength(1);
307
+
308
+ // Editor drops the modal from its registry; the saved copy takes over.
309
+ pushEditorTheme([]);
310
+
311
+ expect(screen.getAllByTestId('composition-region')).toHaveLength(1);
312
+ expect(
313
+ document.querySelectorAll('[data-theme-modal="newsletter"]')
314
+ ).toHaveLength(1);
315
+ });
316
+ });
@@ -0,0 +1,205 @@
1
+ import { readThemeModals } from '../modal-registry';
2
+ import {
3
+ isModalSlug,
4
+ modalIdFromSlug,
5
+ modalSlug
6
+ } from '../../chrome/placeholder-slugs';
7
+ import { resetDevWarnCacheForTests } from '../../utils/dev-warn';
8
+
9
+ describe('modal slug family', () => {
10
+ it('derives a slug from an addressable id', () => {
11
+ expect(modalSlug('newsletter')).toBe('modal-newsletter');
12
+ expect(modalSlug('size-guide-2')).toBe('modal-size-guide-2');
13
+ });
14
+
15
+ it('refuses an id it cannot address rather than cleaning it', () => {
16
+ // Cleaning would map several ids onto one slug, and for a published
17
+ // content key that is a silent overwrite, not a tidy-up.
18
+ expect(modalSlug('News Letter')).toBeNull();
19
+ expect(modalSlug('news_letter')).toBeNull();
20
+ expect(modalSlug('-leading-hyphen')).toBeNull();
21
+ expect(modalSlug('')).toBeNull();
22
+ expect(modalSlug('ÜstBilgi')).toBeNull();
23
+ });
24
+
25
+ it('round-trips a slug back to its id', () => {
26
+ expect(modalIdFromSlug('modal-newsletter')).toBe('newsletter');
27
+ expect(modalIdFromSlug('page-about')).toBeNull();
28
+ expect(modalIdFromSlug('modal-')).toBeNull();
29
+ expect(isModalSlug('modal-newsletter')).toBe(true);
30
+ expect(isModalSlug('header')).toBe(false);
31
+ });
32
+ });
33
+
34
+ describe('readThemeModals', () => {
35
+ beforeEach(() => resetDevWarnCacheForTests());
36
+
37
+ it('reads the JSON string the editor writes', () => {
38
+ expect(
39
+ readThemeModals(
40
+ JSON.stringify([{ id: 'newsletter', name: 'Newsletter popup' }])
41
+ )
42
+ ).toEqual([
43
+ {
44
+ id: 'newsletter',
45
+ name: 'Newsletter popup',
46
+ slug: 'modal-newsletter',
47
+ presentation: 'modal',
48
+ chrome: {
49
+ ariaLabel: 'Newsletter popup',
50
+ showClose: true,
51
+ closeLabel: 'Close',
52
+ showBackdrop: true,
53
+ closeOnBackdrop: true,
54
+ closeOnEscape: true,
55
+ width: 640,
56
+ panelSide: 'right',
57
+ sheetHeight: 60,
58
+ lockScroll: true
59
+ },
60
+ autoTrigger: undefined,
61
+ visibilityRules: undefined
62
+ }
63
+ ]);
64
+ });
65
+
66
+ describe('chrome', () => {
67
+ const chromeOf = (chrome: unknown) =>
68
+ readThemeModals([{ id: 'a', name: 'A', chrome }])[0].chrome;
69
+
70
+ it('names the dialog after the modal when no label was given', () => {
71
+ // A dialog with no accessible name is announced as just "dialog", which
72
+ // tells a screen reader user nothing about what took over their page.
73
+ expect(chromeOf(undefined).ariaLabel).toBe('A');
74
+ expect(chromeOf({ ariaLabel: 'Sign up' }).ariaLabel).toBe('Sign up');
75
+ });
76
+
77
+ it('forces the close button back on when nothing else could dismiss', () => {
78
+ expect(
79
+ chromeOf({
80
+ showClose: false,
81
+ closeOnBackdrop: false,
82
+ closeOnEscape: false
83
+ }).showClose
84
+ ).toBe(true);
85
+ });
86
+
87
+ it('honours the close button being off while another way out remains', () => {
88
+ expect(
89
+ chromeOf({ showClose: false, closeOnBackdrop: true }).showClose
90
+ ).toBe(false);
91
+ expect(
92
+ chromeOf({
93
+ showClose: false,
94
+ closeOnBackdrop: false,
95
+ closeOnEscape: true
96
+ }).showClose
97
+ ).toBe(false);
98
+ });
99
+
100
+ it('clamps geometry into a usable range', () => {
101
+ expect(chromeOf({ width: 10 }).width).toBe(240);
102
+ expect(chromeOf({ width: 99999 }).width).toBe(1600);
103
+ expect(chromeOf({ width: 'wide' }).width).toBe(640);
104
+ expect(chromeOf({ sheetHeight: 5 }).sheetHeight).toBe(20);
105
+ expect(chromeOf({ sheetHeight: 300 }).sheetHeight).toBe(100);
106
+ });
107
+
108
+ it('refuses an unknown panel side', () => {
109
+ expect(chromeOf({ panelSide: 'top' }).panelSide).toBe('right');
110
+ expect(chromeOf({ panelSide: 'left' }).panelSide).toBe('left');
111
+ });
112
+ });
113
+
114
+ it('reads an already-parsed array', () => {
115
+ expect(readThemeModals([{ id: 'a', name: 'A' }])).toHaveLength(1);
116
+ });
117
+
118
+ it('answers empty for anything unusable', () => {
119
+ const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
120
+
121
+ expect(readThemeModals(undefined)).toEqual([]);
122
+ expect(readThemeModals('')).toEqual([]);
123
+ expect(readThemeModals('{not json')).toEqual([]);
124
+ expect(readThemeModals({ id: 'a' })).toEqual([]);
125
+
126
+ warn.mockRestore();
127
+ });
128
+
129
+ it('drops only the malformed entries, keeping the rest', () => {
130
+ const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
131
+
132
+ const modals = readThemeModals([
133
+ { id: 'good', name: 'Good' },
134
+ { id: 'Not Addressable', name: 'Bad' },
135
+ null,
136
+ 'nonsense',
137
+ { id: 'also-good', name: 'Also good' }
138
+ ]);
139
+
140
+ expect(modals.map((modal) => modal.id)).toEqual(['good', 'also-good']);
141
+
142
+ warn.mockRestore();
143
+ });
144
+
145
+ it('collapses duplicate ids to the first, since both claim one slug', () => {
146
+ const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
147
+
148
+ const modals = readThemeModals([
149
+ { id: 'dupe', name: 'First' },
150
+ { id: 'dupe', name: 'Second' }
151
+ ]);
152
+
153
+ expect(modals).toHaveLength(1);
154
+ expect(modals[0].name).toBe('First');
155
+ expect(warn).toHaveBeenCalledWith(
156
+ expect.stringContaining('Duplicate modal id')
157
+ );
158
+
159
+ warn.mockRestore();
160
+ });
161
+
162
+ it('falls back to the id when a modal was never named', () => {
163
+ expect(readThemeModals([{ id: 'unnamed' }])[0].name).toBe('unnamed');
164
+ });
165
+
166
+ it('refuses an unknown presentation instead of passing it through', () => {
167
+ expect(
168
+ readThemeModals([{ id: 'a', presentation: 'lightbox' }])[0].presentation
169
+ ).toBe('modal');
170
+ });
171
+
172
+ describe('auto-trigger', () => {
173
+ it('is absent when the event is none or missing', () => {
174
+ expect(readThemeModals([{ id: 'a' }])[0].autoTrigger).toBeUndefined();
175
+ expect(
176
+ readThemeModals([{ id: 'a', autoTrigger: { event: 'none' } }])[0]
177
+ .autoTrigger
178
+ ).toBeUndefined();
179
+ });
180
+
181
+ it('defaults an unspecified frequency to once per session', () => {
182
+ // Never "always": a welcome popup on every navigation is how this
183
+ // feature gets switched off a week after launch.
184
+ expect(
185
+ readThemeModals([
186
+ { id: 'a', autoTrigger: { event: 'delay', delayMs: 3000 } }
187
+ ])[0].autoTrigger
188
+ ).toEqual({
189
+ event: 'delay',
190
+ delayMs: 3000,
191
+ scrollPercent: undefined,
192
+ anchorId: undefined,
193
+ frequency: 'once-per-session'
194
+ });
195
+ });
196
+
197
+ it('drops a negative or non-numeric delay', () => {
198
+ const trigger = readThemeModals([
199
+ { id: 'a', autoTrigger: { event: 'delay', delayMs: -1 } }
200
+ ])[0].autoTrigger;
201
+
202
+ expect(trigger?.delayMs).toBeUndefined();
203
+ });
204
+ });
205
+ });