@akinon/pz-theme 2.0.96-rc.0 → 2.0.97-beta.0
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 +15 -33
- package/package.json +4 -4
- package/src/__tests__/registry-parity.test.ts +11 -11
- package/src/blocks/__tests__/carousel-item-block.test.tsx +346 -0
- package/src/blocks/__tests__/container-block.test.tsx +149 -0
- package/src/blocks/__tests__/hover-showcase-item-block.test.tsx +315 -0
- package/src/blocks/__tests__/icon-button-block.test.tsx +231 -0
- package/src/blocks/__tests__/icon-group-block.test.tsx +186 -0
- package/src/blocks/__tests__/icon-source.test.tsx +226 -0
- package/src/blocks/__tests__/responsive-array.test.ts +77 -0
- package/src/blocks/__tests__/social-glyphs.test.tsx +108 -0
- package/src/blocks/__tests__/social-icons-list-block.test.tsx +243 -0
- package/src/blocks/__tests__/value-props-list-block.test.tsx +231 -0
- package/src/blocks/block-renderer-registry.tsx +21 -0
- package/src/blocks/carousel-item-block.tsx +168 -0
- package/src/blocks/container-block.tsx +80 -0
- package/src/blocks/hover-showcase-item-block.tsx +232 -0
- package/src/blocks/icon-block.tsx +82 -121
- package/src/blocks/icon-button-block.tsx +180 -0
- package/src/blocks/icon-group-block.tsx +119 -0
- package/src/blocks/icon-group-colored-block.tsx +47 -0
- package/src/blocks/icon-source.tsx +311 -0
- package/src/blocks/responsive-array.ts +54 -0
- package/src/blocks/social-glyphs.tsx +131 -0
- package/src/blocks/social-icons-list-block.tsx +159 -0
- package/src/blocks/social-links-block.tsx +1 -125
- package/src/blocks/value-props-list-block.tsx +152 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render } from '@testing-library/react';
|
|
3
|
+
|
|
4
|
+
// Stub the recursive ThemeBlock so a child is a countable marker.
|
|
5
|
+
jest.mock('../../theme-block', () => ({
|
|
6
|
+
__esModule: true,
|
|
7
|
+
default: ({ block }: { block: { id: string } }) => (
|
|
8
|
+
<span data-testid="tb" data-id={block.id} />
|
|
9
|
+
)
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
import IconGroupBlock from '../icon-group-block';
|
|
13
|
+
import IconGroupColoredBlock from '../icon-group-colored-block';
|
|
14
|
+
import { ThemeSettingsProvider } from '../../theme-settings-context';
|
|
15
|
+
|
|
16
|
+
const child = (id: string, hidden = false, order = 0) => ({
|
|
17
|
+
id,
|
|
18
|
+
type: 'icon',
|
|
19
|
+
label: id,
|
|
20
|
+
value: '',
|
|
21
|
+
properties: {},
|
|
22
|
+
styles: {},
|
|
23
|
+
order,
|
|
24
|
+
hidden
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const storedBlock = (over: Record<string, unknown> = {}) => ({
|
|
28
|
+
id: 'ig-1',
|
|
29
|
+
type: 'icon-group',
|
|
30
|
+
label: 'Icon Group',
|
|
31
|
+
value: '',
|
|
32
|
+
// The descriptor declares `properties: []`; only the shared ones are stored.
|
|
33
|
+
properties: { htmlId: '', actionType: 'none' },
|
|
34
|
+
styles: { size: { desktop: '24' } },
|
|
35
|
+
order: 0,
|
|
36
|
+
hidden: false,
|
|
37
|
+
blocks: [child('c1'), child('c2')],
|
|
38
|
+
...over
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const props = (over: Record<string, unknown> = {}) =>
|
|
42
|
+
({
|
|
43
|
+
block: storedBlock(),
|
|
44
|
+
placeholderId: 'p',
|
|
45
|
+
sectionId: 's',
|
|
46
|
+
isDesigner: false,
|
|
47
|
+
...over
|
|
48
|
+
} as any);
|
|
49
|
+
|
|
50
|
+
const markers = (container: HTMLElement) =>
|
|
51
|
+
Array.from(container.querySelectorAll('[data-testid="tb"]'));
|
|
52
|
+
|
|
53
|
+
describe('IconGroupBlock — children', () => {
|
|
54
|
+
it('renders its children through the shared composed-children path', () => {
|
|
55
|
+
const { container } = render(<IconGroupBlock {...props()} />);
|
|
56
|
+
|
|
57
|
+
expect(markers(container).map((m) => m.getAttribute('data-id'))).toEqual([
|
|
58
|
+
'c1',
|
|
59
|
+
'c2'
|
|
60
|
+
]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('drops a hidden child for the shopper and keeps it in the designer', () => {
|
|
64
|
+
const block = storedBlock({ blocks: [child('c1'), child('c2', true)] });
|
|
65
|
+
|
|
66
|
+
expect(
|
|
67
|
+
markers(render(<IconGroupBlock {...props({ block })} />).container)
|
|
68
|
+
).toHaveLength(1);
|
|
69
|
+
expect(
|
|
70
|
+
markers(
|
|
71
|
+
render(<IconGroupBlock {...props({ block, isDesigner: true })} />)
|
|
72
|
+
.container
|
|
73
|
+
)
|
|
74
|
+
).toHaveLength(2);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('stays transparent to layout so the wrapper gap reaches the icons', () => {
|
|
78
|
+
const { container } = render(<IconGroupBlock {...props()} />);
|
|
79
|
+
const host = container.querySelector('[data-icon-group]') as HTMLElement;
|
|
80
|
+
|
|
81
|
+
expect(host.getAttribute('style')).toContain('display: contents');
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('IconGroupBlock — the size control', () => {
|
|
86
|
+
it('sizes the icons underneath it, scoped to this block alone', () => {
|
|
87
|
+
const { container } = render(<IconGroupBlock {...props()} />);
|
|
88
|
+
const css = container.querySelector('style')!.textContent!;
|
|
89
|
+
|
|
90
|
+
expect(css).toContain('[data-icon-group-id="ig-1"] svg');
|
|
91
|
+
expect(css).toContain('[data-icon-group-id="ig-1"] img');
|
|
92
|
+
expect(css).toContain('width:24px');
|
|
93
|
+
expect(css).toContain('height:24px');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('falls back to the descriptor default when the control is untouched', () => {
|
|
97
|
+
const { container } = render(
|
|
98
|
+
<IconGroupBlock {...props({ block: storedBlock({ styles: {} }) })} />
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
expect(container.querySelector('style')!.textContent).toContain(
|
|
102
|
+
'width:18px'
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('reads the size bucket of the current breakpoint', () => {
|
|
107
|
+
const { container } = render(
|
|
108
|
+
<IconGroupBlock
|
|
109
|
+
{...props({
|
|
110
|
+
block: storedBlock({
|
|
111
|
+
styles: { size: { desktop: '24', mobile: '16' } }
|
|
112
|
+
}),
|
|
113
|
+
currentBreakpoint: 'mobile'
|
|
114
|
+
})}
|
|
115
|
+
/>
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
expect(container.querySelector('style')!.textContent).toContain(
|
|
119
|
+
'width:16px'
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('IconGroupBlock — empty state', () => {
|
|
125
|
+
it('shows a labelled band in the designer when the group holds nothing', () => {
|
|
126
|
+
const { container } = render(
|
|
127
|
+
<IconGroupBlock
|
|
128
|
+
{...props({ block: storedBlock({ blocks: [] }), isDesigner: true })}
|
|
129
|
+
/>
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
expect(container.textContent).toContain('Icon group (no icons)');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('renders nothing for a shopper when the group holds nothing', () => {
|
|
136
|
+
const { container } = render(
|
|
137
|
+
<IconGroupBlock {...props({ block: storedBlock({ blocks: [] }) })} />
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
expect(container.firstChild).toBeNull();
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe('IconGroupColoredBlock — the tint', () => {
|
|
145
|
+
const coloredBlock = (color: unknown) =>
|
|
146
|
+
storedBlock({
|
|
147
|
+
id: 'ig-1',
|
|
148
|
+
type: 'icon-group-colored',
|
|
149
|
+
styles: { size: { desktop: '24' }, color }
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('turns a theme token into a real colour the icons can inherit', () => {
|
|
153
|
+
const { container } = render(
|
|
154
|
+
<ThemeSettingsProvider value={{ primaryColor: '#0a7a5a' }}>
|
|
155
|
+
<IconGroupColoredBlock
|
|
156
|
+
{...props({
|
|
157
|
+
block: coloredBlock({ desktop: 'var(--theme-primary)' })
|
|
158
|
+
})}
|
|
159
|
+
/>
|
|
160
|
+
</ThemeSettingsProvider>
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
const host = container.querySelector('[data-icon-group]') as HTMLElement;
|
|
164
|
+
expect(host.style.color).toBe('rgb(10, 122, 90)');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('leaves a literal colour to the generated rule on the wrapper', () => {
|
|
168
|
+
const { container } = render(
|
|
169
|
+
<IconGroupColoredBlock
|
|
170
|
+
{...props({ block: coloredBlock({ desktop: '#ff0000' }) })}
|
|
171
|
+
/>
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
const host = container.querySelector('[data-icon-group]') as HTMLElement;
|
|
175
|
+
expect(host.style.color).toBe('');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('is the plain group plus a tint — same markup when there is no colour', () => {
|
|
179
|
+
const plain = render(<IconGroupBlock {...props()} />).container.innerHTML;
|
|
180
|
+
const colored = render(
|
|
181
|
+
<IconGroupColoredBlock {...props({ block: coloredBlock(undefined) })} />
|
|
182
|
+
).container.innerHTML;
|
|
183
|
+
|
|
184
|
+
expect(colored).toBe(plain);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render } from '@testing-library/react';
|
|
3
|
+
import {
|
|
4
|
+
ICON_GLYPHS,
|
|
5
|
+
IconGlyph,
|
|
6
|
+
isInlineSvgMarkup,
|
|
7
|
+
resolveIconSource
|
|
8
|
+
} from '../icon-source';
|
|
9
|
+
import IconBlock from '../icon-block';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The keys the editor's picker offers with no upload behind them, copied from
|
|
13
|
+
* pz-dynamic-widget `src/pages/settings/constants.ts` (`DEFAULT_ICON_CATEGORIES`).
|
|
14
|
+
* The repos cannot import from each other, so this list is the seam: when the
|
|
15
|
+
* editor gains a category entry, this test is what says the storefront has
|
|
16
|
+
* nothing to draw for it.
|
|
17
|
+
*/
|
|
18
|
+
const EDITOR_DEFAULT_ICON_KEYS = [
|
|
19
|
+
// navigation
|
|
20
|
+
'home',
|
|
21
|
+
'back-arrow',
|
|
22
|
+
'right-arrow',
|
|
23
|
+
'down-arrow',
|
|
24
|
+
'up-arrow',
|
|
25
|
+
// actions
|
|
26
|
+
'add',
|
|
27
|
+
'remove',
|
|
28
|
+
'close',
|
|
29
|
+
'check',
|
|
30
|
+
'circle-check',
|
|
31
|
+
'filter-icon',
|
|
32
|
+
'sort-icon',
|
|
33
|
+
'picker-icon',
|
|
34
|
+
'spinner',
|
|
35
|
+
// interface
|
|
36
|
+
'search',
|
|
37
|
+
'basket',
|
|
38
|
+
'favourite-empty',
|
|
39
|
+
'favourite-full',
|
|
40
|
+
'gift',
|
|
41
|
+
'share',
|
|
42
|
+
'barcode',
|
|
43
|
+
'image',
|
|
44
|
+
// visibility
|
|
45
|
+
'eye',
|
|
46
|
+
'eye-blocked',
|
|
47
|
+
'user',
|
|
48
|
+
// miscellaneous
|
|
49
|
+
'hamburger',
|
|
50
|
+
'category',
|
|
51
|
+
'trash',
|
|
52
|
+
'globe'
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
const CLOUD = 'cdn.akinoncloud.com';
|
|
56
|
+
|
|
57
|
+
describe('inline icon catalogue', () => {
|
|
58
|
+
it('covers every default key the editor picker offers', () => {
|
|
59
|
+
const missing = EDITOR_DEFAULT_ICON_KEYS.filter((key) => !ICON_GLYPHS[key]);
|
|
60
|
+
expect(missing).toEqual([]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('draws every glyph to the same recipe', () => {
|
|
64
|
+
EDITOR_DEFAULT_ICON_KEYS.forEach((key) => {
|
|
65
|
+
const { container, unmount } = render(<IconGlyph name={key} />);
|
|
66
|
+
const svg = container.querySelector('svg');
|
|
67
|
+
|
|
68
|
+
expect(svg).not.toBeNull();
|
|
69
|
+
expect(svg!.getAttribute('viewBox')).toBe('0 0 24 24');
|
|
70
|
+
expect(svg!.getAttribute('fill')).toBe('none');
|
|
71
|
+
expect(svg!.getAttribute('stroke')).toBe('currentColor');
|
|
72
|
+
expect(svg!.getAttribute('stroke-width')).toBe('1.5');
|
|
73
|
+
expect(svg!.getAttribute('aria-hidden')).toBe('true');
|
|
74
|
+
// A key present in the table but drawing nothing is the failure this set
|
|
75
|
+
// is most likely to ship: it looks fine until it is on a live header.
|
|
76
|
+
expect(svg!.children.length).toBeGreaterThan(0);
|
|
77
|
+
|
|
78
|
+
unmount();
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('leaves fill to the one glyph whose meaning depends on it', () => {
|
|
83
|
+
const filled = EDITOR_DEFAULT_ICON_KEYS.filter((key) => {
|
|
84
|
+
const { container, unmount } = render(<IconGlyph name={key} />);
|
|
85
|
+
const hasFill = !!container.querySelector('svg > [fill], svg > * [fill]');
|
|
86
|
+
unmount();
|
|
87
|
+
return hasFill;
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
expect(filled).toEqual(['favourite-full']);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('accepts a size and renders nothing for an unknown name', () => {
|
|
94
|
+
const { container } = render(<IconGlyph name="basket" size={18} />);
|
|
95
|
+
expect(container.querySelector('svg')!.getAttribute('width')).toBe('18');
|
|
96
|
+
|
|
97
|
+
const unknown = render(<IconGlyph name="not-an-icon" />);
|
|
98
|
+
expect(unknown.container.querySelector('svg')).toBeNull();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('resolveIconSource', () => {
|
|
103
|
+
const originalCloud = process.env.NEXT_PUBLIC_IMAGE_CLOUD_NAME;
|
|
104
|
+
|
|
105
|
+
beforeEach(() => {
|
|
106
|
+
process.env.NEXT_PUBLIC_IMAGE_CLOUD_NAME = CLOUD;
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
afterAll(() => {
|
|
110
|
+
process.env.NEXT_PUBLIC_IMAGE_CLOUD_NAME = originalCloud;
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('answers with inline markup first', () => {
|
|
114
|
+
expect(resolveIconSource('<svg><rect /></svg>')).toEqual({
|
|
115
|
+
kind: 'svg',
|
|
116
|
+
markup: '<svg><rect /></svg>'
|
|
117
|
+
});
|
|
118
|
+
expect(isInlineSvgMarkup('<?xml version="1.0"?><svg />')).toBe(true);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('passes through values that already address something', () => {
|
|
122
|
+
expect(resolveIconSource('data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=')).toEqual(
|
|
123
|
+
{ kind: 'url', url: 'data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=' }
|
|
124
|
+
);
|
|
125
|
+
expect(resolveIconSource('https://example.com/gift.svg')).toEqual({
|
|
126
|
+
kind: 'url',
|
|
127
|
+
url: 'https://example.com/gift.svg'
|
|
128
|
+
});
|
|
129
|
+
// A root-relative path is served by the app itself and must not be sent
|
|
130
|
+
// to the image CDN.
|
|
131
|
+
expect(resolveIconSource('/icons/basket.svg')).toEqual({
|
|
132
|
+
kind: 'url',
|
|
133
|
+
url: '/icons/basket.svg'
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("prefers the brand's uploaded icon over the built-in glyph", () => {
|
|
138
|
+
const themeSettings = {
|
|
139
|
+
icons: { basket: '<svg id="brand-basket"></svg>' }
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
expect(resolveIconSource('basket', themeSettings)).toEqual({
|
|
143
|
+
kind: 'svg',
|
|
144
|
+
markup: '<svg id="brand-basket"></svg>'
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('falls through to the catalogue when the upload is missing or unusable', () => {
|
|
149
|
+
const glyph = resolveIconSource('basket', { icons: { search: null } });
|
|
150
|
+
expect(glyph.kind).toBe('glyph');
|
|
151
|
+
expect(glyph).toMatchObject({ key: 'basket' });
|
|
152
|
+
|
|
153
|
+
// A theme-settings entry that is itself a bare key resolves nothing, so
|
|
154
|
+
// the catalogue still gets its turn.
|
|
155
|
+
const recursiveKey = resolveIconSource('gift', { icons: { gift: 'gift' } });
|
|
156
|
+
expect(recursiveKey.kind).toBe('glyph');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('never turns a catalogue key into a CDN request', () => {
|
|
160
|
+
EDITOR_DEFAULT_ICON_KEYS.forEach((key) => {
|
|
161
|
+
const source = resolveIconSource(key);
|
|
162
|
+
expect(source.kind).toBe('glyph');
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('still sends a genuine media path to the image CDN', () => {
|
|
167
|
+
expect(resolveIconSource('media/icons/free-shipping.svg')).toEqual({
|
|
168
|
+
kind: 'url',
|
|
169
|
+
url: `https://${CLOUD}/media/icons/free-shipping.svg`
|
|
170
|
+
});
|
|
171
|
+
// A bare file name is a path too — it has an extension.
|
|
172
|
+
expect(resolveIconSource('free-shipping.png')).toEqual({
|
|
173
|
+
kind: 'url',
|
|
174
|
+
url: `https://${CLOUD}/free-shipping.png`
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('answers nothing when there is nothing to draw', () => {
|
|
179
|
+
expect(resolveIconSource('')).toEqual({ kind: 'none' });
|
|
180
|
+
expect(resolveIconSource(null)).toEqual({ kind: 'none' });
|
|
181
|
+
expect(resolveIconSource(undefined)).toEqual({ kind: 'none' });
|
|
182
|
+
expect(resolveIconSource({})).toEqual({ kind: 'none' });
|
|
183
|
+
// Not a key, not a path: prefixing the CDN host would only produce a
|
|
184
|
+
// broken image.
|
|
185
|
+
expect(resolveIconSource('some-removed-key')).toEqual({ kind: 'none' });
|
|
186
|
+
|
|
187
|
+
delete process.env.NEXT_PUBLIC_IMAGE_CLOUD_NAME;
|
|
188
|
+
expect(resolveIconSource('media/icons/gift.svg')).toEqual({ kind: 'none' });
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('unwraps the object shapes published JSON has carried', () => {
|
|
192
|
+
expect(resolveIconSource({ url: 'https://example.com/a.svg' })).toEqual({
|
|
193
|
+
kind: 'url',
|
|
194
|
+
url: 'https://example.com/a.svg'
|
|
195
|
+
});
|
|
196
|
+
expect(resolveIconSource({ tr: 'basket' }).kind).toBe('glyph');
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const iconProps = (value: unknown) =>
|
|
201
|
+
({
|
|
202
|
+
block: {
|
|
203
|
+
id: 'i',
|
|
204
|
+
type: 'icon',
|
|
205
|
+
label: 'i',
|
|
206
|
+
value,
|
|
207
|
+
properties: {},
|
|
208
|
+
styles: {},
|
|
209
|
+
order: 0,
|
|
210
|
+
hidden: false
|
|
211
|
+
},
|
|
212
|
+
placeholderId: 'p',
|
|
213
|
+
sectionId: 's',
|
|
214
|
+
isDesigner: false,
|
|
215
|
+
currentBreakpoint: 'desktop'
|
|
216
|
+
} as never);
|
|
217
|
+
|
|
218
|
+
describe('IconBlock reading through the resolver', () => {
|
|
219
|
+
it('draws a picked catalogue key inline instead of a broken image', () => {
|
|
220
|
+
process.env.NEXT_PUBLIC_IMAGE_CLOUD_NAME = CLOUD;
|
|
221
|
+
const { container } = render(<IconBlock {...iconProps('basket')} />);
|
|
222
|
+
|
|
223
|
+
expect(container.querySelector('svg')).not.toBeNull();
|
|
224
|
+
expect(container.querySelector('img')).toBeNull();
|
|
225
|
+
});
|
|
226
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import {
|
|
2
|
+
EMPTY_RESPONSIVE_ARRAY,
|
|
3
|
+
hasResponsiveArray,
|
|
4
|
+
readResponsiveArray
|
|
5
|
+
} from '../responsive-array';
|
|
6
|
+
|
|
7
|
+
describe('readResponsiveArray', () => {
|
|
8
|
+
it('passes a flat array through by identity', () => {
|
|
9
|
+
const stored = [{ label: 'Free shipping' }, { label: 'Store pickup' }];
|
|
10
|
+
expect(readResponsiveArray(stored)).toBe(stored);
|
|
11
|
+
expect(readResponsiveArray(stored, 'mobile')).toBe(stored);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('reads the bucket for the current breakpoint', () => {
|
|
15
|
+
const stored = {
|
|
16
|
+
desktop: [{ label: 'Complete the look' }],
|
|
17
|
+
mobile: [{ label: 'In store' }, { label: 'Instalments' }]
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
expect(readResponsiveArray(stored, 'desktop')).toHaveLength(1);
|
|
21
|
+
expect(readResponsiveArray(stored, 'mobile')).toHaveLength(2);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('lets tablet inherit mobile before desktop, like the emitted cascade', () => {
|
|
25
|
+
const withMobile = {
|
|
26
|
+
desktop: [{ label: 'desktop' }],
|
|
27
|
+
mobile: [{ label: 'mobile' }]
|
|
28
|
+
};
|
|
29
|
+
expect(readResponsiveArray<{ label: string }>(withMobile, 'tablet')[0]).toEqual(
|
|
30
|
+
{ label: 'mobile' }
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
const desktopOnly = { desktop: [{ label: 'desktop' }] };
|
|
34
|
+
expect(
|
|
35
|
+
readResponsiveArray<{ label: string }>(desktopOnly, 'tablet')[0]
|
|
36
|
+
).toEqual({ label: 'desktop' });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('falls back to desktop when the breakpoint has nothing authored', () => {
|
|
40
|
+
const stored = { desktop: [{ label: 'Instalment table' }] };
|
|
41
|
+
expect(readResponsiveArray(stored, 'mobile')).toHaveLength(1);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('returns the same empty array every time nothing is stored', () => {
|
|
45
|
+
const first = readResponsiveArray(undefined);
|
|
46
|
+
const second = readResponsiveArray(null);
|
|
47
|
+
|
|
48
|
+
expect(first).toBe(second);
|
|
49
|
+
expect(first).toBe(EMPTY_RESPONSIVE_ARRAY);
|
|
50
|
+
expect(first).toHaveLength(0);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('returns the shared empty for values that are not lists', () => {
|
|
54
|
+
expect(readResponsiveArray('two items')).toBe(EMPTY_RESPONSIVE_ARRAY);
|
|
55
|
+
expect(readResponsiveArray(3)).toBe(EMPTY_RESPONSIVE_ARRAY);
|
|
56
|
+
expect(readResponsiveArray(true)).toBe(EMPTY_RESPONSIVE_ARRAY);
|
|
57
|
+
// A per-breakpoint object whose bucket is not a list is still "no list".
|
|
58
|
+
expect(readResponsiveArray({ desktop: 'nope' })).toBe(
|
|
59
|
+
EMPTY_RESPONSIVE_ARRAY
|
|
60
|
+
);
|
|
61
|
+
expect(readResponsiveArray({ somethingElse: [1] })).toBe(
|
|
62
|
+
EMPTY_RESPONSIVE_ARRAY
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('is frozen, so a caller cannot poison the next reader', () => {
|
|
67
|
+
expect(() => readResponsiveArray<number>(undefined).push(1)).toThrow();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('answers hasResponsiveArray per breakpoint', () => {
|
|
71
|
+
const stored = { desktop: [{ label: 'Gift wrap' }], mobile: [] };
|
|
72
|
+
|
|
73
|
+
expect(hasResponsiveArray(stored, 'desktop')).toBe(true);
|
|
74
|
+
expect(hasResponsiveArray(stored, 'mobile')).toBe(false);
|
|
75
|
+
expect(hasResponsiveArray(undefined)).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render } from '@testing-library/react';
|
|
3
|
+
import {
|
|
4
|
+
PLATFORM_LABELS,
|
|
5
|
+
SOCIAL_GLYPHS,
|
|
6
|
+
platformLabel
|
|
7
|
+
} from '../social-glyphs';
|
|
8
|
+
import SocialLinksBlock from '../social-links-block';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The table moved out of `social-links-block` so more than one renderer can
|
|
12
|
+
* draw a platform mark. These pin the move: the same 19 keys, the same labels,
|
|
13
|
+
* and the block still drawing the glyph it drew before.
|
|
14
|
+
*/
|
|
15
|
+
const EXPECTED_PLATFORMS = [
|
|
16
|
+
'facebook',
|
|
17
|
+
'instagram',
|
|
18
|
+
'twitter',
|
|
19
|
+
'youtube',
|
|
20
|
+
'linkedin',
|
|
21
|
+
'tiktok',
|
|
22
|
+
'pinterest',
|
|
23
|
+
'whatsapp',
|
|
24
|
+
'telegram',
|
|
25
|
+
'discord',
|
|
26
|
+
'snapchat',
|
|
27
|
+
'reddit',
|
|
28
|
+
'twitch',
|
|
29
|
+
'spotify',
|
|
30
|
+
'apple',
|
|
31
|
+
'google',
|
|
32
|
+
'email',
|
|
33
|
+
'phone',
|
|
34
|
+
'globe'
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
describe('social glyph table', () => {
|
|
38
|
+
it('carries every platform the picker stores, and nothing else', () => {
|
|
39
|
+
expect(Object.keys(SOCIAL_GLYPHS).sort()).toEqual(
|
|
40
|
+
[...EXPECTED_PLATFORMS].sort()
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('keeps the hand-written labels and title-cases the rest', () => {
|
|
45
|
+
expect(PLATFORM_LABELS.twitter).toBe('Twitter / X');
|
|
46
|
+
expect(platformLabel('twitter')).toBe('Twitter / X');
|
|
47
|
+
expect(platformLabel('globe')).toBe('Website');
|
|
48
|
+
expect(platformLabel('facebook')).toBe('Facebook');
|
|
49
|
+
expect(platformLabel('')).toBe('Link');
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const socialProps = (items: unknown[]) =>
|
|
54
|
+
({
|
|
55
|
+
block: {
|
|
56
|
+
id: 'b',
|
|
57
|
+
type: 'social-links',
|
|
58
|
+
label: 'Social',
|
|
59
|
+
value: '',
|
|
60
|
+
properties: { items },
|
|
61
|
+
styles: {},
|
|
62
|
+
order: 0,
|
|
63
|
+
hidden: false
|
|
64
|
+
},
|
|
65
|
+
placeholderId: 'p',
|
|
66
|
+
sectionId: 's',
|
|
67
|
+
isDesigner: false,
|
|
68
|
+
currentBreakpoint: 'desktop'
|
|
69
|
+
} as never);
|
|
70
|
+
|
|
71
|
+
describe('social links block after the table moved', () => {
|
|
72
|
+
it('still draws the glyph for a stored entry', () => {
|
|
73
|
+
const { container } = render(
|
|
74
|
+
<SocialLinksBlock
|
|
75
|
+
{...socialProps([
|
|
76
|
+
{ icon: 'facebook', link: 'https://facebook.com/lulu' }
|
|
77
|
+
])}
|
|
78
|
+
/>
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const link = container.querySelector('[data-social-link="facebook"]');
|
|
82
|
+
expect(link).not.toBeNull();
|
|
83
|
+
expect(link!.getAttribute('aria-label')).toBe('Facebook');
|
|
84
|
+
expect(link!.querySelector('svg path')).not.toBeNull();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('falls back to the globe glyph for an unknown platform', () => {
|
|
88
|
+
const { container } = render(
|
|
89
|
+
<SocialLinksBlock
|
|
90
|
+
{...socialProps([{ icon: 'myspace', link: 'https://example.com' }])}
|
|
91
|
+
/>
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const link = container.querySelector('[data-social-link="myspace"]');
|
|
95
|
+
expect(link).not.toBeNull();
|
|
96
|
+
// The globe is three shapes; an unknown key that resolved to nothing would
|
|
97
|
+
// have drawn an empty <svg>.
|
|
98
|
+
expect(link!.querySelectorAll('svg > *').length).toBe(3);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('draws the designer placeholder when there are no entries', () => {
|
|
102
|
+
const { container } = render(
|
|
103
|
+
<SocialLinksBlock {...socialProps([])} isDesigner />
|
|
104
|
+
);
|
|
105
|
+
expect(container.querySelector('[data-social-links]')).not.toBeNull();
|
|
106
|
+
expect(container.textContent).toContain('Social links');
|
|
107
|
+
});
|
|
108
|
+
});
|