@akinon/pz-theme 2.0.78-rc.2 → 2.0.79-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 +10 -37
- package/package.json +4 -4
- package/src/__tests__/custom-catalog-announce.test.ts +73 -0
- package/src/chrome/__tests__/chrome-mode.test.ts +174 -0
- package/src/chrome/chrome-mode.ts +61 -0
- package/src/custom/README-sections.md +289 -0
- package/src/custom/README.md +7 -0
- package/src/custom/__tests__/custom-section-bindings.test.tsx +327 -0
- package/src/custom/__tests__/define-section.test.tsx +481 -0
- package/src/custom/__tests__/module-boundaries.test.ts +2 -0
- package/src/custom/__tests__/public-api.test.tsx +123 -0
- package/src/custom/__tests__/region-node-wire.test.tsx +302 -0
- package/src/custom/__tests__/region-nodes.test.ts +161 -0
- package/src/custom/__tests__/theme-editable.test.tsx +22 -10
- package/src/custom/__tests__/theme-region.test.tsx +331 -0
- package/src/custom/__tests__/theme-slot.test.tsx +18 -10
- package/src/custom/__tests__/use-theme-setting.test.tsx +17 -9
- package/src/custom/block-context.ts +53 -10
- package/src/custom/block-host.tsx +13 -1
- package/src/custom/collect.ts +26 -5
- package/src/custom/define-section.tsx +163 -0
- package/src/custom/index.ts +83 -1
- package/src/custom/region-nodes.ts +72 -0
- package/src/custom/register.ts +419 -48
- package/src/custom/section-catalog.ts +98 -0
- package/src/custom/section-definitions.ts +59 -0
- package/src/custom/section-host.tsx +125 -0
- package/src/custom/section-types.ts +158 -0
- package/src/custom/theme-editable.tsx +3 -1
- package/src/custom/theme-region.tsx +213 -0
- package/src/custom/theme-slot.tsx +8 -7
- package/src/custom/types.ts +10 -1
- package/src/custom/use-theme-setting.ts +6 -4
- package/src/hooks/use-designer-features.tsx +41 -0
- package/src/index.ts +25 -1
- package/src/placeholder-registry.ts +34 -5
- package/src/sections/__tests__/custom-renderer-section-types.test.ts +29 -0
- package/src/sections/__tests__/section-part-selection.test.tsx +277 -0
- package/src/sections/custom-renderer-section-types.ts +19 -0
- package/src/sections/section-wrapper.tsx +12 -1
- package/src/theme-section.tsx +162 -3
- package/src/utils/__tests__/docked-state-styles.test.ts +249 -0
- package/src/utils/__tests__/section-content-width.test.ts +28 -0
- package/src/utils/__tests__/section-max-width.test.ts +53 -0
- package/src/utils/index.ts +31 -3
- package/src/utils/part-styles.ts +17 -1
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
# Custom sections
|
|
2
|
+
|
|
3
|
+
A brand developer writes a whole **area** — a header, a footer, eventually a
|
|
4
|
+
product detail page — in their own repo, names the children that go inside it,
|
|
5
|
+
and hands each one to the merchant as an ordinary composed block. The section
|
|
6
|
+
appears in the theme editor's Add Section band under **Brand sections**.
|
|
7
|
+
|
|
8
|
+
Read [`README.md`](./README.md) first. Everything about `ThemeEditable`,
|
|
9
|
+
`useThemeSetting`, `ThemeSlot`, `partAttrs` and the limits works identically
|
|
10
|
+
inside a section; this file is only the difference.
|
|
11
|
+
|
|
12
|
+
## When a section instead of a block
|
|
13
|
+
|
|
14
|
+
A block, because it is dropped into a section the merchant created, gets the
|
|
15
|
+
near-bare block-root panel and lives inside a wrapper the merchant can put a
|
|
16
|
+
`transform` on — which traps a `position: fixed` descendant, measured once as a
|
|
17
|
+
1247px header collapsing to 600px.
|
|
18
|
+
|
|
19
|
+
A section root gets the geometry family (`position`, `display`, `height`,
|
|
20
|
+
`max-height`, `z-index`, `grid-template-columns`) and Content Width, and there
|
|
21
|
+
is no wrapper left to spring the trap.
|
|
22
|
+
|
|
23
|
+
**Rule of thumb:** if the thing you are writing owns its own band on the page,
|
|
24
|
+
write a section. If it is one element inside a band the merchant composed,
|
|
25
|
+
write a block.
|
|
26
|
+
|
|
27
|
+
## The shape
|
|
28
|
+
|
|
29
|
+
```tsx
|
|
30
|
+
'use client';
|
|
31
|
+
|
|
32
|
+
import {
|
|
33
|
+
defineCustomSection,
|
|
34
|
+
partAttrs,
|
|
35
|
+
ThemeRegion,
|
|
36
|
+
ThemeSlot
|
|
37
|
+
} from '@akinon/pz-theme/src/custom';
|
|
38
|
+
|
|
39
|
+
function StoreHeader() {
|
|
40
|
+
return (
|
|
41
|
+
<header {...partAttrs('bar')}>
|
|
42
|
+
<div {...partAttrs('row')}>
|
|
43
|
+
<ThemeRegion name="logo" label="Logo" />
|
|
44
|
+
|
|
45
|
+
<nav {...partAttrs('actions')}>
|
|
46
|
+
<ThemeRegion name="search" label="Search" />
|
|
47
|
+
<ThemeRegion name="basket" label="Basket" />
|
|
48
|
+
<ThemeSlot name="extras" label="Extra header actions" />
|
|
49
|
+
</nav>
|
|
50
|
+
</div>
|
|
51
|
+
</header>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export default defineCustomSection(StoreHeader, {
|
|
56
|
+
label: 'Store header',
|
|
57
|
+
placeholders: ['header'],
|
|
58
|
+
|
|
59
|
+
regions: [
|
|
60
|
+
{ name: 'logo', label: 'Logo', block: 'image', value: '/logo.svg' },
|
|
61
|
+
{ name: 'search', label: 'Search', block: 'search-box' },
|
|
62
|
+
{ name: 'basket', label: 'Basket', block: 'mini-basket' }
|
|
63
|
+
],
|
|
64
|
+
|
|
65
|
+
slots: [{ name: 'extras', label: 'Extra header actions' }],
|
|
66
|
+
|
|
67
|
+
parts: ['bar', 'row', 'actions'],
|
|
68
|
+
|
|
69
|
+
styles: {
|
|
70
|
+
position: { desktop: 'fixed' },
|
|
71
|
+
inset: { desktop: '0 0 auto 0' },
|
|
72
|
+
'background-color': { desktop: 'transparent' },
|
|
73
|
+
':scrolled/background-color': { desktop: '#000000' }
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
lockRootTransforms: true
|
|
77
|
+
});
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Then, from the **client root only**:
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
registerCustomSections('acme', { header: StoreHeader });
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## `ThemeRegion` — the one genuinely new piece
|
|
87
|
+
|
|
88
|
+
A region is a child you named, chose the type of, and placed. Declaring
|
|
89
|
+
`{ name: 'search', block: 'search-box' }` is what buys the whole search-box
|
|
90
|
+
panel: its settings, its shared controls, the 161 advanced CSS controls and its
|
|
91
|
+
part groups. You write no descriptor for it.
|
|
92
|
+
|
|
93
|
+
The merchant can style it, configure it, hide it and delete it. They **cannot**
|
|
94
|
+
reorder it or insert beside it — your JSX owns the layout, and no drag in the
|
|
95
|
+
layer tree changes where it renders.
|
|
96
|
+
|
|
97
|
+
`properties`, `styles` and `value` on a region are a **seed**: what it looks
|
|
98
|
+
like the moment the section is added. They are written once, at insert, and
|
|
99
|
+
nothing re-applies them, so a merchant's edit is never quietly reverted.
|
|
100
|
+
`value` is there for the block types that keep their content there rather than
|
|
101
|
+
in `properties` — an `image` reads `block.value` for its src.
|
|
102
|
+
|
|
103
|
+
**A deleted region draws a labelled band in the designer and nothing at all
|
|
104
|
+
live.** That is not an error state: deleting is an ordinary composition edit and
|
|
105
|
+
has to stay durable. Regions are seeded once, at insert; the editor lists every
|
|
106
|
+
absent one under the section with an **Add back** control, so a deletion is
|
|
107
|
+
reversible without being undone behind the merchant's back.
|
|
108
|
+
|
|
109
|
+
**There is no unnamed region.** A bare `<ThemeSlot />` resolves to a block's
|
|
110
|
+
single slot; a region has no such fallback, because a section's direct children
|
|
111
|
+
are the merchant's own composed blocks and a nameless region would swallow all
|
|
112
|
+
of them.
|
|
113
|
+
|
|
114
|
+
## Region or slot
|
|
115
|
+
|
|
116
|
+
| | Region | Slot |
|
|
117
|
+
|---|---|---|
|
|
118
|
+
| Who chooses the block type | you | the merchant |
|
|
119
|
+
| Who chooses where it renders | you | you choose where the slot is |
|
|
120
|
+
| How many blocks | exactly one | any number |
|
|
121
|
+
| Merchant can reorder | no | yes, inside the slot |
|
|
122
|
+
| Merchant can delete | yes, and it stays deleted (Add back restores it) | the row itself, no |
|
|
123
|
+
|
|
124
|
+
## Commerce data
|
|
125
|
+
|
|
126
|
+
Two different things want data and only one of them is yours.
|
|
127
|
+
|
|
128
|
+
**The merchant's blocks resolve their own bindings, and you wire nothing.**
|
|
129
|
+
Every block inside a region or a slot goes through the same pass a block under
|
|
130
|
+
an ordinary section root goes through: `{{...}}` tokens in `dataBinding`, a bare
|
|
131
|
+
`dataBinding` path, and an `isIterator` block repeating over the section's data
|
|
132
|
+
source. That is the point — the merchant composed those blocks before you
|
|
133
|
+
rewrote the area, and rewriting the area must not change what they say. It ran
|
|
134
|
+
only on the generic path once, which meant a header moved into a custom section
|
|
135
|
+
silently fell back to its authored text with nothing logged anywhere.
|
|
136
|
+
|
|
137
|
+
Unlike the generic path, the pass is not gated on the section type: a
|
|
138
|
+
`custom:acme:hero` root resolves bindings whenever there is anything to resolve
|
|
139
|
+
against — a collection source, a static source, or the page's own commerce data.
|
|
140
|
+
With no source at all the walk finds no root and returns the tree untouched, so
|
|
141
|
+
the merchant's authored fallback stands.
|
|
142
|
+
|
|
143
|
+
**Your own JSX reads the page instead.** The section component is code, so it
|
|
144
|
+
asks directly rather than through a binding:
|
|
145
|
+
|
|
146
|
+
```tsx
|
|
147
|
+
import { useThemePageContext } from '@akinon/pz-theme/src/theme-page-context';
|
|
148
|
+
|
|
149
|
+
const pageContext = useThemePageContext();
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Import it from that deep path, not from `@akinon/pz-theme`. The package root
|
|
153
|
+
re-exports `ThemePlaceholder`, which imports `server-only`, and that poisons the
|
|
154
|
+
whole module for a client graph — a section file is a client module, so the root
|
|
155
|
+
import fails the brand's build with "cannot be imported from a Client Component
|
|
156
|
+
module". `@akinon/pz-theme/src/custom` does not re-export it either; the deep
|
|
157
|
+
module is the one route.
|
|
158
|
+
|
|
159
|
+
## Parts must live on YOUR markup
|
|
160
|
+
|
|
161
|
+
Part rules are generated with an ownership guard that excludes anything inside a
|
|
162
|
+
descendant `[data-block-id]`. Every region renders inside one. So a `data-part`
|
|
163
|
+
stamped on markup a region's block renders is excluded from its own section's
|
|
164
|
+
part rules, and the control the panel drew for it does nothing.
|
|
165
|
+
|
|
166
|
+
Stamp parts on the elements the section itself draws — the bands **around** the
|
|
167
|
+
regions.
|
|
168
|
+
|
|
169
|
+
## The three page-level states
|
|
170
|
+
|
|
171
|
+
Every other style state is a fact about an element and matches on it: `hover`,
|
|
172
|
+
`selected`, `invalid`. Three are facts about the **page**, and they are the only
|
|
173
|
+
way a composition learns something it cannot see from where it sits.
|
|
174
|
+
|
|
175
|
+
| State | Means | Stamped on | By |
|
|
176
|
+
|---|---|---|---|
|
|
177
|
+
| `scrolled` | the page has moved under the chrome | the chrome slot, `data-scroll-state` | pz-theme (`chrome/scroll-state.tsx`) |
|
|
178
|
+
| `overlay-open` | some overlay is open | `body`, `data-overlay-state` | pz-theme (`hooks/use-overlay.ts`) |
|
|
179
|
+
| `docked` | the chrome sits **above** the page, not over it | `body`, `data-chrome-mode` | **you**, via `setChromeMode` |
|
|
180
|
+
|
|
181
|
+
All three work as a root state on the section itself, `:<state>/<property>`, and
|
|
182
|
+
as a part state, `<part>/<state>/<property>`.
|
|
183
|
+
|
|
184
|
+
**The one rule: pz-theme owns the vocabulary, the brand owns the signal.** The
|
|
185
|
+
attribute names, the tokens and the selectors that read them live here. Nothing
|
|
186
|
+
in pz-theme can know which of your routes carry a hero, so nothing in pz-theme
|
|
187
|
+
stamps `docked`. You do — and through `setChromeMode`, never by hand-writing the
|
|
188
|
+
attribute, because a typo there raises no error and simply produces a header
|
|
189
|
+
that never changes appearance.
|
|
190
|
+
|
|
191
|
+
### The worked example: a header with two appearances
|
|
192
|
+
|
|
193
|
+
This is what `docked` exists for. Over the home page's hero the header is
|
|
194
|
+
transparent with light content; on every other route it sits above the page,
|
|
195
|
+
solid, with dark content. Which one applies is a fact about the **route**, and a
|
|
196
|
+
route is the one thing a composition cannot ask about. Before this, the choice
|
|
197
|
+
was to keep the header in code and lose every merchant control, or compose it
|
|
198
|
+
and lose the second appearance.
|
|
199
|
+
|
|
200
|
+
```tsx
|
|
201
|
+
'use client';
|
|
202
|
+
|
|
203
|
+
const isHome = usePathname() === '/';
|
|
204
|
+
|
|
205
|
+
useEffect(() => setChromeMode(isHome ? null : CHROME_MODE_DOCKED), [isHome]);
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
`setChromeMode` returns a cleanup, which is why it is returned straight from the
|
|
209
|
+
effect. **Give the signal one owner.** The cleanup restores the value that call
|
|
210
|
+
displaced — a snapshot, not a counted stack — so it unwinds correctly as one
|
|
211
|
+
component mounts, re-stamps and unmounts, and does *not* compose if two
|
|
212
|
+
components both stamp during a route transition.
|
|
213
|
+
|
|
214
|
+
The section then styles all three appearances, and no descriptor changes:
|
|
215
|
+
|
|
216
|
+
```ts
|
|
217
|
+
styles: {
|
|
218
|
+
'background-color': { desktop: 'transparent' },
|
|
219
|
+
color: { desktop: '#ffffff' },
|
|
220
|
+
|
|
221
|
+
// Docked: above the page, solid, burgundy content.
|
|
222
|
+
':docked/background-color': { desktop: '#ffffff' },
|
|
223
|
+
':docked/color': { desktop: '#4a0e26' },
|
|
224
|
+
|
|
225
|
+
// Still transparent over the hero, but opaque once the page moves.
|
|
226
|
+
':scrolled/background-color': { desktop: '#ffffff' },
|
|
227
|
+
|
|
228
|
+
// And opaque while the mega menu is open, which the menu cannot say itself.
|
|
229
|
+
':overlay-open/background-color': { desktop: '#ffffff' }
|
|
230
|
+
}
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
**Absent means overlay.** The attribute is not written until something writes
|
|
234
|
+
it, so every composition published before `docked` existed renders exactly as it
|
|
235
|
+
did.
|
|
236
|
+
|
|
237
|
+
**`docked` outranks `scrolled` on purpose.** On an inner page both are true at
|
|
238
|
+
once — a docked header is scrolled the moment the shopper moves — and both set
|
|
239
|
+
the same property on the same band. The `docked` selector is qualified with
|
|
240
|
+
`body`, which makes it (0,2,1) against `scrolled` at (0,2,0), so the mode wins
|
|
241
|
+
on specificity rather than on whichever rule happened to be emitted last. That
|
|
242
|
+
is the right answer and not an arbitrary one: `scrolled` describes how far the
|
|
243
|
+
page has slid under a bar that floats over it, and once the bar is docked the
|
|
244
|
+
question has no meaning. If you want a docked header to change on scroll as
|
|
245
|
+
well, say so with a `docked` value that differs from the plain one.
|
|
246
|
+
|
|
247
|
+
## `lockRootTransforms`
|
|
248
|
+
|
|
249
|
+
Set it on any section whose design contains a `position: fixed` or `sticky`
|
|
250
|
+
descendant. It removes `rotate`, `scale`, `translate`, `filter` and
|
|
251
|
+
`backdrop-filter` from the section root's panel. Each of those creates a
|
|
252
|
+
containing block for a fixed descendant, and a merchant has no way to know that.
|
|
253
|
+
|
|
254
|
+
Remembering to set it is on you. It defaults to off because most sections do not
|
|
255
|
+
contain a fixed child, and withholding controls a section could legitimately use
|
|
256
|
+
is its own kind of lie.
|
|
257
|
+
|
|
258
|
+
## `placeholders`
|
|
259
|
+
|
|
260
|
+
`placeholders: ['header']` offers the section on the header placeholder and
|
|
261
|
+
nowhere else. Omit it to offer it everywhere. A header section on the basket
|
|
262
|
+
page is noise the merchant has to scroll past.
|
|
263
|
+
|
|
264
|
+
## Limits
|
|
265
|
+
|
|
266
|
+
Sixteen section types per build, twenty-four settings, twenty-four regions,
|
|
267
|
+
eight slots and thirty-two parts per type, and a 256 KB **section** catalog
|
|
268
|
+
measured separately from the block one.
|
|
269
|
+
|
|
270
|
+
Sixteen rather than the blocks' forty because a section descriptor is the larger
|
|
271
|
+
of the two — it carries a region list with seeded properties, styles and values —
|
|
272
|
+
and a brand needs far fewer of them: sections are pages and chrome regions,
|
|
273
|
+
blocks are the vocabulary inside them.
|
|
274
|
+
|
|
275
|
+
## One type namespace
|
|
276
|
+
|
|
277
|
+
`custom:acme:header` can be claimed by a block **or** a section, never both.
|
|
278
|
+
Registration refuses either order with a message that says which one already
|
|
279
|
+
holds it.
|
|
280
|
+
|
|
281
|
+
## Failure behaviour
|
|
282
|
+
|
|
283
|
+
A composition naming a section type this build does not register renders nothing
|
|
284
|
+
on the live site and a labelled, selectable band in the designer — never the
|
|
285
|
+
generic block loop, which would spill the seeded region blocks onto the page as
|
|
286
|
+
loose top-level content in an order you never chose.
|
|
287
|
+
|
|
288
|
+
A section component that throws is caught per-section: nothing live, a labelled
|
|
289
|
+
card in the designer, and everything composed beside it renders normally.
|
package/src/custom/README.md
CHANGED
|
@@ -9,6 +9,13 @@ with ordinary editor content.
|
|
|
9
9
|
Nothing here is a schema kept alongside the component. The declaration *is* the
|
|
10
10
|
usage.
|
|
11
11
|
|
|
12
|
+
**Writing a whole area rather than one element inside it?** See
|
|
13
|
+
[`README-sections.md`](./README-sections.md). A custom *section* owns its own
|
|
14
|
+
band on the page: it gets the section root panel (geometry and content width),
|
|
15
|
+
and it names the children that go inside it rather than opening a hole. A custom
|
|
16
|
+
*block* is the right shape for one element the merchant drops into a band they
|
|
17
|
+
composed.
|
|
18
|
+
|
|
12
19
|
## The shape
|
|
13
20
|
|
|
14
21
|
A "Complete the look" row for a product page — the merchant retypes its
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render, screen } from '@testing-library/react';
|
|
3
|
+
|
|
4
|
+
import blockRendererRegistry from '../../blocks/block-renderer-registry';
|
|
5
|
+
import ThemeSection from '../../theme-section';
|
|
6
|
+
import type { Section } from '../../theme-section';
|
|
7
|
+
import { ThemeSettingsProvider } from '../../theme-settings-context';
|
|
8
|
+
import { resetCustomCatalog } from '../catalog';
|
|
9
|
+
import { resetCustomCollection } from '../collect';
|
|
10
|
+
import { defineCustomSection } from '../define-section';
|
|
11
|
+
import { registerCustomSections } from '../register';
|
|
12
|
+
import { resetCustomSectionCatalog } from '../section-catalog';
|
|
13
|
+
import { resetCustomSectionDefinitions } from '../section-definitions';
|
|
14
|
+
import { ThemeRegion } from '../theme-region';
|
|
15
|
+
import { isCustomThemeType } from '../types';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* THE MERCHANT'S DATA HAS TO SURVIVE THE REWRITE.
|
|
19
|
+
*
|
|
20
|
+
* A brand takes a header the merchant already composed — bound text, a repeat
|
|
21
|
+
* over the catalogue — and rewrites the area as a developer-authored section so
|
|
22
|
+
* the JSX can own the layout. The blocks do not change: the same tree moves
|
|
23
|
+
* from under a generic section root to under a `custom:acme:hero` one.
|
|
24
|
+
*
|
|
25
|
+
* It used to lose every binding in the move. The custom branch of
|
|
26
|
+
* `ThemeSection` returned before `resolveIteratorsRecursively` and
|
|
27
|
+
* `resolveBlockBindings` ran, so `ThemeRegion`/`ThemeSlot` handed
|
|
28
|
+
* `renderComposedChildren` raw blocks and `ThemeBlock` resolves nothing: the
|
|
29
|
+
* text fell back to its authored value and the repeat drew one empty row. No
|
|
30
|
+
* console output in the designer, none on the storefront, and nothing in the
|
|
31
|
+
* diff to review — the section markup was the only thing that changed.
|
|
32
|
+
*
|
|
33
|
+
* EVERY CASE IS ASSERTED AS AN EQUALITY BETWEEN THE TWO RENDERS, never against
|
|
34
|
+
* a literal. A literal expectation ("1,299.90 TL") pins today's formatter and
|
|
35
|
+
* says nothing about the two paths agreeing; if the fixture or the formatting
|
|
36
|
+
* moves, an equality test still asks the only question that matters — does the
|
|
37
|
+
* custom root render what the generic root renders. The second case below —
|
|
38
|
+
* "proves that equality is over resolved data" — is what stops that equality
|
|
39
|
+
* passing because both sides are equally broken.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
const CUSTOM_TYPE = 'custom:acme:hero';
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* `custom` is the type a merchant-composed section actually carries, so this is
|
|
46
|
+
* the real before-state of the rewrite, not a synthetic control. It is also the
|
|
47
|
+
* one type the generic path resolves bindings for unconditionally
|
|
48
|
+
* (`shouldApplyGenericBindings`), which is exactly why a `custom:acme:hero`
|
|
49
|
+
* root cannot reuse that predicate: it is not the string `custom`.
|
|
50
|
+
*/
|
|
51
|
+
const GENERIC_TYPE = 'custom';
|
|
52
|
+
|
|
53
|
+
const CATALOGUE = [
|
|
54
|
+
{ name: 'Spring Sneaker', colour: 'sand' },
|
|
55
|
+
{ name: 'Trail Runner', colour: 'moss' },
|
|
56
|
+
{ name: 'City Loafer', colour: 'ink' }
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
/** Fewer rows than the catalogue holds, so the slice is visibly doing something. */
|
|
60
|
+
const ITERATOR_COUNT = 2;
|
|
61
|
+
|
|
62
|
+
/** A collection source, which is the case `shouldApplyGenericBindings` gates on. */
|
|
63
|
+
const DATA_SOURCE = {
|
|
64
|
+
type: 'collection',
|
|
65
|
+
details: { collection: { data: CATALOGUE } }
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The blocks, authored once and rendered under both roots byte for byte.
|
|
70
|
+
*
|
|
71
|
+
* `properties.regionName` is what a section's `<ThemeRegion>` resolves against
|
|
72
|
+
* and is an ordinary unread property everywhere else, so one array serves both
|
|
73
|
+
* fixtures and the ONLY difference between the two renders is `section.type`.
|
|
74
|
+
*/
|
|
75
|
+
const authoredBlocks = () => [
|
|
76
|
+
{
|
|
77
|
+
// (a) A `{{...}}` token inside literal text.
|
|
78
|
+
id: 'block-headline',
|
|
79
|
+
type: 'text',
|
|
80
|
+
label: 'Headline',
|
|
81
|
+
value: 'Authored headline fallback',
|
|
82
|
+
properties: {
|
|
83
|
+
regionName: 'headline',
|
|
84
|
+
dataBinding: 'Featured: {{ products[0].name }}'
|
|
85
|
+
},
|
|
86
|
+
styles: {},
|
|
87
|
+
order: 0,
|
|
88
|
+
hidden: false
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
// (b) A bare `properties.dataBinding` path.
|
|
92
|
+
id: 'block-lead',
|
|
93
|
+
type: 'text',
|
|
94
|
+
label: 'Lead',
|
|
95
|
+
value: 'Authored lead fallback',
|
|
96
|
+
properties: { regionName: 'lead', dataBinding: 'products[1].name' },
|
|
97
|
+
styles: {},
|
|
98
|
+
order: 1,
|
|
99
|
+
hidden: false
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
// (c) A repeat over the section's data source, one bound row template.
|
|
103
|
+
id: 'block-list',
|
|
104
|
+
type: 'group',
|
|
105
|
+
label: 'Catalogue',
|
|
106
|
+
isIterator: true,
|
|
107
|
+
iteratorDataPath: 'products',
|
|
108
|
+
properties: {
|
|
109
|
+
regionName: 'list',
|
|
110
|
+
useIteratorCount: true,
|
|
111
|
+
iteratorCount: ITERATOR_COUNT
|
|
112
|
+
},
|
|
113
|
+
styles: {},
|
|
114
|
+
order: 2,
|
|
115
|
+
hidden: false,
|
|
116
|
+
blocks: [
|
|
117
|
+
{
|
|
118
|
+
id: 'block-row',
|
|
119
|
+
type: 'text',
|
|
120
|
+
label: 'Row',
|
|
121
|
+
value: 'Authored row fallback',
|
|
122
|
+
properties: { dataBinding: 'item.name' },
|
|
123
|
+
styles: {},
|
|
124
|
+
order: 0,
|
|
125
|
+
hidden: false
|
|
126
|
+
}
|
|
127
|
+
]
|
|
128
|
+
}
|
|
129
|
+
];
|
|
130
|
+
|
|
131
|
+
const sectionOfType = (type: string): Section =>
|
|
132
|
+
({
|
|
133
|
+
id: `section-${type}`,
|
|
134
|
+
type,
|
|
135
|
+
name: 'Store hero',
|
|
136
|
+
label: 'Store hero',
|
|
137
|
+
properties: {},
|
|
138
|
+
styles: {},
|
|
139
|
+
order: 0,
|
|
140
|
+
hidden: false,
|
|
141
|
+
dataSource: DATA_SOURCE,
|
|
142
|
+
blocks: authoredBlocks()
|
|
143
|
+
} as unknown as Section);
|
|
144
|
+
|
|
145
|
+
/** Places one region per authored block, in the order they were authored. */
|
|
146
|
+
function AcmeHero() {
|
|
147
|
+
return (
|
|
148
|
+
<div>
|
|
149
|
+
<div data-testid="headline-area">
|
|
150
|
+
<ThemeRegion name="headline" />
|
|
151
|
+
</div>
|
|
152
|
+
<div data-testid="lead-area">
|
|
153
|
+
<ThemeRegion name="lead" />
|
|
154
|
+
</div>
|
|
155
|
+
<div data-testid="list-area">
|
|
156
|
+
<ThemeRegion name="list" />
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const registerHero = () =>
|
|
163
|
+
registerCustomSections('acme', {
|
|
164
|
+
hero: defineCustomSection(AcmeHero, {
|
|
165
|
+
label: 'Store hero',
|
|
166
|
+
regions: [
|
|
167
|
+
{ name: 'headline', label: 'Headline', block: 'text' },
|
|
168
|
+
{ name: 'lead', label: 'Lead', block: 'text' },
|
|
169
|
+
{ name: 'list', label: 'Catalogue', block: 'group' }
|
|
170
|
+
]
|
|
171
|
+
})
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const rendererMap = (): Map<string, unknown> =>
|
|
175
|
+
(blockRendererRegistry as unknown as { renderers: Map<string, unknown> })
|
|
176
|
+
.renderers;
|
|
177
|
+
|
|
178
|
+
const clearCustomRenderers = (): void => {
|
|
179
|
+
const map = rendererMap();
|
|
180
|
+
Array.from(map.keys())
|
|
181
|
+
.filter(isCustomThemeType)
|
|
182
|
+
.forEach((type) => map.delete(type));
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const renderSection = (section: Section) =>
|
|
186
|
+
render(
|
|
187
|
+
<ThemeSettingsProvider value={null}>
|
|
188
|
+
<ThemeSection
|
|
189
|
+
section={section}
|
|
190
|
+
placeholderId="header"
|
|
191
|
+
currentBreakpoint="desktop"
|
|
192
|
+
isDesigner={false}
|
|
193
|
+
/>
|
|
194
|
+
</ThemeSettingsProvider>
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Collapsed whitespace, because the two roots wrap the same blocks in different
|
|
199
|
+
* markup and a comparison that counted the gaps between elements would be
|
|
200
|
+
* asserting the JSX rather than the data.
|
|
201
|
+
*/
|
|
202
|
+
const renderedText = (section: Section): string =>
|
|
203
|
+
(renderSection(section).container.textContent ?? '')
|
|
204
|
+
.replace(/\s+/g, ' ')
|
|
205
|
+
.trim();
|
|
206
|
+
|
|
207
|
+
/** Direct `[data-block-id]` children — the top-level blocks the root rendered. */
|
|
208
|
+
const topLevelBlockIds = (parent: Element | null): string[] =>
|
|
209
|
+
Array.from(parent?.children ?? [])
|
|
210
|
+
.filter((child) => child.hasAttribute('data-block-id'))
|
|
211
|
+
.map((child) => child.getAttribute('data-block-id') ?? '');
|
|
212
|
+
|
|
213
|
+
describe('merchant bindings inside a developer-authored section', () => {
|
|
214
|
+
beforeEach(() => {
|
|
215
|
+
resetCustomCatalog();
|
|
216
|
+
resetCustomCollection();
|
|
217
|
+
resetCustomSectionCatalog();
|
|
218
|
+
resetCustomSectionDefinitions();
|
|
219
|
+
clearCustomRenderers();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('renders the same text as the generic root the blocks came from', () => {
|
|
223
|
+
registerHero();
|
|
224
|
+
|
|
225
|
+
const generic = renderedText(sectionOfType(GENERIC_TYPE));
|
|
226
|
+
const custom = renderedText(sectionOfType(CUSTOM_TYPE));
|
|
227
|
+
|
|
228
|
+
// The whole regression in one line: rewriting the area as a section must
|
|
229
|
+
// change where the blocks render, never what they say.
|
|
230
|
+
expect(custom).toBe(generic);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('proves that equality is over resolved data, not two dead renders', () => {
|
|
234
|
+
registerHero();
|
|
235
|
+
|
|
236
|
+
// Without this the assertion above would pass just as happily if both
|
|
237
|
+
// roots printed the authored fallbacks, which is the state this file
|
|
238
|
+
// exists to forbid.
|
|
239
|
+
const custom = renderedText(sectionOfType(CUSTOM_TYPE));
|
|
240
|
+
|
|
241
|
+
expect(custom).toContain(`Featured: ${CATALOGUE[0].name}`);
|
|
242
|
+
expect(custom).toContain(CATALOGUE[1].name);
|
|
243
|
+
expect(custom).not.toContain('Authored');
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it.each([
|
|
247
|
+
['a {{...}} token', 'headline-area', `Featured: ${CATALOGUE[0].name}`],
|
|
248
|
+
['a properties.dataBinding path', 'lead-area', CATALOGUE[1].name],
|
|
249
|
+
[
|
|
250
|
+
'an isIterator block over the data source',
|
|
251
|
+
'list-area',
|
|
252
|
+
// Two rows, `iteratorCount` of them, spelled from the fixture so the
|
|
253
|
+
// case keeps its meaning if the catalogue changes. Concatenated with no
|
|
254
|
+
// separator because adjacent rows put no whitespace between themselves.
|
|
255
|
+
CATALOGUE.slice(0, ITERATOR_COUNT)
|
|
256
|
+
.map((product) => product.name)
|
|
257
|
+
.join('')
|
|
258
|
+
]
|
|
259
|
+
])('resolves %s in the region that places it', (_case, area, expected) => {
|
|
260
|
+
registerHero();
|
|
261
|
+
renderSection(sectionOfType(CUSTOM_TYPE));
|
|
262
|
+
|
|
263
|
+
// Region by region rather than on the whole section, so a binding that
|
|
264
|
+
// resolved into the WRONG region cannot pass on the concatenated text.
|
|
265
|
+
const text = (screen.getByTestId(area).textContent ?? '')
|
|
266
|
+
.replace(/\s+/g, ' ')
|
|
267
|
+
.trim();
|
|
268
|
+
|
|
269
|
+
expect(text).toBe(expected);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('leaves the top-level block count untouched when a repeat expands', () => {
|
|
273
|
+
registerHero();
|
|
274
|
+
|
|
275
|
+
// THE INVARIANT `findRegionChild` DEPENDS ON. It matches by property over
|
|
276
|
+
// `section.blocks`, so a resolution pass that spliced a repeat's rows in as
|
|
277
|
+
// siblings would leave every later region resolving against a shifted list.
|
|
278
|
+
// `buildIteratorBlock` expands INSIDE the iterator block, and the custom
|
|
279
|
+
// branch maps one resolved block per authored block — this is what pins
|
|
280
|
+
// both halves of that.
|
|
281
|
+
const { container } = renderSection(sectionOfType(GENERIC_TYPE));
|
|
282
|
+
|
|
283
|
+
expect(
|
|
284
|
+
topLevelBlockIds(container.querySelector('[data-section-content]'))
|
|
285
|
+
).toEqual(['block-headline', 'block-lead', 'block-list']);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('still resolves each region to its own block after resolution', () => {
|
|
289
|
+
registerHero();
|
|
290
|
+
renderSection(sectionOfType(CUSTOM_TYPE));
|
|
291
|
+
|
|
292
|
+
// One block per region, still the block that carries that region's name —
|
|
293
|
+
// the repeat sitting between them expanded without displacing anything.
|
|
294
|
+
expect(topLevelBlockIds(screen.getByTestId('headline-area'))).toEqual([
|
|
295
|
+
'block-headline'
|
|
296
|
+
]);
|
|
297
|
+
expect(topLevelBlockIds(screen.getByTestId('lead-area'))).toEqual([
|
|
298
|
+
'block-lead'
|
|
299
|
+
]);
|
|
300
|
+
expect(topLevelBlockIds(screen.getByTestId('list-area'))).toEqual([
|
|
301
|
+
'block-list'
|
|
302
|
+
]);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('keeps the authored value when there is nothing to bind against', () => {
|
|
306
|
+
registerHero();
|
|
307
|
+
|
|
308
|
+
// The unconditional binding pass must not invent a value: with no data
|
|
309
|
+
// source and no page context `resolveBlockBindings` finds no binding root
|
|
310
|
+
// and returns the tree unchanged, exactly as the generic path does.
|
|
311
|
+
const withoutData = sectionOfType(CUSTOM_TYPE);
|
|
312
|
+
delete (withoutData as unknown as Record<string, unknown>).dataSource;
|
|
313
|
+
|
|
314
|
+
const custom = renderedText(withoutData);
|
|
315
|
+
const generic = renderedText(
|
|
316
|
+
(() => {
|
|
317
|
+
const genericWithoutData = sectionOfType(GENERIC_TYPE);
|
|
318
|
+
delete (genericWithoutData as unknown as Record<string, unknown>)
|
|
319
|
+
.dataSource;
|
|
320
|
+
return genericWithoutData;
|
|
321
|
+
})()
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
expect(custom).toBe(generic);
|
|
325
|
+
expect(custom).toContain('Authored headline fallback');
|
|
326
|
+
});
|
|
327
|
+
});
|