@akinon/pz-theme 2.0.157 → 2.0.158-rc.14
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.
- package/CHANGELOG.md +44 -0
- package/package.json +4 -4
- package/src/__tests__/editor-locale-bridge.test.tsx +32 -1
- package/src/__tests__/row-scope.test.tsx +118 -0
- package/src/blocks/__tests__/order-detail-return.test.tsx +70 -5
- package/src/blocks/__tests__/order-line-actions.test.tsx +54 -0
- package/src/blocks/__tests__/order-list-rows.test.tsx +112 -0
- package/src/blocks/__tests__/text-block.test.tsx +34 -6
- package/src/blocks/item-template.tsx +25 -19
- package/src/blocks/order-detail-block.tsx +155 -112
- package/src/blocks/order-line-actions.ts +45 -0
- package/src/blocks/order-list-block.tsx +25 -2
- package/src/blocks/row-scope.tsx +35 -0
- package/src/blocks/text-block.tsx +6 -2
- package/src/chrome/__tests__/page-context-builders.test.ts +14 -1
- package/src/chrome/page-context.ts +4 -0
- package/src/custom/README.md +120 -219
- package/src/custom/__tests__/use-theme-setting.test.tsx +97 -0
- package/src/custom/collect.ts +4 -9
- package/src/custom/property-types.ts +15 -15
- package/src/custom/use-theme-setting.ts +21 -5
- package/src/editor-locale-bridge.tsx +39 -11
- package/src/theme-placeholder-wrapper.tsx +3 -0
- package/src/utils/__tests__/order-action-rules.test.tsx +96 -0
- package/src/utils/__tests__/sanitize-rich-text.test.ts +80 -0
- package/src/utils/order-action-rules.ts +115 -0
- package/src/utils/sanitize-rich-text.ts +215 -0
|
@@ -33,19 +33,20 @@ export type PropertyType = (typeof CUSTOM_PROPERTY_TYPES)[number];
|
|
|
33
33
|
* The subset whose value provably survives the full round trip — panel write,
|
|
34
34
|
* persistence, and resolution back into a prop.
|
|
35
35
|
*
|
|
36
|
-
* The
|
|
37
|
-
* guessed:
|
|
36
|
+
* The two excluded members are excluded for reasons that were verified, not
|
|
37
|
+
* guessed: `multiselect` and `localetext` are stored UNBUCKETED by the editor
|
|
38
|
+
* (`theme-basic-properties.tsx:1547-1548` passes arrays and locale objects
|
|
39
|
+
* through without the per-breakpoint wrapper). `getResponsiveValue` then
|
|
40
|
+
* rejects an array with a devWarn and returns the fallback, and a locale
|
|
41
|
+
* object has no `desktop` key so it does too. They can never deliver a
|
|
42
|
+
* value until a per-type resolver exists.
|
|
38
43
|
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* - `richtext` has no safe renderer here. The allowlist sanitizer this repo
|
|
46
|
-
* already owns parses by assigning to `innerHTML` on a live element, so the
|
|
47
|
-
* sink is its first act — and pz-theme renders server-side, where there is
|
|
48
|
-
* no `document` at all.
|
|
44
|
+
* `richtext` is the one type with such a resolver. The editor's rich text
|
|
45
|
+
* control writes per language (`{ tr: '<p>…</p>', en: … }`, unbucketed, like
|
|
46
|
+
* `localetext`), so `useThemeSetting` reads the page's language out of the
|
|
47
|
+
* map before the breakpoint read, then passes the html through
|
|
48
|
+
* `sanitizeRichText`, a DOM-free allowlist, so a block can hand it to
|
|
49
|
+
* `dangerouslySetInnerHTML` on the server as well as the client.
|
|
49
50
|
*
|
|
50
51
|
* Declaring one of these throws at define time rather than shipping a control
|
|
51
52
|
* that silently shows the default forever.
|
|
@@ -59,12 +60,11 @@ export const SUPPORTED_PROPERTY_TYPES: readonly PropertyType[] = [
|
|
|
59
60
|
'color',
|
|
60
61
|
'image',
|
|
61
62
|
'file',
|
|
63
|
+
'richtext',
|
|
62
64
|
'typography',
|
|
63
65
|
'icon'
|
|
64
66
|
];
|
|
65
67
|
|
|
66
|
-
export const isSupportedPropertyType = (
|
|
67
|
-
type: unknown
|
|
68
|
-
): type is PropertyType =>
|
|
68
|
+
export const isSupportedPropertyType = (type: unknown): type is PropertyType =>
|
|
69
69
|
typeof type === 'string' &&
|
|
70
70
|
(SUPPORTED_PROPERTY_TYPES as readonly string[]).includes(type);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { getResponsiveValue } from '../utils';
|
|
4
|
+
import { sanitizeRichText } from '../utils/sanitize-rich-text';
|
|
4
5
|
import { devWarn } from '../utils/dev-warn';
|
|
5
6
|
import {
|
|
6
7
|
isLocalizedValue,
|
|
@@ -238,6 +239,11 @@ export function useThemeSetting<
|
|
|
238
239
|
|
|
239
240
|
const defaults = splitDefault(declaration, valueLocale);
|
|
240
241
|
|
|
242
|
+
const finish = (value: unknown): TValue =>
|
|
243
|
+
(declaration.type === 'richtext'
|
|
244
|
+
? sanitizeRichText(typeof value === 'string' ? value : '')
|
|
245
|
+
: value) as TValue;
|
|
246
|
+
|
|
241
247
|
if (!context) {
|
|
242
248
|
// A missing block context is never an error on its own: a developer
|
|
243
249
|
// previewing the component in a storybook or a unit test should see the
|
|
@@ -254,7 +260,7 @@ export function useThemeSetting<
|
|
|
254
260
|
`unreachable and the declared default is used instead. Render this ` +
|
|
255
261
|
`component through a block registered with registerCustomBlocks().`
|
|
256
262
|
);
|
|
257
|
-
return defaults.page
|
|
263
|
+
return finish(defaults.page);
|
|
258
264
|
}
|
|
259
265
|
|
|
260
266
|
// Reported on every render rather than once from an effect: the collection
|
|
@@ -271,7 +277,17 @@ export function useThemeSetting<
|
|
|
271
277
|
// attribute keys that were never declared. `node` rather than `block` so a
|
|
272
278
|
// setting declared inside a custom SECTION reads the section's own
|
|
273
279
|
// properties, which is where the editor writes it.
|
|
274
|
-
const
|
|
280
|
+
const authored = context.node.properties?.[key];
|
|
281
|
+
|
|
282
|
+
// The rich text control writes per language — `{ tr: '<p>…</p>', en: … }` —
|
|
283
|
+
// and the editor stores that map as it is, with no breakpoint key: the same
|
|
284
|
+
// shape that keeps `localetext` out (see `property-types.ts`). Read the
|
|
285
|
+
// page's language out of it first; what is left is a string or a breakpoint
|
|
286
|
+
// map, which the responsive read below already understands.
|
|
287
|
+
const stored =
|
|
288
|
+
declaration.type === 'richtext'
|
|
289
|
+
? resolveLocalizedValue(authored, valueLocale)
|
|
290
|
+
: authored;
|
|
275
291
|
|
|
276
292
|
// The sentinel again, for the same reason: handing `getResponsiveValue` an
|
|
277
293
|
// undefined fallback would resolve an undeclared default to `''`, so a
|
|
@@ -280,15 +296,15 @@ export function useThemeSetting<
|
|
|
280
296
|
const resolved = getResponsiveValue(stored, breakpoint, NOT_AUTHORED);
|
|
281
297
|
|
|
282
298
|
if (resolved === NOT_AUTHORED) {
|
|
283
|
-
return resolveAuthoredDefault(defaults.page, breakpoint)
|
|
299
|
+
return finish(resolveAuthoredDefault(defaults.page, breakpoint));
|
|
284
300
|
}
|
|
285
301
|
|
|
286
302
|
// The default-language text as the editor seeded it when the block was
|
|
287
303
|
// placed, never changed since: still the author's copy, so it follows the
|
|
288
304
|
// page's language. Anything the merchant typed is theirs and shows as typed.
|
|
289
305
|
if (defaults.perLocale && resolved === defaults.base) {
|
|
290
|
-
return defaults.page
|
|
306
|
+
return finish(defaults.page);
|
|
291
307
|
}
|
|
292
308
|
|
|
293
|
-
return resolved
|
|
309
|
+
return finish(resolved);
|
|
294
310
|
}
|
|
@@ -4,14 +4,28 @@ import { useEffect } from 'react';
|
|
|
4
4
|
import { useLocalization } from '@akinon/next/hooks';
|
|
5
5
|
|
|
6
6
|
import { isTrustedEditorEvent, postToEditor } from './editor-bridge';
|
|
7
|
+
import { useValueLocale } from './utils/localized-value';
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* The storefront half of the editor's Language dropdown.
|
|
10
11
|
*
|
|
11
12
|
* The editor starts that dropdown with English only and fills it from the
|
|
12
|
-
* `LOCALE_UPDATE` frame the preview posts (`{ locale, locales }`);
|
|
13
|
-
* merchant picks another language it posts `LOCALE_UPDATE` back and
|
|
14
|
-
* preview to follow.
|
|
13
|
+
* `LOCALE_UPDATE` frame the preview posts (`{ locale, locales, defaultLocale }`);
|
|
14
|
+
* when the merchant picks another language it posts `LOCALE_UPDATE` back and
|
|
15
|
+
* expects the preview to follow.
|
|
16
|
+
*
|
|
17
|
+
* The frame carries the storefront's default language as well. The editor's
|
|
18
|
+
* per-language text controls store a plain string in that language and a map
|
|
19
|
+
* in every other, so an editor that never heard it assumed English and gave
|
|
20
|
+
* every Turkish-default store a stray `en` key (ZERO-5296).
|
|
21
|
+
*
|
|
22
|
+
* It is posted BEFORE the document reports ready (`IFRAME_READY`): the bridge
|
|
23
|
+
* is a child of the placeholder wrapper, whose own effect posts that frame,
|
|
24
|
+
* and React runs a child's effects first. The editor relies on the order — it
|
|
25
|
+
* holds every per-language write until the preview is ready, and takes
|
|
26
|
+
* "ready without languages" to mean a storefront that will never send them.
|
|
27
|
+
*
|
|
28
|
+
* For a long time the only code answering either direction
|
|
15
29
|
* was the app-template's `LanguageSelect` — brand code. A brand whose header no
|
|
16
30
|
* longer renders that component (a header composed through ChromeSlot, a
|
|
17
31
|
* custom header section, a project older than the template) left the editor
|
|
@@ -54,6 +68,7 @@ export interface EditorLocaleOption {
|
|
|
54
68
|
interface LocaleState {
|
|
55
69
|
locale: string;
|
|
56
70
|
locales: EditorLocaleOption[];
|
|
71
|
+
defaultLocale?: string;
|
|
57
72
|
setLocale: (locale: string) => void;
|
|
58
73
|
}
|
|
59
74
|
|
|
@@ -86,15 +101,22 @@ const toEditorLocales = (locales: unknown): EditorLocaleOption[] =>
|
|
|
86
101
|
: entry.value
|
|
87
102
|
}));
|
|
88
103
|
|
|
89
|
-
const announcementKey = (
|
|
90
|
-
|
|
104
|
+
const announcementKey = ({
|
|
105
|
+
locale,
|
|
106
|
+
locales,
|
|
107
|
+
defaultLocale
|
|
108
|
+
}: Omit<LocaleState, 'setLocale'>) =>
|
|
109
|
+
JSON.stringify([locale, locales, defaultLocale ?? null]);
|
|
91
110
|
|
|
92
|
-
const announce = ({ locale, locales }: LocaleState) => {
|
|
93
|
-
const key = announcementKey(locale, locales);
|
|
111
|
+
const announce = ({ locale, locales, defaultLocale }: LocaleState) => {
|
|
112
|
+
const key = announcementKey({ locale, locales, defaultLocale });
|
|
94
113
|
if (key === lastAnnounced) return;
|
|
95
114
|
lastAnnounced = key;
|
|
96
115
|
|
|
97
|
-
postToEditor({
|
|
116
|
+
postToEditor({
|
|
117
|
+
type: 'LOCALE_UPDATE',
|
|
118
|
+
data: { locale, locales, defaultLocale }
|
|
119
|
+
});
|
|
98
120
|
};
|
|
99
121
|
|
|
100
122
|
const handleEditorMessage = (event: MessageEvent) => {
|
|
@@ -115,7 +137,7 @@ const handleEditorMessage = (event: MessageEvent) => {
|
|
|
115
137
|
|
|
116
138
|
// The editor is already showing `requested`. Recording it as announced keeps
|
|
117
139
|
// the switch from being echoed back when the new locale reaches the page.
|
|
118
|
-
lastAnnounced = announcementKey(
|
|
140
|
+
lastAnnounced = announcementKey({ ...current, locale: requested });
|
|
119
141
|
current.setLocale(requested);
|
|
120
142
|
};
|
|
121
143
|
|
|
@@ -126,6 +148,7 @@ const handleEditorMessage = (event: MessageEvent) => {
|
|
|
126
148
|
*/
|
|
127
149
|
export function EditorLocaleBridge(): null {
|
|
128
150
|
const { locale, locales, setLocale } = useLocalization();
|
|
151
|
+
const { defaultLocale } = useValueLocale();
|
|
129
152
|
|
|
130
153
|
useEffect(() => {
|
|
131
154
|
if (!isFramed()) return;
|
|
@@ -147,9 +170,14 @@ export function EditorLocaleBridge(): null {
|
|
|
147
170
|
useEffect(() => {
|
|
148
171
|
if (!isFramed() || !locale) return;
|
|
149
172
|
|
|
150
|
-
current = {
|
|
173
|
+
current = {
|
|
174
|
+
locale,
|
|
175
|
+
locales: toEditorLocales(locales),
|
|
176
|
+
defaultLocale,
|
|
177
|
+
setLocale
|
|
178
|
+
};
|
|
151
179
|
announce(current);
|
|
152
|
-
}, [locale, locales, setLocale]);
|
|
180
|
+
}, [locale, locales, defaultLocale, setLocale]);
|
|
153
181
|
|
|
154
182
|
return null;
|
|
155
183
|
}
|
|
@@ -1166,6 +1166,9 @@ export default function ThemePlaceholderWrapper({
|
|
|
1166
1166
|
Tells the editor which languages this storefront has and follows its
|
|
1167
1167
|
language switch. Every placeholder renders one; they share a single
|
|
1168
1168
|
listener and a single announcement (see editor-locale-bridge.tsx).
|
|
1169
|
+
A child of this wrapper on purpose: its effect, and so its frame,
|
|
1170
|
+
runs before the effect below that posts IFRAME_READY, and the editor
|
|
1171
|
+
counts on hearing the languages before the ready report.
|
|
1169
1172
|
*/}
|
|
1170
1173
|
<EditorLocaleBridge />
|
|
1171
1174
|
{themeBackgroundCSS && (
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { act, render, screen } from '@testing-library/react';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
__resetOrderActionRulesForTests,
|
|
6
|
+
allowsLineReturn,
|
|
7
|
+
allowsOrderCancel,
|
|
8
|
+
registerOrderActionRules,
|
|
9
|
+
useOrderActionRules
|
|
10
|
+
} from '../order-action-rules';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The brand's say over the order actions a storefront offers (ZERO-5302):
|
|
14
|
+
* asked only about what commerce already allows; with no rule, commerce's
|
|
15
|
+
* flags decide.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const line = (status: string) => ({ id: 1, status: { value: status } });
|
|
19
|
+
const order = { id: 7, number: 'O-7' };
|
|
20
|
+
|
|
21
|
+
afterEach(() => __resetOrderActionRulesForTests());
|
|
22
|
+
|
|
23
|
+
describe('order action rules', () => {
|
|
24
|
+
it('leaves the decision to commerce when no brand registered rules', () => {
|
|
25
|
+
expect(allowsLineReturn({}, line('500'), order)).toBe(true);
|
|
26
|
+
expect(allowsOrderCancel({}, order)).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('lets a brand narrow returns and cancellations', () => {
|
|
30
|
+
const rules = {
|
|
31
|
+
returnLine: (item: { status?: { value?: string } | null }) =>
|
|
32
|
+
item.status?.value === '550',
|
|
33
|
+
cancelOrder: () => false
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
expect(allowsLineReturn(rules, line('550'), order)).toBe(true);
|
|
37
|
+
expect(allowsLineReturn(rules, line('500'), order)).toBe(false);
|
|
38
|
+
expect(allowsOrderCancel(rules, order)).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('gives a rule that throws no say', () => {
|
|
42
|
+
const rules = {
|
|
43
|
+
returnLine: () => {
|
|
44
|
+
throw new Error('brand bug');
|
|
45
|
+
},
|
|
46
|
+
cancelOrder: () => {
|
|
47
|
+
throw new Error('brand bug');
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
expect(allowsLineReturn(rules, line('550'), order)).toBe(true);
|
|
52
|
+
expect(allowsOrderCancel(rules, order)).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('renders a reader again when the rules are registered or dropped', () => {
|
|
56
|
+
const Reader = () => {
|
|
57
|
+
const rules = useOrderActionRules();
|
|
58
|
+
return (
|
|
59
|
+
<span data-testid="reader">
|
|
60
|
+
{allowsLineReturn(rules, line('500'), order) ? 'offers' : 'withholds'}
|
|
61
|
+
</span>
|
|
62
|
+
);
|
|
63
|
+
};
|
|
64
|
+
render(<Reader />);
|
|
65
|
+
expect(screen.getByTestId('reader')).toHaveTextContent('offers');
|
|
66
|
+
|
|
67
|
+
let unregister = () => {};
|
|
68
|
+
act(() => {
|
|
69
|
+
unregister = registerOrderActionRules({ returnLine: () => false });
|
|
70
|
+
});
|
|
71
|
+
expect(screen.getByTestId('reader')).toHaveTextContent('withholds');
|
|
72
|
+
|
|
73
|
+
act(() => unregister());
|
|
74
|
+
expect(screen.getByTestId('reader')).toHaveTextContent('offers');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('keeps rules registered later when an older registration unregisters', () => {
|
|
78
|
+
const first = registerOrderActionRules({ returnLine: () => false });
|
|
79
|
+
const second = { returnLine: () => true };
|
|
80
|
+
registerOrderActionRules(second);
|
|
81
|
+
|
|
82
|
+
first();
|
|
83
|
+
|
|
84
|
+
expect(allowsLineReturn(second, line('500'), order)).toBe(true);
|
|
85
|
+
const Reader = () => {
|
|
86
|
+
const rules = useOrderActionRules();
|
|
87
|
+
return (
|
|
88
|
+
<span data-testid="current">
|
|
89
|
+
{rules === second ? 'second' : 'other'}
|
|
90
|
+
</span>
|
|
91
|
+
);
|
|
92
|
+
};
|
|
93
|
+
render(<Reader />);
|
|
94
|
+
expect(screen.getByTestId('current')).toHaveTextContent('second');
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { sanitizeRichText } from '../sanitize-rich-text';
|
|
2
|
+
|
|
3
|
+
describe('sanitizeRichText', () => {
|
|
4
|
+
it('keeps the markup the editor writes', () => {
|
|
5
|
+
const html =
|
|
6
|
+
'<p>Sipariş <b>verirseniz</b> <i>yarın</i> <u>kapınızda</u></p><ul><li>bir</li></ul><h2>Başlık</h2><br>';
|
|
7
|
+
|
|
8
|
+
expect(sanitizeRichText(html)).toBe(
|
|
9
|
+
'<p>Sipariş <b>verirseniz</b> <i>yarın</i> <u>kapınızda</u></p><ul><li>bir</li></ul><h2>Başlık</h2><br />'
|
|
10
|
+
);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('drops scripts, event handlers and unknown tags but keeps their text', () => {
|
|
14
|
+
const html =
|
|
15
|
+
'<p onclick="x()">a<script>alert(1)</script><custom>b</custom><img src=x onerror=alert(1)></p>';
|
|
16
|
+
|
|
17
|
+
expect(sanitizeRichText(html)).toBe('<p>ab</p>');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('keeps only safe links and forces rel on new-tab links', () => {
|
|
21
|
+
expect(
|
|
22
|
+
sanitizeRichText(
|
|
23
|
+
'<a href="javascript:alert(1)">x</a><a href="https://gant.com" target="_blank">y</a><a href="/sss">z</a>'
|
|
24
|
+
)
|
|
25
|
+
).toBe(
|
|
26
|
+
'<a>x</a><a href="https://gant.com" target="_blank" rel="noopener noreferrer">y</a><a href="/sss">z</a>'
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('keeps only the colour and emphasis styles', () => {
|
|
31
|
+
expect(
|
|
32
|
+
sanitizeRichText(
|
|
33
|
+
'<span style="color: #9a2320; position: fixed; background: url(x)">a</span>'
|
|
34
|
+
)
|
|
35
|
+
).toBe('<span style="color: #9a2320">a</span>');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('leaves text, entities and placeholders untouched', () => {
|
|
39
|
+
expect(sanitizeRichText('{{countdown}} & {{add_day|4}}')).toBe(
|
|
40
|
+
'{{countdown}} & {{add_day|4}}'
|
|
41
|
+
);
|
|
42
|
+
expect(sanitizeRichText('')).toBe('');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('reads tag names the way the browser does, so no element slips through as text', () => {
|
|
46
|
+
expect(
|
|
47
|
+
sanitizeRichText(
|
|
48
|
+
'<x_y onmouseover=alert(1) style=position:fixed;inset:0>a</x_y>'
|
|
49
|
+
)
|
|
50
|
+
).toBe('a');
|
|
51
|
+
expect(sanitizeRichText('<svg/onload=alert(1)>b')).toBe('b');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('never lets an unfinished tag run into the markup that follows', () => {
|
|
55
|
+
expect(sanitizeRichText('<img src=x onerror=alert(1)')).toBe(
|
|
56
|
+
'<img src=x onerror=alert(1)'
|
|
57
|
+
);
|
|
58
|
+
expect(sanitizeRichText('<p>a</p><b')).toBe('<p>a</p><b');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('escapes every < that does not become a kept tag', () => {
|
|
62
|
+
expect(sanitizeRichText('a < b <3 c')).toBe('a < b <3 c');
|
|
63
|
+
expect(sanitizeRichText('<!DOCTYPE html><?xml version="1.0"?>x')).toBe(
|
|
64
|
+
'<!DOCTYPE html><?xml version="1.0"?>x'
|
|
65
|
+
);
|
|
66
|
+
expect(sanitizeRichText('</>')).toBe('</>');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('reads a quoted attribute value holding > as part of the tag', () => {
|
|
70
|
+
expect(sanitizeRichText('<a href="/x" title="a>b">t</a>')).toBe(
|
|
71
|
+
'<a href="/x">t</a>'
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('drops an unterminated comment or script to the end', () => {
|
|
76
|
+
expect(sanitizeRichText('a<!-- b')).toBe('a');
|
|
77
|
+
expect(sanitizeRichText('<!-->x')).toBe('x');
|
|
78
|
+
expect(sanitizeRichText('a<script>alert(1)')).toBe('a');
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { useSyncExternalStore } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Brand hook for the order actions a storefront offers (ZERO-5302).
|
|
5
|
+
*
|
|
6
|
+
* Commerce says what it allows: a line it will take back is flagged
|
|
7
|
+
* `is_refundable`, an order it will cancel `is_cancellable`. Whether the
|
|
8
|
+
* storefront offers that return or that cancellation is a brand decision on
|
|
9
|
+
* top — from which statuses, for how long after the order, whether the action
|
|
10
|
+
* is switched on at all — so the order blocks never learn it. A brand
|
|
11
|
+
* registers its rules from its own code (the pattern of the search-term
|
|
12
|
+
* transform), and the blocks ask them only about what commerce already
|
|
13
|
+
* allows. No registration, or a rule left out, and commerce's flags decide.
|
|
14
|
+
* A rule that throws has no say: a brand bug must not take the page down.
|
|
15
|
+
*
|
|
16
|
+
* Rules may be registered again when what they depend on arrives (a brand
|
|
17
|
+
* reading its settings from a widget, say); every block reading them through
|
|
18
|
+
* `useOrderActionRules` renders again.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export interface OrderActionLine {
|
|
22
|
+
id?: number | string;
|
|
23
|
+
status?: { value?: string; label?: string } | null;
|
|
24
|
+
created_date?: string | null;
|
|
25
|
+
is_refundable?: boolean;
|
|
26
|
+
is_cancellable?: boolean;
|
|
27
|
+
[key: string]: unknown;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface OrderActionOrder {
|
|
31
|
+
id?: number | string;
|
|
32
|
+
number?: string;
|
|
33
|
+
created_date?: string | null;
|
|
34
|
+
status?: { value?: string; label?: string } | null;
|
|
35
|
+
[key: string]: unknown;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface OrderActionRules {
|
|
39
|
+
/**
|
|
40
|
+
* Whether a return may start from this line. Asked only about a line
|
|
41
|
+
* commerce flags refundable and that has no request open on it.
|
|
42
|
+
*/
|
|
43
|
+
returnLine?: (line: OrderActionLine, order: OrderActionOrder) => boolean;
|
|
44
|
+
/**
|
|
45
|
+
* Whether the order may be cancelled from the storefront. Asked only about
|
|
46
|
+
* an order commerce flags cancellable.
|
|
47
|
+
*/
|
|
48
|
+
cancelOrder?: (order: OrderActionOrder) => boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const NO_RULES: OrderActionRules = {};
|
|
52
|
+
|
|
53
|
+
let current: OrderActionRules = NO_RULES;
|
|
54
|
+
const listeners = new Set<() => void>();
|
|
55
|
+
|
|
56
|
+
const notify = () => listeners.forEach((listener) => listener());
|
|
57
|
+
|
|
58
|
+
/** Register the brand's rules. Returns an unregister function. */
|
|
59
|
+
export const registerOrderActionRules = (
|
|
60
|
+
rules: OrderActionRules
|
|
61
|
+
): (() => void) => {
|
|
62
|
+
current = rules;
|
|
63
|
+
notify();
|
|
64
|
+
return () => {
|
|
65
|
+
if (current !== rules) return;
|
|
66
|
+
current = NO_RULES;
|
|
67
|
+
notify();
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const subscribe = (listener: () => void) => {
|
|
72
|
+
listeners.add(listener);
|
|
73
|
+
return () => {
|
|
74
|
+
listeners.delete(listener);
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const getSnapshot = () => current;
|
|
79
|
+
const getServerSnapshot = () => NO_RULES;
|
|
80
|
+
|
|
81
|
+
/** The rules in force; the caller renders again when they are replaced. */
|
|
82
|
+
export const useOrderActionRules = (): OrderActionRules =>
|
|
83
|
+
useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
|
84
|
+
|
|
85
|
+
/** Whether the brand lets a return start from a line commerce allows. */
|
|
86
|
+
export const allowsLineReturn = (
|
|
87
|
+
rules: OrderActionRules,
|
|
88
|
+
line: OrderActionLine,
|
|
89
|
+
order: OrderActionOrder
|
|
90
|
+
): boolean => {
|
|
91
|
+
if (!rules.returnLine) return true;
|
|
92
|
+
try {
|
|
93
|
+
return rules.returnLine(line, order) !== false;
|
|
94
|
+
} catch {
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** Whether the brand lets an order commerce allows be cancelled. */
|
|
100
|
+
export const allowsOrderCancel = (
|
|
101
|
+
rules: OrderActionRules,
|
|
102
|
+
order: OrderActionOrder
|
|
103
|
+
): boolean => {
|
|
104
|
+
if (!rules.cancelOrder) return true;
|
|
105
|
+
try {
|
|
106
|
+
return rules.cancelOrder(order) !== false;
|
|
107
|
+
} catch {
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/** Test-only: drop the registered rules between cases. */
|
|
113
|
+
export const __resetOrderActionRulesForTests = (): void => {
|
|
114
|
+
current = NO_RULES;
|
|
115
|
+
};
|