@akinon/pz-theme 2.0.79-rc.0 → 2.0.79
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 +12 -33
- package/package.json +4 -4
- package/src/chrome/__tests__/composition-source.test.ts +79 -1
- package/src/chrome/__tests__/sticky-slot.test.tsx +40 -0
- package/src/chrome/chrome-slot.tsx +10 -2
- package/src/chrome/composition-source.ts +11 -0
- package/src/chrome/preview-snapshot-source.ts +51 -0
- package/src/components/__tests__/action-toolbar.test.tsx +338 -0
- package/src/components/action-toolbar.tsx +57 -1
- package/src/load-placeholder-sections.ts +144 -54
package/CHANGELOG.md
CHANGED
|
@@ -1,38 +1,17 @@
|
|
|
1
1
|
# @akinon/pz-theme
|
|
2
2
|
|
|
3
|
-
## 2.0.79
|
|
4
|
-
|
|
5
|
-
### Patch Changes
|
|
6
|
-
|
|
7
|
-
-
|
|
8
|
-
|
|
9
|
-
-
|
|
10
|
-
|
|
11
|
-
-
|
|
12
|
-
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
- Updated dependencies [143be2b9d]
|
|
16
|
-
- Updated dependencies [7889b08fe]
|
|
17
|
-
- Updated dependencies [9f8cd3bc5]
|
|
18
|
-
- Updated dependencies [bfafa3f49]
|
|
19
|
-
- Updated dependencies [57d7eb305]
|
|
20
|
-
- Updated dependencies [d99a6a7d5]
|
|
21
|
-
- Updated dependencies [9db81a714]
|
|
22
|
-
- Updated dependencies [591e345e1]
|
|
23
|
-
- Updated dependencies [4de5303c5]
|
|
24
|
-
- Updated dependencies [95b139dc1]
|
|
25
|
-
- Updated dependencies [1d00f2d06]
|
|
26
|
-
- Updated dependencies [4ac7b2a1e]
|
|
27
|
-
- Updated dependencies [e9598c71]
|
|
28
|
-
- Updated dependencies [4998a9631]
|
|
29
|
-
- Updated dependencies [804d2bd6c]
|
|
30
|
-
- Updated dependencies [3909d3224]
|
|
31
|
-
- Updated dependencies [6a3d8a631]
|
|
32
|
-
- Updated dependencies [e18836b20]
|
|
33
|
-
- Updated dependencies [b1111d7b]
|
|
34
|
-
- @akinon/next@2.0.79-rc.0
|
|
35
|
-
- @akinon/theme-contract@2.0.79-rc.0
|
|
3
|
+
## 2.0.79
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 945e153d: ZERO-4669: Keep the designer action toolbar reachable at the top of the canvas.
|
|
8
|
+
|
|
9
|
+
The toolbar is portaled to `<body>` (ZERO-4651), so nothing can clip it any more — but hanging above its host still draws it at a negative y when the host sits at the viewport top: the first section at scroll 0, and any section in a chrome slot pinned with `position: sticky; top: 0`, which reports the same box at every scroll position and so was unreachable permanently. The toolbar now flips inside its host when there is not enough room above it on screen, while the portal frame stays pinned to the host's box.
|
|
10
|
+
|
|
11
|
+
Also adds the `@akinon/pz-theme` content globs to the app's Tailwind config: without them the toolbar's own utilities (`-top-0.5`, `top-1`, `-translate-y-full`, `bg-[#4482ff]`) are never generated, so the designer chrome rendered unstyled. The app template carries the same two lines, so scaffolded brand apps are not shipping unstyled designer chrome either.
|
|
12
|
+
|
|
13
|
+
- @akinon/next@2.0.79
|
|
14
|
+
- @akinon/theme-contract@2.0.79
|
|
36
15
|
|
|
37
16
|
## 2.0.78
|
|
38
17
|
|
package/package.json
CHANGED
|
@@ -1,26 +1,26 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akinon/pz-theme",
|
|
3
3
|
"description": "Theme package for Project Zero Next — ThemePlaceholder system and theme editor infrastructure",
|
|
4
|
-
"version": "2.0.79
|
|
4
|
+
"version": "2.0.79",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "src/index.ts",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"test": "jest"
|
|
9
9
|
},
|
|
10
10
|
"peerDependencies": {
|
|
11
|
-
"@akinon/next": "2.0.79
|
|
11
|
+
"@akinon/next": "2.0.79",
|
|
12
12
|
"react": "^18.0.0 || ^19.0.0",
|
|
13
13
|
"react-dom": "^18.0.0 || ^19.0.0"
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@akinon/theme-contract": "2.0.79
|
|
16
|
+
"@akinon/theme-contract": "2.0.79",
|
|
17
17
|
"clsx": "2.1.1",
|
|
18
18
|
"lottie-web": "^5.13.0",
|
|
19
19
|
"tailwind-merge": "^2.5.4",
|
|
20
20
|
"zod": "4.4.1"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
|
-
"@akinon/next": "2.0.79
|
|
23
|
+
"@akinon/next": "2.0.79",
|
|
24
24
|
"@testing-library/jest-dom": "5.16.4",
|
|
25
25
|
"@testing-library/react": "16.3.2",
|
|
26
26
|
"@testing-library/user-event": "14.1.1",
|
|
@@ -27,12 +27,17 @@ jest.mock('react', () => {
|
|
|
27
27
|
const getWidgetData = jest.fn();
|
|
28
28
|
const getWidgetSchemaData = jest.fn();
|
|
29
29
|
const getCollectionWidgetData = jest.fn();
|
|
30
|
+
// Named preview snapshot reader (draft mode only). Resolves null by default:
|
|
31
|
+
// "not a preview", the live path.
|
|
32
|
+
const getPreviewSnapshot = jest.fn(async () => null as unknown);
|
|
30
33
|
|
|
31
34
|
jest.mock('@akinon/next/data/server', () => ({
|
|
32
35
|
getWidgetData: (...args: unknown[]) => getWidgetData(...args),
|
|
33
36
|
getWidgetSchemaData: (...args: unknown[]) => getWidgetSchemaData(...args),
|
|
34
37
|
getCollectionWidgetData: (...args: unknown[]) =>
|
|
35
|
-
getCollectionWidgetData(...args)
|
|
38
|
+
getCollectionWidgetData(...args),
|
|
39
|
+
getPreviewSnapshot: (...args: unknown[]) =>
|
|
40
|
+
(getPreviewSnapshot as (...a: unknown[]) => unknown)(...args)
|
|
36
41
|
}));
|
|
37
42
|
|
|
38
43
|
jest.mock(
|
|
@@ -123,3 +128,76 @@ describe('shared placeholder widget fetch', () => {
|
|
|
123
128
|
expect(attempts).toBe(3);
|
|
124
129
|
});
|
|
125
130
|
});
|
|
131
|
+
|
|
132
|
+
describe('named preview snapshots', () => {
|
|
133
|
+
beforeEach(() => {
|
|
134
|
+
getWidgetData.mockReset();
|
|
135
|
+
getWidgetSchemaData.mockReset();
|
|
136
|
+
getCollectionWidgetData.mockReset();
|
|
137
|
+
getPreviewSnapshot.mockReset();
|
|
138
|
+
getPreviewSnapshot.mockResolvedValue(null);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const blob = {
|
|
142
|
+
version: 1,
|
|
143
|
+
themeSettings: { mainMenuSticky: true },
|
|
144
|
+
dataSources: [],
|
|
145
|
+
sections: [
|
|
146
|
+
{
|
|
147
|
+
id: 'sec-preview',
|
|
148
|
+
type: 'custom',
|
|
149
|
+
label: 'Preview hero',
|
|
150
|
+
properties: {},
|
|
151
|
+
styles: {},
|
|
152
|
+
blocks: [],
|
|
153
|
+
order: 0
|
|
154
|
+
}
|
|
155
|
+
]
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
it('composes the slot and loads the sections from the blob, never the live widget', async () => {
|
|
159
|
+
getPreviewSnapshot.mockResolvedValue(blob);
|
|
160
|
+
getWidgetData.mockResolvedValue(placeholderPayload('preview-slot-a'));
|
|
161
|
+
|
|
162
|
+
const state = await getSlugCompositionState('preview-slot-a');
|
|
163
|
+
const loaded = await loadPlaceholderSections('preview-slot-a');
|
|
164
|
+
|
|
165
|
+
expect(state).toEqual({ kind: 'composed', sectionCount: 1 });
|
|
166
|
+
expect(loaded?.sections.map((s) => s.id)).toEqual(['sec-preview']);
|
|
167
|
+
expect(loaded?.placeholderId).toBe('preview-slot-a');
|
|
168
|
+
expect(loaded?.themeSettings).toEqual({ mainMenuSticky: true });
|
|
169
|
+
// The blob owns the render: no per-section or theme-config reads.
|
|
170
|
+
expect(getWidgetData).not.toHaveBeenCalled();
|
|
171
|
+
expect(getWidgetSchemaData).not.toHaveBeenCalled();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('an empty blob withdraws the slot even when live is composed', async () => {
|
|
175
|
+
getPreviewSnapshot.mockResolvedValue({ ...blob, sections: [] });
|
|
176
|
+
getWidgetData.mockResolvedValue(placeholderPayload('preview-slot-b'));
|
|
177
|
+
|
|
178
|
+
expect(await getSlugCompositionState('preview-slot-b')).toEqual({
|
|
179
|
+
kind: 'empty'
|
|
180
|
+
});
|
|
181
|
+
expect((await loadPlaceholderSections('preview-slot-b'))?.sections).toEqual(
|
|
182
|
+
[]
|
|
183
|
+
);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('falls through to the live path without a blob or when the reader throws', async () => {
|
|
187
|
+
getPreviewSnapshot.mockRejectedValue(new Error('backend down'));
|
|
188
|
+
getWidgetData.mockResolvedValue(placeholderPayload('preview-slot-c'));
|
|
189
|
+
getWidgetSchemaData.mockResolvedValue({
|
|
190
|
+
schema: { 'sec-a': { metadata: { blocks: [] } } }
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
expect(await getSlugCompositionState('preview-slot-c')).toEqual({
|
|
194
|
+
kind: 'composed',
|
|
195
|
+
sectionCount: 1
|
|
196
|
+
});
|
|
197
|
+
expect(
|
|
198
|
+
(await loadPlaceholderSections('preview-slot-c'))?.sections.map(
|
|
199
|
+
(s) => s.id
|
|
200
|
+
)
|
|
201
|
+
).toEqual(['sec-a']);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
@@ -27,6 +27,14 @@ jest.mock('../theme-config-source', () => ({
|
|
|
27
27
|
getThemeConfig: () => themeConfig()
|
|
28
28
|
}));
|
|
29
29
|
|
|
30
|
+
// A named preview's snapshot owns the theme settings; null = not a preview.
|
|
31
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
32
|
+
const previewSnapshot = jest.fn<any, [string]>(() => null);
|
|
33
|
+
|
|
34
|
+
jest.mock('../preview-snapshot-source', () => ({
|
|
35
|
+
getPlaceholderPreviewSnapshot: (slug: string) => previewSnapshot(slug)
|
|
36
|
+
}));
|
|
37
|
+
|
|
30
38
|
// The placeholder is an async server component that reaches for the data
|
|
31
39
|
// layer; what the slot decides about itself is what is under test.
|
|
32
40
|
jest.mock('../../theme-placeholder', () => ({
|
|
@@ -221,3 +229,35 @@ describe('ChromeSlot sticky', () => {
|
|
|
221
229
|
expect(themeConfig).not.toHaveBeenCalled();
|
|
222
230
|
});
|
|
223
231
|
});
|
|
232
|
+
|
|
233
|
+
describe('ChromeSlot sticky in a named preview', () => {
|
|
234
|
+
afterEach(() => {
|
|
235
|
+
cleanup();
|
|
236
|
+
previewSnapshot.mockReset();
|
|
237
|
+
previewSnapshot.mockReturnValue(null);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('follows the preview snapshot, not the live theme-config', async () => {
|
|
241
|
+
compositionState.mockResolvedValue(composed);
|
|
242
|
+
themeConfig.mockResolvedValue({ settings: { mainMenuSticky: false } });
|
|
243
|
+
previewSnapshot.mockResolvedValue({
|
|
244
|
+
version: 1,
|
|
245
|
+
sections: [{ id: 'sec' }],
|
|
246
|
+
themeSettings: { mainMenuSticky: true }
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
await renderSlot({ slug: 'header', fallback: <div>brand</div> });
|
|
250
|
+
|
|
251
|
+
expect(slotElement('header').dataset.sticky).toBe('true');
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('falls back to the live theme-config when the preview has no blob', async () => {
|
|
255
|
+
compositionState.mockResolvedValue(composed);
|
|
256
|
+
themeConfig.mockResolvedValue({ settings: { mainMenuSticky: true } });
|
|
257
|
+
previewSnapshot.mockResolvedValue(null);
|
|
258
|
+
|
|
259
|
+
await renderSlot({ slug: 'header', fallback: <div>brand</div> });
|
|
260
|
+
|
|
261
|
+
expect(slotElement('header').dataset.sticky).toBe('true');
|
|
262
|
+
});
|
|
263
|
+
});
|
|
@@ -2,6 +2,7 @@ import React from 'react';
|
|
|
2
2
|
import ThemePlaceholder from '../theme-placeholder';
|
|
3
3
|
import { warnAboutUncomposedSlot } from './composition-state';
|
|
4
4
|
import { getSlugCompositionState } from './composition-source';
|
|
5
|
+
import { getPlaceholderPreviewSnapshot } from './preview-snapshot-source';
|
|
5
6
|
import { getThemeConfig } from './theme-config-source';
|
|
6
7
|
import { ChromeFallback } from './chrome-fallback';
|
|
7
8
|
import { ChromeScrollState } from './scroll-state';
|
|
@@ -79,9 +80,16 @@ export default async function ChromeSlot({
|
|
|
79
80
|
warnAboutUncomposedSlot(slug, state);
|
|
80
81
|
|
|
81
82
|
// Only read when the mounting code said nothing, and memoized per request, so
|
|
82
|
-
// a page full of slots costs at most one theme-config read.
|
|
83
|
+
// a page full of slots costs at most one theme-config read. In a named
|
|
84
|
+
// preview the snapshot blob owns the theme settings (theme-config is never
|
|
85
|
+
// preview-transformed), so the pin must follow the blob the placeholder
|
|
86
|
+
// renders from — otherwise "sticky header" toggled in the preview would
|
|
87
|
+
// only show once published.
|
|
83
88
|
const themeSettings =
|
|
84
|
-
sticky === undefined
|
|
89
|
+
sticky === undefined
|
|
90
|
+
? (await getPlaceholderPreviewSnapshot(slug))?.themeSettings ??
|
|
91
|
+
(await getThemeConfig()).settings
|
|
92
|
+
: null;
|
|
85
93
|
const pinned = resolveSlotSticky({
|
|
86
94
|
slug,
|
|
87
95
|
sticky,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
2
|
|
|
3
3
|
import { CompositionState, readCompositionState } from './composition-state';
|
|
4
|
+
import { getPlaceholderPreviewSnapshot } from './preview-snapshot-source';
|
|
4
5
|
import { getWidgetDataWithRetry } from './retrying-widget-data';
|
|
5
6
|
|
|
6
7
|
// `React.cache` exists on the RSC-capable React this package renders under;
|
|
@@ -49,6 +50,16 @@ export const getPlaceholderWidget = cache
|
|
|
49
50
|
*/
|
|
50
51
|
const load = async (slug: string): Promise<CompositionState> => {
|
|
51
52
|
try {
|
|
53
|
+
// A named preview (draft mode) composes from its snapshot, not from the
|
|
54
|
+
// live placeholder: a slug composed only in the preview must still take
|
|
55
|
+
// the slot over there, and one emptied in the preview must give it back.
|
|
56
|
+
const preview = await getPlaceholderPreviewSnapshot(slug);
|
|
57
|
+
if (preview) {
|
|
58
|
+
return preview.sections.length > 0
|
|
59
|
+
? { kind: 'composed', sectionCount: preview.sections.length }
|
|
60
|
+
: { kind: 'empty' };
|
|
61
|
+
}
|
|
62
|
+
|
|
52
63
|
const data = await getPlaceholderWidget(slug);
|
|
53
64
|
|
|
54
65
|
return readCompositionState(data);
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import * as serverData from '@akinon/next/data/server';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Whole-placeholder snapshot the theme editor writes for a named preview
|
|
6
|
+
* (`<slug>-preview-<id>`, `preview_snapshot` attribute). Sections arrive in
|
|
7
|
+
* render shape. See akinon-next's `getPreviewSnapshot` for the contract.
|
|
8
|
+
*/
|
|
9
|
+
export interface PreviewSnapshot {
|
|
10
|
+
version: number;
|
|
11
|
+
savedAt?: string;
|
|
12
|
+
themeSettings?: Record<string, unknown> | null;
|
|
13
|
+
dataSources?: unknown[];
|
|
14
|
+
sections: unknown[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type GetPreviewSnapshot = (args: {
|
|
18
|
+
slug: string;
|
|
19
|
+
}) => Promise<PreviewSnapshot | null>;
|
|
20
|
+
|
|
21
|
+
const fetchPreviewSnapshot = async (
|
|
22
|
+
slug: string
|
|
23
|
+
): Promise<PreviewSnapshot | null> => {
|
|
24
|
+
// v1 cores predate widget preview: no reader means never a preview. The
|
|
25
|
+
// reader itself answers null outside draft mode or without a preview id.
|
|
26
|
+
const fn = (
|
|
27
|
+
serverData as unknown as { getPreviewSnapshot?: GetPreviewSnapshot }
|
|
28
|
+
).getPreviewSnapshot;
|
|
29
|
+
if (!fn) return null;
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
return (await fn({ slug })) ?? null;
|
|
33
|
+
} catch {
|
|
34
|
+
// A failed preview read must fall back to the live render, never blank it.
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// `React.cache` exists on the RSC-capable React this package renders under;
|
|
40
|
+
// fall back to the raw function on older cores, where correctness is identical
|
|
41
|
+
// and the only cost is a second fetch.
|
|
42
|
+
const cache = (React as { cache?: <T>(fn: T) => T }).cache;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The one preview-snapshot read per placeholder per request — shared by the
|
|
46
|
+
* slot's composed/uncomposed decision and the section loader so the two can
|
|
47
|
+
* never disagree about which content a preview shows.
|
|
48
|
+
*/
|
|
49
|
+
export const getPlaceholderPreviewSnapshot = cache
|
|
50
|
+
? cache(fetchPreviewSnapshot)
|
|
51
|
+
: fetchPreviewSnapshot;
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render, screen, fireEvent, act } from '@testing-library/react';
|
|
3
|
+
import ActionToolbar from '../action-toolbar';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The toolbar is portaled to <body> and pinned onto its host's viewport box, so
|
|
7
|
+
* everything here is read off `document`, never off the render container, and
|
|
8
|
+
* the host has to carry `[data-block-id]` / `[data-section-id]` — that is the
|
|
9
|
+
* selector the toolbar resolves its host with.
|
|
10
|
+
*
|
|
11
|
+
* These are the placement cases; that the portal escapes every clipping
|
|
12
|
+
* declaration a composition can set is covered in
|
|
13
|
+
* `src/__tests__/designer-chrome-clipping.test.tsx`.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Mirrors the component's own constants: 40px tall + the 2px `-top-0.5`. */
|
|
17
|
+
const SPACE_NEEDED_ABOVE = 42;
|
|
18
|
+
const CHROME_Z_BASE = 2147483000;
|
|
19
|
+
|
|
20
|
+
const rectAt = (top: number, left = 40) => ({
|
|
21
|
+
top,
|
|
22
|
+
left,
|
|
23
|
+
right: left + 300,
|
|
24
|
+
bottom: top + 200,
|
|
25
|
+
width: 300,
|
|
26
|
+
height: 200,
|
|
27
|
+
x: left,
|
|
28
|
+
y: top
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A complete rect for the host and a zero rect for everything else — the shape
|
|
33
|
+
* `getBoundingClientRect` really has. A partial mock leaves `rect.left`
|
|
34
|
+
* undefined, which used to turn the every-render measure into an infinite
|
|
35
|
+
* render loop; `nonFiniteHostRect` below pins the guard that stops it.
|
|
36
|
+
*/
|
|
37
|
+
const ZERO_RECT = {
|
|
38
|
+
top: 0,
|
|
39
|
+
left: 0,
|
|
40
|
+
right: 0,
|
|
41
|
+
bottom: 0,
|
|
42
|
+
width: 0,
|
|
43
|
+
height: 0,
|
|
44
|
+
x: 0,
|
|
45
|
+
y: 0
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const mockHostRect = (rect: ReturnType<typeof rectAt>) =>
|
|
49
|
+
jest
|
|
50
|
+
.spyOn(Element.prototype, 'getBoundingClientRect')
|
|
51
|
+
.mockImplementation(function (this: Element) {
|
|
52
|
+
const value = this.hasAttribute('data-block-id') ? rect : ZERO_RECT;
|
|
53
|
+
return { ...value, toJSON: () => value } as DOMRect;
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const renderToolbar = (
|
|
57
|
+
props: Partial<React.ComponentProps<typeof ActionToolbar>> = {}
|
|
58
|
+
) =>
|
|
59
|
+
render(
|
|
60
|
+
<div data-block-id="b1">
|
|
61
|
+
<ActionToolbar label="My Section" {...props} />
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const toolbarEl = () =>
|
|
66
|
+
document.querySelector('[data-action-toolbar]') as HTMLElement;
|
|
67
|
+
|
|
68
|
+
const frameEl = () => toolbarEl().parentElement as HTMLElement;
|
|
69
|
+
|
|
70
|
+
/** Every re-measure but a re-render is coalesced into a frame. */
|
|
71
|
+
const flushFrame = () =>
|
|
72
|
+
act(() => {
|
|
73
|
+
jest.advanceTimersByTime(20);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe('ActionToolbar placement', () => {
|
|
77
|
+
beforeEach(() => jest.useFakeTimers());
|
|
78
|
+
|
|
79
|
+
afterEach(() => {
|
|
80
|
+
jest.useRealTimers();
|
|
81
|
+
jest.restoreAllMocks();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('hangs above the host when there is room above it on screen', () => {
|
|
85
|
+
mockHostRect(rectAt(200));
|
|
86
|
+
renderToolbar();
|
|
87
|
+
|
|
88
|
+
expect(toolbarEl().className).toContain('-top-0.5');
|
|
89
|
+
expect(toolbarEl().className).toContain('-translate-y-full');
|
|
90
|
+
expect(toolbarEl().className).not.toContain('top-1');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The case the portal alone does not solve. Hanging above a host at the
|
|
95
|
+
* viewport top draws the toolbar at y = -42, off-screen, and nothing scrolls
|
|
96
|
+
* above 0 — rename / duplicate / delete would be unreachable. A chrome slot
|
|
97
|
+
* pinned with `position: sticky; top: 0` reports this rect at *every* scroll
|
|
98
|
+
* position, so there it is unreachable permanently.
|
|
99
|
+
*/
|
|
100
|
+
it('flips inside the host when the host sits at the viewport top', () => {
|
|
101
|
+
mockHostRect(rectAt(0));
|
|
102
|
+
renderToolbar();
|
|
103
|
+
|
|
104
|
+
expect(toolbarEl().className).toContain('top-1');
|
|
105
|
+
expect(toolbarEl().className).not.toContain('-translate-y-full');
|
|
106
|
+
expect(toolbarEl().className).not.toContain('-top-0.5');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The flip must be expressed in the toolbar's own classes and nowhere else.
|
|
111
|
+
* Clamping or offsetting the frame instead would break the contract that the
|
|
112
|
+
* frame is pinned to the host's box
|
|
113
|
+
* (`designer-chrome-clipping.test.tsx` → 'pins a nested block toolbar…').
|
|
114
|
+
*/
|
|
115
|
+
it('leaves the frame pinned to the host box when the toolbar flips', () => {
|
|
116
|
+
mockHostRect(rectAt(0, 220));
|
|
117
|
+
renderToolbar();
|
|
118
|
+
|
|
119
|
+
expect(toolbarEl().className).toContain('top-1');
|
|
120
|
+
expect(frameEl().style.position).toBe('fixed');
|
|
121
|
+
expect(frameEl().style.top).toBe('0px');
|
|
122
|
+
expect(frameEl().style.left).toBe('220px');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('flips one pixel short of the room it needs and not one pixel over', () => {
|
|
126
|
+
mockHostRect(rectAt(SPACE_NEEDED_ABOVE - 1));
|
|
127
|
+
const short = render(
|
|
128
|
+
<div data-block-id="b1">
|
|
129
|
+
<ActionToolbar label="My Section" />
|
|
130
|
+
</div>
|
|
131
|
+
);
|
|
132
|
+
expect(toolbarEl().className).toContain('top-1');
|
|
133
|
+
short.unmount();
|
|
134
|
+
|
|
135
|
+
mockHostRect(rectAt(SPACE_NEEDED_ABOVE));
|
|
136
|
+
renderToolbar();
|
|
137
|
+
expect(toolbarEl().className).toContain('-translate-y-full');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Documented limitation, asserted so that fixing it is a deliberate change:
|
|
142
|
+
* once the host's top has scrolled *past* the viewport edge, neither branch
|
|
143
|
+
* puts the toolbar on screen — flipped it lands at `origin.top + 4`. The
|
|
144
|
+
* in-tree predecessor had the same hole. The fix is to slide the toolbar
|
|
145
|
+
* down inside the host by a computed amount, which needs the host's height
|
|
146
|
+
* carried in the measured origin.
|
|
147
|
+
*/
|
|
148
|
+
it('still flips, but cannot rescue a host scrolled above the viewport', () => {
|
|
149
|
+
mockHostRect(rectAt(-300));
|
|
150
|
+
renderToolbar();
|
|
151
|
+
|
|
152
|
+
expect(toolbarEl().className).toContain('top-1');
|
|
153
|
+
// The frame is where the host is, i.e. off-screen; the flip does not clamp.
|
|
154
|
+
expect(frameEl().style.top).toBe('-300px');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('repositions and re-evaluates placement on scroll', () => {
|
|
158
|
+
const spy = mockHostRect(rectAt(200));
|
|
159
|
+
renderToolbar();
|
|
160
|
+
expect(frameEl().style.top).toBe('200px');
|
|
161
|
+
expect(toolbarEl().className).toContain('-translate-y-full');
|
|
162
|
+
|
|
163
|
+
spy.mockRestore();
|
|
164
|
+
mockHostRect(rectAt(0));
|
|
165
|
+
fireEvent.scroll(window);
|
|
166
|
+
flushFrame();
|
|
167
|
+
|
|
168
|
+
expect(frameEl().style.top).toBe('0px');
|
|
169
|
+
expect(toolbarEl().className).toContain('top-1');
|
|
170
|
+
expect(toolbarEl().className).not.toContain('-translate-y-full');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('repositions and re-evaluates placement on resize', () => {
|
|
174
|
+
const spy = mockHostRect(rectAt(0));
|
|
175
|
+
renderToolbar();
|
|
176
|
+
expect(frameEl().style.top).toBe('0px');
|
|
177
|
+
expect(toolbarEl().className).toContain('top-1');
|
|
178
|
+
|
|
179
|
+
spy.mockRestore();
|
|
180
|
+
mockHostRect(rectAt(200));
|
|
181
|
+
fireEvent.resize(window);
|
|
182
|
+
flushFrame();
|
|
183
|
+
|
|
184
|
+
expect(frameEl().style.top).toBe('200px');
|
|
185
|
+
expect(toolbarEl().className).toContain('-translate-y-full');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Move Up / Move Down are buttons on this toolbar: they move the host to a
|
|
190
|
+
* different y without resizing it, scrolling it or touching its attributes,
|
|
191
|
+
* so no listener fires. The canvas re-renders, so a render has to be a
|
|
192
|
+
* measuring point too.
|
|
193
|
+
*/
|
|
194
|
+
it('re-evaluates placement on a re-render that moved the host', () => {
|
|
195
|
+
const spy = mockHostRect(rectAt(200));
|
|
196
|
+
const { rerender } = renderToolbar();
|
|
197
|
+
expect(toolbarEl().className).toContain('-translate-y-full');
|
|
198
|
+
|
|
199
|
+
spy.mockRestore();
|
|
200
|
+
mockHostRect(rectAt(0));
|
|
201
|
+
rerender(
|
|
202
|
+
<div data-block-id="b1">
|
|
203
|
+
<ActionToolbar label="My Section" isLocked />
|
|
204
|
+
</div>
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
expect(toolbarEl().className).toContain('top-1');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* The measure runs on every render and its only brake is an unchanged
|
|
212
|
+
* origin, so a coordinate that is not a number (`NaN !== NaN`) would set a
|
|
213
|
+
* fresh origin every commit and render forever.
|
|
214
|
+
*/
|
|
215
|
+
it('ignores an unreadable rect instead of re-rendering forever', () => {
|
|
216
|
+
mockHostRect(rectAt(200));
|
|
217
|
+
renderToolbar();
|
|
218
|
+
expect(frameEl().style.top).toBe('200px');
|
|
219
|
+
|
|
220
|
+
jest.restoreAllMocks();
|
|
221
|
+
jest
|
|
222
|
+
.spyOn(Element.prototype, 'getBoundingClientRect')
|
|
223
|
+
.mockReturnValue({ top: 10 } as DOMRect);
|
|
224
|
+
|
|
225
|
+
expect(() => {
|
|
226
|
+
fireEvent.scroll(window);
|
|
227
|
+
flushFrame();
|
|
228
|
+
}).not.toThrow();
|
|
229
|
+
|
|
230
|
+
// Left exactly where the last good measurement put it.
|
|
231
|
+
expect(frameEl().style.top).toBe('200px');
|
|
232
|
+
expect(frameEl().style.left).toBe('40px');
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('marks the toolbar with data-action-toolbar for outside-click guards', () => {
|
|
236
|
+
mockHostRect(rectAt(200));
|
|
237
|
+
renderToolbar();
|
|
238
|
+
|
|
239
|
+
// `accordion-block` reads this off the click target to tell chrome clicks
|
|
240
|
+
// from content clicks; portaled events still bubble through the React tree.
|
|
241
|
+
expect(toolbarEl()).toBeInTheDocument();
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
describe('ActionToolbar stacking', () => {
|
|
246
|
+
beforeEach(() => jest.useFakeTimers());
|
|
247
|
+
|
|
248
|
+
afterEach(() => {
|
|
249
|
+
jest.useRealTimers();
|
|
250
|
+
jest.restoreAllMocks();
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* At <body> the toolbar no longer inherits the host's stacking context, so
|
|
255
|
+
* the frame carries an absolute z-index and `zIndex` is only the relative
|
|
256
|
+
* order of block chrome (default 30) over section chrome (20).
|
|
257
|
+
*/
|
|
258
|
+
it('stacks the frame above page chrome and leaves the toolbar unstyled', () => {
|
|
259
|
+
mockHostRect(rectAt(200));
|
|
260
|
+
renderToolbar();
|
|
261
|
+
|
|
262
|
+
expect(frameEl().style.zIndex).toBe(String(CHROME_Z_BASE + 30));
|
|
263
|
+
expect(toolbarEl().style.zIndex).toBe('');
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('orders section chrome below block chrome', () => {
|
|
267
|
+
mockHostRect(rectAt(200));
|
|
268
|
+
renderToolbar({ zIndex: 20 });
|
|
269
|
+
|
|
270
|
+
expect(frameEl().style.zIndex).toBe(String(CHROME_Z_BASE + 20));
|
|
271
|
+
expect(Number(frameEl().style.zIndex)).toBeLessThan(CHROME_Z_BASE + 30);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* `zIndex` is added to a base that already sits just under the 32-bit
|
|
276
|
+
* maximum browsers clamp z-index to, so only a small range is usable: a
|
|
277
|
+
* larger value silently ties with everything else past the clamp instead of
|
|
278
|
+
* ordering above it.
|
|
279
|
+
*/
|
|
280
|
+
it('keeps the default inside the range a browser can represent', () => {
|
|
281
|
+
mockHostRect(rectAt(200));
|
|
282
|
+
renderToolbar();
|
|
283
|
+
|
|
284
|
+
expect(Number(frameEl().style.zIndex)).toBeLessThanOrEqual(2147483647);
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
describe('ActionToolbar rename', () => {
|
|
289
|
+
beforeEach(() => jest.useFakeTimers());
|
|
290
|
+
|
|
291
|
+
afterEach(() => {
|
|
292
|
+
jest.useRealTimers();
|
|
293
|
+
jest.restoreAllMocks();
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('starts rename on label click and commits the trimmed value on Enter', () => {
|
|
297
|
+
mockHostRect(rectAt(200));
|
|
298
|
+
const onRename = jest.fn();
|
|
299
|
+
renderToolbar({ onRename });
|
|
300
|
+
|
|
301
|
+
fireEvent.click(screen.getByText('My Section'));
|
|
302
|
+
const input = screen.getByDisplayValue('My Section');
|
|
303
|
+
fireEvent.change(input, { target: { value: ' Renamed ' } });
|
|
304
|
+
fireEvent.keyDown(input, { key: 'Enter' });
|
|
305
|
+
|
|
306
|
+
expect(onRename).toHaveBeenCalledWith('Renamed');
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('cancels rename on Escape without calling onRename', () => {
|
|
310
|
+
mockHostRect(rectAt(200));
|
|
311
|
+
const onRename = jest.fn();
|
|
312
|
+
renderToolbar({ onRename });
|
|
313
|
+
|
|
314
|
+
fireEvent.click(screen.getByText('My Section'));
|
|
315
|
+
const input = screen.getByDisplayValue('My Section');
|
|
316
|
+
fireEvent.change(input, { target: { value: 'Changed' } });
|
|
317
|
+
fireEvent.keyDown(input, { key: 'Escape' });
|
|
318
|
+
|
|
319
|
+
expect(onRename).not.toHaveBeenCalled();
|
|
320
|
+
expect(screen.getByText('My Section')).toBeInTheDocument();
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* The renaming input's `border-2` makes the control strip 44px, so a
|
|
325
|
+
* measured height would move the flip threshold mid-interaction and could
|
|
326
|
+
* pull the toolbar out from under the cursor. The height is a constant
|
|
327
|
+
* precisely so it cannot.
|
|
328
|
+
*/
|
|
329
|
+
it('does not move the toolbar when a rename starts', () => {
|
|
330
|
+
mockHostRect(rectAt(200));
|
|
331
|
+
renderToolbar({ onRename: jest.fn() });
|
|
332
|
+
const before = toolbarEl().className;
|
|
333
|
+
|
|
334
|
+
fireEvent.click(screen.getByText('My Section'));
|
|
335
|
+
|
|
336
|
+
expect(toolbarEl().className).toBe(before);
|
|
337
|
+
});
|
|
338
|
+
});
|
|
@@ -30,6 +30,27 @@ const HOST_SELECTOR = '[data-block-id],[data-section-id]';
|
|
|
30
30
|
*/
|
|
31
31
|
const CHROME_Z_BASE = 2147483000;
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* The toolbar's own height, as a constant rather than a measurement: it is
|
|
35
|
+
* fully determined by this file's markup — the control strip is `h-10` and the
|
|
36
|
+
* label column is a `text-sm` 20px line box inside `p-2.5` — and nothing about
|
|
37
|
+
* the host or the composition can change it. Measuring would need a ref on an
|
|
38
|
+
* element that does not exist until `origin` is known, i.e. a second pass and
|
|
39
|
+
* a frame of wrong placement, and it would make the placement depend on
|
|
40
|
+
* `isRenaming` (the input's `border-2` makes the row 44px), so starting a
|
|
41
|
+
* rename could move the toolbar out from under the cursor.
|
|
42
|
+
*/
|
|
43
|
+
const TOOLBAR_HEIGHT = 40;
|
|
44
|
+
|
|
45
|
+
/** The `-top-0.5` nudge above the host's edge, in pixels. */
|
|
46
|
+
const TOOLBAR_TOP_OFFSET = 2;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Room the toolbar needs above the host to be fully on screen. Hanging above,
|
|
50
|
+
* it occupies `[origin.top - 42, origin.top - 2]`.
|
|
51
|
+
*/
|
|
52
|
+
const SPACE_NEEDED_ABOVE = TOOLBAR_HEIGHT + TOOLBAR_TOP_OFFSET;
|
|
53
|
+
|
|
33
54
|
// `useLayoutEffect` would warn during SSR; the toolbar only ever mounts in the
|
|
34
55
|
// designer, but the module is part of a server-rendered package.
|
|
35
56
|
const useIsomorphicLayoutEffect =
|
|
@@ -140,6 +161,13 @@ export default function ActionToolbar({
|
|
|
140
161
|
left: rect.left + host.clientLeft
|
|
141
162
|
};
|
|
142
163
|
|
|
164
|
+
// A coordinate that is not a number would defeat the bail-out below
|
|
165
|
+
// (`NaN !== NaN`), and this effect runs on every render — so the measure
|
|
166
|
+
// would set a fresh origin every commit and re-render forever. Leaving
|
|
167
|
+
// the toolbar where it is is also the right answer: an unreadable rect
|
|
168
|
+
// is a failed measurement, not a moved host.
|
|
169
|
+
if (!Number.isFinite(next.top) || !Number.isFinite(next.left)) return;
|
|
170
|
+
|
|
143
171
|
setOrigin((previous) =>
|
|
144
172
|
previous && previous.top === next.top && previous.left === next.left
|
|
145
173
|
? previous
|
|
@@ -232,10 +260,38 @@ export default function ActionToolbar({
|
|
|
232
260
|
handleCancelRename();
|
|
233
261
|
}
|
|
234
262
|
};
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Portaled to <body>, no ancestor can clip the toolbar any more, so the one
|
|
266
|
+
* reason left to draw it *inside* its host is that there is no room above it
|
|
267
|
+
* on screen: a host whose top is at the viewport edge would have its toolbar
|
|
268
|
+
* drawn at a negative y, painted off-screen with no scroll position that
|
|
269
|
+
* reveals it — and rename / duplicate / delete exist nowhere else. Two hosts
|
|
270
|
+
* sit there in the canvas: the top-most section at scroll 0, and anything in
|
|
271
|
+
* a chrome slot pinned with `position: sticky; top: 0`, which reports
|
|
272
|
+
* `rect.top === 0` at every scroll position and so is unreachable
|
|
273
|
+
* permanently.
|
|
274
|
+
*
|
|
275
|
+
* This covers `0 <= origin.top < SPACE_NEEDED_ABOVE` only. A host whose top
|
|
276
|
+
* has scrolled *past* the viewport edge keeps an off-screen toolbar, exactly
|
|
277
|
+
* as it did when the toolbar rendered in-tree; fixing that means sliding the
|
|
278
|
+
* toolbar down inside the host by a computed amount, which needs the host's
|
|
279
|
+
* height carried in `origin`.
|
|
280
|
+
*
|
|
281
|
+
* Derived from `origin` during render rather than kept in its own state:
|
|
282
|
+
* `origin` is the only input, so a second state set from an effect would buy
|
|
283
|
+
* an extra render and one painted frame in the old place. It cannot feed
|
|
284
|
+
* back either — the toolbar is `fixed` inside a zero-size frame at <body>
|
|
285
|
+
* and contributes no layout, so flipping it can never move the host.
|
|
286
|
+
*/
|
|
287
|
+
const placeInside = origin !== null && origin.top < SPACE_NEEDED_ABOVE;
|
|
288
|
+
|
|
235
289
|
const toolbar = (
|
|
236
290
|
<div
|
|
237
291
|
data-action-toolbar=""
|
|
238
|
-
className=
|
|
292
|
+
className={`absolute left-0 flex items-center pointer-events-auto gap-1 min-w-max ${
|
|
293
|
+
placeInside ? 'top-1' : '-top-0.5 -translate-y-full'
|
|
294
|
+
}`}
|
|
239
295
|
>
|
|
240
296
|
<div className="flex items-center gap-1">
|
|
241
297
|
{isRenaming ? (
|
|
@@ -8,6 +8,10 @@ import { Section } from './theme-section';
|
|
|
8
8
|
import { devWarn } from './utils/dev-warn';
|
|
9
9
|
import { validateThemeSectionsInDev } from './schemas/theme-schema';
|
|
10
10
|
import { getPlaceholderWidget } from './chrome/composition-source';
|
|
11
|
+
import {
|
|
12
|
+
getPlaceholderPreviewSnapshot,
|
|
13
|
+
PreviewSnapshot
|
|
14
|
+
} from './chrome/preview-snapshot-source';
|
|
11
15
|
import { getThemeConfig, ThemeConfig } from './chrome/theme-config-source';
|
|
12
16
|
|
|
13
17
|
const { getWidgetData } = serverData;
|
|
@@ -58,6 +62,135 @@ const getWidgetSchemaData = async <T,>(args: {
|
|
|
58
62
|
return directFetch<T>(`/widgets/${args.widgetSlug}/schema/`);
|
|
59
63
|
};
|
|
60
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Resolves a section's data source reference into the object the renderer
|
|
67
|
+
* expects, fetching collection data for collection-backed sources. Shared by
|
|
68
|
+
* the live render path and the preview-snapshot path so the two cannot drift.
|
|
69
|
+
*/
|
|
70
|
+
async function resolveSectionDataSource(
|
|
71
|
+
dataSourceId: string | undefined,
|
|
72
|
+
dataSources: ThemeConfig['dataSources'],
|
|
73
|
+
sectionSlug: string
|
|
74
|
+
): Promise<any | undefined> {
|
|
75
|
+
if (!dataSourceId) {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const dataSource = dataSources.find((ds) => ds.id === dataSourceId);
|
|
80
|
+
|
|
81
|
+
if (!dataSource) {
|
|
82
|
+
devWarn(
|
|
83
|
+
'ThemePlaceholder',
|
|
84
|
+
`Section "${sectionSlug}" references data source "${dataSourceId}" which was not found; rendering placeholder items`
|
|
85
|
+
);
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!dataSource.details) {
|
|
90
|
+
return dataSource;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Only fetch collection data if we have a valid widget slug
|
|
94
|
+
// (not the section's own slug which contains config, not products)
|
|
95
|
+
const collectionSlug = dataSource.details.collection?.slug;
|
|
96
|
+
if (!collectionSlug) {
|
|
97
|
+
// The saved products from the editor (details.collection.products)
|
|
98
|
+
// will be used as fallback
|
|
99
|
+
return dataSource;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
let collectionData = await getWidgetData<{
|
|
104
|
+
[key: string]: unknown;
|
|
105
|
+
}>({
|
|
106
|
+
slug: collectionSlug
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
if (!collectionData) {
|
|
110
|
+
collectionData = await getCollectionWidgetData<{
|
|
111
|
+
[key: string]: unknown;
|
|
112
|
+
}>({
|
|
113
|
+
slug: collectionSlug
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (collectionData) {
|
|
118
|
+
return {
|
|
119
|
+
...dataSource,
|
|
120
|
+
details: {
|
|
121
|
+
...dataSource.details,
|
|
122
|
+
collection: {
|
|
123
|
+
...dataSource.details.collection,
|
|
124
|
+
data: collectionData
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
} catch (error) {
|
|
130
|
+
console.error(
|
|
131
|
+
`Error fetching widget data for section ${sectionSlug}:`,
|
|
132
|
+
error
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return dataSource;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Preview (draft mode): the theme editor writes the whole placeholder as one
|
|
141
|
+
* snapshot blob — sections arrive in render shape, so none of the per-widget
|
|
142
|
+
* fetches of the live path happen. The blob also owns the theme settings and
|
|
143
|
+
* data sources it was saved with.
|
|
144
|
+
*/
|
|
145
|
+
async function loadFromPreviewSnapshot(
|
|
146
|
+
slug: string,
|
|
147
|
+
snapshot: PreviewSnapshot
|
|
148
|
+
): Promise<LoadedPlaceholder> {
|
|
149
|
+
const dataSources: ThemeConfig['dataSources'] = Array.isArray(
|
|
150
|
+
snapshot.dataSources
|
|
151
|
+
)
|
|
152
|
+
? (snapshot.dataSources as any[])
|
|
153
|
+
: [];
|
|
154
|
+
|
|
155
|
+
const resolved = await Promise.all(
|
|
156
|
+
snapshot.sections.map(async (raw: any): Promise<Section | null> => {
|
|
157
|
+
if (!raw?.id) {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
id: raw.id,
|
|
163
|
+
type: raw.type || 'default',
|
|
164
|
+
name: raw.name || raw.id,
|
|
165
|
+
label: raw.label || raw.id,
|
|
166
|
+
properties: raw.properties || {},
|
|
167
|
+
styles: raw.styles || {},
|
|
168
|
+
blocks: Array.isArray(raw.blocks) ? raw.blocks : [],
|
|
169
|
+
order: raw.order ?? 0,
|
|
170
|
+
hidden: raw.hidden || false,
|
|
171
|
+
dataSourceId: raw.dataSourceId,
|
|
172
|
+
dataSource: await resolveSectionDataSource(
|
|
173
|
+
raw.dataSourceId,
|
|
174
|
+
dataSources,
|
|
175
|
+
raw.id
|
|
176
|
+
)
|
|
177
|
+
};
|
|
178
|
+
})
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
const sections = resolved.filter((s): s is Section => s !== null);
|
|
182
|
+
|
|
183
|
+
validateThemeSectionsInDev(sections);
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
sections,
|
|
187
|
+
placeholderId: slug,
|
|
188
|
+
dataSources,
|
|
189
|
+
themeSettings:
|
|
190
|
+
(snapshot.themeSettings as Record<string, unknown> | null) ?? null
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
61
194
|
export interface LoadedPlaceholder {
|
|
62
195
|
sections: Section[];
|
|
63
196
|
placeholderId: string;
|
|
@@ -81,6 +214,12 @@ export async function loadPlaceholderSections(
|
|
|
81
214
|
slug: string
|
|
82
215
|
): Promise<LoadedPlaceholder | null> {
|
|
83
216
|
try {
|
|
217
|
+
// Absent/malformed blob (or no draft mode) falls through to the live path.
|
|
218
|
+
const previewSnapshot = await getPlaceholderPreviewSnapshot(slug);
|
|
219
|
+
if (previewSnapshot) {
|
|
220
|
+
return loadFromPreviewSnapshot(slug, previewSnapshot);
|
|
221
|
+
}
|
|
222
|
+
|
|
84
223
|
// theme-config is optional for placeholder rendering, and the reader is
|
|
85
224
|
// memoized per request — a page with several placeholders, or a ChromeSlot
|
|
86
225
|
// asking the same question to decide whether it is pinned, reads it once.
|
|
@@ -224,60 +363,11 @@ export async function loadPlaceholderSections(
|
|
|
224
363
|
(getBlockValue('dataSourceId') as string | undefined) ||
|
|
225
364
|
(getBlockValue('selectedDataSourceId') as string | undefined);
|
|
226
365
|
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
devWarn(
|
|
233
|
-
'ThemePlaceholder',
|
|
234
|
-
`Section "${sectionSlug}" references data source "${dataSourceId}" which was not found; rendering placeholder items`
|
|
235
|
-
);
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
let dataSourceWithData = dataSource;
|
|
239
|
-
if (dataSource && dataSource.details) {
|
|
240
|
-
// Only fetch collection data if we have a valid widget slug
|
|
241
|
-
// (not the section's own slug which contains config, not products)
|
|
242
|
-
const collectionSlug = dataSource.details.collection?.slug;
|
|
243
|
-
if (collectionSlug) {
|
|
244
|
-
try {
|
|
245
|
-
let collectionData = await getWidgetData<{
|
|
246
|
-
[key: string]: unknown;
|
|
247
|
-
}>({
|
|
248
|
-
slug: collectionSlug
|
|
249
|
-
});
|
|
250
|
-
|
|
251
|
-
if (!collectionData) {
|
|
252
|
-
collectionData = await getCollectionWidgetData<{
|
|
253
|
-
[key: string]: unknown;
|
|
254
|
-
}>({
|
|
255
|
-
slug: collectionSlug
|
|
256
|
-
});
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
if (collectionData) {
|
|
260
|
-
dataSourceWithData = {
|
|
261
|
-
...dataSource,
|
|
262
|
-
details: {
|
|
263
|
-
...dataSource.details,
|
|
264
|
-
collection: {
|
|
265
|
-
...dataSource.details.collection,
|
|
266
|
-
data: collectionData
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
};
|
|
270
|
-
}
|
|
271
|
-
} catch (error) {
|
|
272
|
-
console.error(
|
|
273
|
-
`Error fetching widget data for section ${sectionSlug}:`,
|
|
274
|
-
error
|
|
275
|
-
);
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
// If no collection slug, the saved products from editor
|
|
279
|
-
// (details.collection.products) will be used as fallback
|
|
280
|
-
}
|
|
366
|
+
const dataSourceWithData = await resolveSectionDataSource(
|
|
367
|
+
dataSourceId,
|
|
368
|
+
dataSources,
|
|
369
|
+
sectionSlug
|
|
370
|
+
);
|
|
281
371
|
|
|
282
372
|
const section: Section = {
|
|
283
373
|
id: sectionSlug,
|