@akinon/pz-theme 2.0.43-rc.0 → 2.0.43
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 +7 -31
- package/package.json +4 -4
- package/src/__tests__/checkout-pieces.test.tsx +420 -0
- package/src/__tests__/chrome-empty-zone.test.tsx +3 -3
- package/src/__tests__/code-owned-region.test.tsx +30 -6
- package/src/__tests__/composable-region.test.tsx +481 -0
- package/src/__tests__/part-styles.test.ts +24 -4
- package/src/__tests__/registry-parity.test.ts +3 -0
- package/src/__tests__/route-slug.test.ts +11 -0
- package/src/blocks/__tests__/checkout-outlet-blocks.test.tsx +623 -0
- package/src/blocks/block-renderer-registry.tsx +6 -0
- package/src/blocks/checkout-flow-outlet-block.tsx +104 -0
- package/src/blocks/checkout-outlet-base.tsx +241 -0
- package/src/blocks/checkout-steps-outlet-block.tsx +57 -0
- package/src/blocks/checkout-summary-outlet-block.tsx +43 -0
- package/src/chrome/checkout-piece.tsx +145 -0
- package/src/chrome/checkout-pieces-provider.tsx +28 -0
- package/src/chrome/checkout-pieces.ts +173 -0
- package/src/chrome/chrome-fallback.tsx +8 -26
- package/src/chrome/chrome-slot.tsx +25 -26
- package/src/chrome/code-owned-region.tsx +21 -9
- package/src/chrome/composable-region-client.tsx +228 -0
- package/src/chrome/composable-region.tsx +58 -0
- package/src/chrome/composition-source.ts +34 -0
- package/src/chrome/composition-state.ts +49 -0
- package/src/chrome/live-checkout-page-context.tsx +3 -2
- package/src/chrome/placeholder-slugs.ts +14 -4
- package/src/chrome/route-slug.ts +6 -3
- package/src/hooks/use-designer-features.tsx +41 -10
- package/src/index.ts +6 -0
- package/src/utils/__tests__/checkout-safety.test.ts +374 -0
- package/src/utils/checkout-safety.ts +177 -0
- package/src/utils/index.ts +47 -8
- package/src/utils/part-styles.ts +4 -0
- package/src/utils/visibility-rules.ts +31 -8
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { getWidgetData } from '@akinon/next/data/server';
|
|
3
|
+
|
|
4
|
+
import { CompositionState, readCompositionState } from './composition-state';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The one server-side answer to "is this slug composed?".
|
|
8
|
+
*
|
|
9
|
+
* Two things now ask it for the same slug in one render — the ChromeSlot that
|
|
10
|
+
* decides whether to render the composition, and the ComposableRegion that
|
|
11
|
+
* decides whether to still offer the takeover — and asking twice would mean two
|
|
12
|
+
* requests for one placeholder on every checkout render. Memoizing per request
|
|
13
|
+
* also guarantees the two cannot reach opposite conclusions from two answers.
|
|
14
|
+
*/
|
|
15
|
+
const load = async (slug: string): Promise<CompositionState> => {
|
|
16
|
+
try {
|
|
17
|
+
// The generic is the widget's attributes bag, not the widget wrapper
|
|
18
|
+
const data = await getWidgetData<{
|
|
19
|
+
theme_editor_placeholder?: unknown;
|
|
20
|
+
}>({ slug });
|
|
21
|
+
|
|
22
|
+
return readCompositionState(data);
|
|
23
|
+
} catch {
|
|
24
|
+
// No chrome widget answered — the caller keeps the project's own markup.
|
|
25
|
+
return { kind: 'missing' };
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// `React.cache` exists on the RSC-capable React this package renders under;
|
|
30
|
+
// fall back to the raw function on older cores, where correctness is identical
|
|
31
|
+
// and the only cost is a second fetch.
|
|
32
|
+
const cache = (React as { cache?: <T>(fn: T) => T }).cache;
|
|
33
|
+
|
|
34
|
+
export const getSlugCompositionState = cache ? cache(load) : load;
|
|
@@ -58,6 +58,55 @@ export const readCompositionState = (data: WidgetLike): CompositionState => {
|
|
|
58
58
|
return { kind: 'composed', sectionCount: layout.length };
|
|
59
59
|
};
|
|
60
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Whether the theme the editor just pushed composes this slug.
|
|
63
|
+
*
|
|
64
|
+
* The server decides takeover once, at request time, from the persisted order;
|
|
65
|
+
* the designer has to keep deciding it as the theme changes under the preview.
|
|
66
|
+
* Two components now ask that question — the fallback that steps aside and the
|
|
67
|
+
* region that offers the takeover — and they must never disagree about one
|
|
68
|
+
* slug, so the rule lives here rather than in either of them.
|
|
69
|
+
*
|
|
70
|
+
* Returns `null` when the frame says nothing about placeholders at all: that is
|
|
71
|
+
* not "uncomposed", it is "no answer", and the caller must leave its state
|
|
72
|
+
* alone rather than withdraw on an unrelated message.
|
|
73
|
+
*/
|
|
74
|
+
export const isSlugComposedInFrame = (
|
|
75
|
+
frameData: unknown,
|
|
76
|
+
slug: string
|
|
77
|
+
): boolean | null => {
|
|
78
|
+
const placeholders = (
|
|
79
|
+
frameData as { theme?: { placeholders?: unknown } } | undefined
|
|
80
|
+
)?.theme?.placeholders;
|
|
81
|
+
|
|
82
|
+
if (!Array.isArray(placeholders)) return null;
|
|
83
|
+
|
|
84
|
+
const placeholder = placeholders.find(
|
|
85
|
+
(item: { slug?: string; id?: string }) =>
|
|
86
|
+
item?.slug === slug || item?.id === slug
|
|
87
|
+
);
|
|
88
|
+
const sections = placeholder?.sections;
|
|
89
|
+
|
|
90
|
+
// Mirror the server's takeover rule exactly: only a *composed* section
|
|
91
|
+
// replaces the project markup, and a composed section is one that reached
|
|
92
|
+
// the saved order.
|
|
93
|
+
//
|
|
94
|
+
// The `native` exclusion guards against the retired native widget path
|
|
95
|
+
// rather than supporting it. That path styled a project component in place
|
|
96
|
+
// instead of replacing it, so its sections never entered the order; the
|
|
97
|
+
// runtime is gone, but the editor can still hold such a section in a stored
|
|
98
|
+
// theme and push it here, and withdrawing the project markup for one would
|
|
99
|
+
// show the designer an empty area no visitor ever sees. It goes when the
|
|
100
|
+
// editor-side machinery does.
|
|
101
|
+
return (
|
|
102
|
+
Array.isArray(sections) &&
|
|
103
|
+
sections.some(
|
|
104
|
+
(section: { hidden?: boolean; type?: string }) =>
|
|
105
|
+
!section?.hidden && section?.type !== 'native'
|
|
106
|
+
)
|
|
107
|
+
);
|
|
108
|
+
};
|
|
109
|
+
|
|
61
110
|
/**
|
|
62
111
|
* Dev-only explanation of a slot that kept the brand markup. One line per slug
|
|
63
112
|
* per process (see `devWarn`), so a brand with no compositions at all does not
|
|
@@ -14,8 +14,9 @@ import { buildCheckoutPageContext } from './page-context';
|
|
|
14
14
|
* composition authored on the basket page keeps resolving here and the
|
|
15
15
|
* shipping/total figures that live only on the pre-order become bindable.
|
|
16
16
|
*
|
|
17
|
-
* The flow
|
|
18
|
-
*
|
|
17
|
+
* The flow's internals stay code-owned — this provider gives the zones data;
|
|
18
|
+
* placement of the flow is composed via checkout outlet blocks, never its
|
|
19
|
+
* behavior.
|
|
19
20
|
*/
|
|
20
21
|
export const LiveCheckoutPageContext = ({
|
|
21
22
|
path,
|
|
@@ -40,12 +40,22 @@ export const PLACEHOLDER_SLUGS = {
|
|
|
40
40
|
accountNotifications: 'account-notifications-body',
|
|
41
41
|
accountStockAlerts: 'account-stock-alerts-body',
|
|
42
42
|
|
|
43
|
-
// Order flow. Checkout
|
|
44
|
-
//
|
|
45
|
-
//
|
|
43
|
+
// Order flow. Checkout internals stay sealed — payment forms, 3DS, agreements
|
|
44
|
+
// and place-order exist only in code — but PLACEMENT is composable: the body
|
|
45
|
+
// composition arranges where the real flow, steps and summary render via
|
|
46
|
+
// checkout outlet blocks.
|
|
47
|
+
//
|
|
48
|
+
// Two zones only, and they mean exactly what they say: a band above the flow
|
|
49
|
+
// and a band below it, for a brand that wants trust content without
|
|
50
|
+
// rebuilding the page. A third "summary" zone existed briefly and was
|
|
51
|
+
// removed: it rendered as a second full-width band under the flow (the
|
|
52
|
+
// summary column is client-owned, so a slot could never land inside it), so
|
|
53
|
+
// it was indistinguishable from the bottom band — and anything meant to sit
|
|
54
|
+
// beside the summary now belongs in the body composition, around the
|
|
55
|
+
// summary outlet.
|
|
56
|
+
checkoutBody: 'checkout-page-body',
|
|
46
57
|
checkoutTop: 'checkout-top-body',
|
|
47
58
|
checkoutBottom: 'checkout-bottom-body',
|
|
48
|
-
checkoutSummary: 'checkout-summary-body',
|
|
49
59
|
orderSuccess: 'order-success-body',
|
|
50
60
|
orderTracking: 'order-tracking-body',
|
|
51
61
|
|
package/src/chrome/route-slug.ts
CHANGED
|
@@ -66,9 +66,9 @@ const DEFAULT_RULES: RouteSlugRule[] = [
|
|
|
66
66
|
|
|
67
67
|
// Order flow
|
|
68
68
|
[/^\/orders\/completed/, PLACEHOLDER_SLUGS.orderSuccess],
|
|
69
|
-
// Checkout
|
|
70
|
-
// (
|
|
71
|
-
[/^\/orders\/checkout/, PLACEHOLDER_SLUGS.
|
|
69
|
+
// Checkout body: the composition arranges WHERE the code-owned flow renders
|
|
70
|
+
// (via checkout outlet blocks); it can never replace the flow itself.
|
|
71
|
+
[/^\/orders\/checkout/, PLACEHOLDER_SLUGS.checkoutBody],
|
|
72
72
|
[/^\/anonymous-(orders|tracking)/, PLACEHOLDER_SLUGS.orderTracking],
|
|
73
73
|
|
|
74
74
|
// Commerce
|
|
@@ -99,6 +99,9 @@ const PAGE_MOUNTED_SLUGS = new Set<string>([
|
|
|
99
99
|
PLACEHOLDER_SLUGS.productListing,
|
|
100
100
|
PLACEHOLDER_SLUGS.basket,
|
|
101
101
|
PLACEHOLDER_SLUGS.orderSuccess,
|
|
102
|
+
// The checkout layout mounts this slot itself. A root catch-all must skip it:
|
|
103
|
+
// mounting it with fallback={children} would make the payment flow replaceable.
|
|
104
|
+
PLACEHOLDER_SLUGS.checkoutBody,
|
|
102
105
|
PLACEHOLDER_SLUGS.accountShell,
|
|
103
106
|
PLACEHOLDER_SLUGS.accountMenu,
|
|
104
107
|
PLACEHOLDER_SLUGS.accountDashboard,
|
|
@@ -18,6 +18,40 @@ interface UseDesignerFeaturesProps {
|
|
|
18
18
|
blockInfo?: BlockInfo;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Posts the canvas's block/part selection to the editor. Extracted so
|
|
23
|
+
* renderers that manage their own designer click handling (the checkout
|
|
24
|
+
* outlet blocks select parts inside TELEPORTED code-owned markup, where no
|
|
25
|
+
* React handler of ours exists) reuse the exact message the wrapper sends.
|
|
26
|
+
*/
|
|
27
|
+
export function postBlockSelection({
|
|
28
|
+
placeholderId,
|
|
29
|
+
sectionId,
|
|
30
|
+
blockId,
|
|
31
|
+
blockType,
|
|
32
|
+
blockLabel,
|
|
33
|
+
partName
|
|
34
|
+
}: {
|
|
35
|
+
placeholderId: string;
|
|
36
|
+
sectionId: string;
|
|
37
|
+
blockId: string;
|
|
38
|
+
blockType?: string;
|
|
39
|
+
blockLabel?: string;
|
|
40
|
+
partName?: string | null;
|
|
41
|
+
}) {
|
|
42
|
+
postToEditor({
|
|
43
|
+
type: 'SELECT_BLOCK',
|
|
44
|
+
data: {
|
|
45
|
+
placeholderId,
|
|
46
|
+
sectionId,
|
|
47
|
+
blockId,
|
|
48
|
+
blockType,
|
|
49
|
+
blockLabel,
|
|
50
|
+
partName: partName || undefined
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
21
55
|
export function useDesignerFeatures({
|
|
22
56
|
blockId,
|
|
23
57
|
placeholderId,
|
|
@@ -46,16 +80,13 @@ export function useDesignerFeatures({
|
|
|
46
80
|
: null;
|
|
47
81
|
|
|
48
82
|
if (window.parent) {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
blockLabel: blockInfo?.label,
|
|
57
|
-
partName: partName || undefined
|
|
58
|
-
}
|
|
83
|
+
postBlockSelection({
|
|
84
|
+
placeholderId,
|
|
85
|
+
sectionId,
|
|
86
|
+
blockId,
|
|
87
|
+
blockType: blockInfo?.type,
|
|
88
|
+
blockLabel: blockInfo?.label,
|
|
89
|
+
partName
|
|
59
90
|
});
|
|
60
91
|
}
|
|
61
92
|
}
|
package/src/index.ts
CHANGED
|
@@ -21,6 +21,7 @@ export type { Section } from './theme-section';
|
|
|
21
21
|
export type { Block } from './theme-block';
|
|
22
22
|
|
|
23
23
|
export { default as ChromeSlot } from './chrome/chrome-slot';
|
|
24
|
+
export { default as ComposableRegion } from './chrome/composable-region';
|
|
24
25
|
export { ChromeFallback } from './chrome/chrome-fallback';
|
|
25
26
|
export { CodeOwnedRegion } from './chrome/code-owned-region';
|
|
26
27
|
export { PreviewAuthStateReporter } from './chrome/preview-auth-state';
|
|
@@ -60,3 +61,8 @@ export {
|
|
|
60
61
|
normalizePath
|
|
61
62
|
} from './chrome/route-slug';
|
|
62
63
|
export type { RouteSlugRule } from './chrome/route-slug';
|
|
64
|
+
|
|
65
|
+
export { CheckoutPiecesProvider } from './chrome/checkout-pieces-provider';
|
|
66
|
+
export { CheckoutPiece } from './chrome/checkout-piece';
|
|
67
|
+
export { CHECKOUT_PIECE_KEYS } from './chrome/checkout-pieces';
|
|
68
|
+
export type { CheckoutPieceKey } from './chrome/checkout-pieces';
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import { generateThemeCSS } from '../index';
|
|
2
|
+
import {
|
|
3
|
+
containsCheckoutFlowOutlet,
|
|
4
|
+
containsCheckoutOutlet,
|
|
5
|
+
isHidingDeclaration,
|
|
6
|
+
stripHidingCustomCss,
|
|
7
|
+
stripHidingStyles
|
|
8
|
+
} from '../checkout-safety';
|
|
9
|
+
import { applyVisibilityRulesToSections } from '../visibility-rules';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* I1, enforced where every authoring route converges.
|
|
13
|
+
*
|
|
14
|
+
* The editor's descriptors refuse to offer a hide-capable control on the flow
|
|
15
|
+
* outlet, but that guards one surface. Three routes go around it: an ANCESTOR
|
|
16
|
+
* block (the flow is portaled inside every parent's wrapper), the scoped
|
|
17
|
+
* CUSTOM CSS hatch, and a composition authored outside this repo entirely
|
|
18
|
+
* (the AI generator, the legacy widget editor, a direct API write). The CSS
|
|
19
|
+
* generator and the visibility-rule pass see all of them, so the rule lives
|
|
20
|
+
* there: styling may change how checkout looks, never whether it works.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const section = (blocks: unknown[]) =>
|
|
24
|
+
[{ id: 'sec', blocks }] as unknown as Parameters<typeof generateThemeCSS>[0];
|
|
25
|
+
|
|
26
|
+
const flowOutlet = (over: Record<string, unknown> = {}) => ({
|
|
27
|
+
id: 'flow-outlet',
|
|
28
|
+
type: 'checkout-flow-outlet',
|
|
29
|
+
...over
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('isHidingDeclaration', () => {
|
|
33
|
+
it.each([
|
|
34
|
+
['display', 'none'],
|
|
35
|
+
['display', 'NONE'],
|
|
36
|
+
['display', 'none !important'],
|
|
37
|
+
['visibility', 'hidden'],
|
|
38
|
+
['visibility', 'collapse'],
|
|
39
|
+
['pointer-events', 'none'],
|
|
40
|
+
['content-visibility', 'hidden'],
|
|
41
|
+
['opacity', '0'],
|
|
42
|
+
['opacity', '0.0'],
|
|
43
|
+
['opacity', '.02'],
|
|
44
|
+
['opacity', '0%']
|
|
45
|
+
])('treats %s: %s as hiding', (property, value) => {
|
|
46
|
+
expect(isHidingDeclaration(property, value)).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it.each([
|
|
50
|
+
['display', 'flex'],
|
|
51
|
+
['display', 'grid'],
|
|
52
|
+
['visibility', 'visible'],
|
|
53
|
+
['pointer-events', 'auto'],
|
|
54
|
+
['opacity', '0.9'],
|
|
55
|
+
['opacity', '1'],
|
|
56
|
+
// Values we cannot read are left alone rather than guessed at.
|
|
57
|
+
['opacity', 'var(--fade)'],
|
|
58
|
+
['color', 'transparent'],
|
|
59
|
+
['width', '0']
|
|
60
|
+
])('leaves %s: %s alone', (property, value) => {
|
|
61
|
+
expect(isHidingDeclaration(property, value)).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('subtree detection', () => {
|
|
66
|
+
it('finds the flow outlet at any depth, and the block itself', () => {
|
|
67
|
+
expect(containsCheckoutFlowOutlet(flowOutlet())).toBe(true);
|
|
68
|
+
expect(
|
|
69
|
+
containsCheckoutFlowOutlet({
|
|
70
|
+
type: 'group',
|
|
71
|
+
blocks: [{ type: 'group', blocks: [flowOutlet()] }]
|
|
72
|
+
})
|
|
73
|
+
).toBe(true);
|
|
74
|
+
expect(containsCheckoutFlowOutlet({ type: 'group', blocks: [] })).toBe(
|
|
75
|
+
false
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('separates "any outlet" from "the flow outlet"', () => {
|
|
80
|
+
const summaryOutlet = { type: 'checkout-summary-outlet' };
|
|
81
|
+
|
|
82
|
+
expect(containsCheckoutOutlet(summaryOutlet)).toBe(true);
|
|
83
|
+
expect(containsCheckoutFlowOutlet(summaryOutlet)).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('survives the shapes the three call sites actually pass', () => {
|
|
87
|
+
expect(containsCheckoutOutlet(null)).toBe(false);
|
|
88
|
+
expect(containsCheckoutOutlet(undefined)).toBe(false);
|
|
89
|
+
expect(containsCheckoutOutlet({ blocks: null })).toBe(false);
|
|
90
|
+
expect(containsCheckoutOutlet({ type: 42, blocks: 'nope' })).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('stripHidingStyles', () => {
|
|
95
|
+
it('drops only the offending breakpoint, so the flow stays on the page', () => {
|
|
96
|
+
expect(
|
|
97
|
+
stripHidingStyles(
|
|
98
|
+
{ display: { desktop: 'flex', mobile: 'none' } },
|
|
99
|
+
'root-only'
|
|
100
|
+
)
|
|
101
|
+
).toEqual({ display: { desktop: 'flex' } });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('drops the key entirely when every breakpoint hides', () => {
|
|
105
|
+
expect(
|
|
106
|
+
stripHidingStyles(
|
|
107
|
+
{ visibility: { desktop: 'hidden', mobile: 'hidden' } },
|
|
108
|
+
'root-only'
|
|
109
|
+
)
|
|
110
|
+
).toEqual({});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('keeps every layout value untouched', () => {
|
|
114
|
+
const styles = {
|
|
115
|
+
display: { desktop: 'flex' },
|
|
116
|
+
opacity: { desktop: '0.9' },
|
|
117
|
+
'flex-direction': { desktop: 'column' },
|
|
118
|
+
width: { desktop: '64%' }
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
expect(stripHidingStyles(styles, 'root-only')).toEqual(styles);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('guards critical parts but leaves the informational summary hideable', () => {
|
|
125
|
+
const styles = {
|
|
126
|
+
'place-order-button/display': { desktop: 'none' },
|
|
127
|
+
'payment-option/pointer-events': { desktop: 'none' },
|
|
128
|
+
'agreement/opacity': { desktop: '0' },
|
|
129
|
+
'summary-row/display': { desktop: 'none' },
|
|
130
|
+
'summary-total/opacity': { desktop: '0' }
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
expect(stripHidingStyles(styles, 'critical-parts')).toEqual({
|
|
134
|
+
'summary-row/display': { desktop: 'none' },
|
|
135
|
+
'summary-total/opacity': { desktop: '0' }
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('leaves an ancestor part key alone — it addresses the ancestor own markup', () => {
|
|
140
|
+
const styles = { 'title/display': { desktop: 'none' } };
|
|
141
|
+
|
|
142
|
+
expect(stripHidingStyles(styles, 'root-only')).toEqual(styles);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('reaches a state-qualified part key', () => {
|
|
146
|
+
expect(
|
|
147
|
+
stripHidingStyles(
|
|
148
|
+
{ 'payment-option/selected/display': { desktop: 'none' } },
|
|
149
|
+
'critical-parts'
|
|
150
|
+
)
|
|
151
|
+
).toEqual({});
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe('stripHidingCustomCss', () => {
|
|
156
|
+
it('removes the hiding declaration and keeps the rest of the rule', () => {
|
|
157
|
+
const css = stripHidingCustomCss(
|
|
158
|
+
'[data-block-id="x"] [data-part="place-order-button"] { display: none; border-radius: 4px; }'
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
expect(css).not.toContain('display: none');
|
|
162
|
+
expect(css).toContain('border-radius: 4px');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('handles consecutive hiding declarations', () => {
|
|
166
|
+
const css = stripHidingCustomCss(
|
|
167
|
+
'.a { display:none;visibility:hidden;pointer-events:none;color:red }'
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
expect(css).not.toMatch(/display\s*:\s*none/);
|
|
171
|
+
expect(css).not.toMatch(/visibility\s*:\s*hidden/);
|
|
172
|
+
expect(css).not.toMatch(/pointer-events\s*:\s*none/);
|
|
173
|
+
expect(css).toContain('color:red');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('leaves legitimate custom CSS alone', () => {
|
|
177
|
+
const css = '.a { display: flex; opacity: 0.85; visibility: visible }';
|
|
178
|
+
|
|
179
|
+
expect(stripHidingCustomCss(css)).toBe(css);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe('generateThemeCSS — checkout flow can never be hidden', () => {
|
|
184
|
+
it('drops a display:none seeded onto a submit-critical part', () => {
|
|
185
|
+
const css = generateThemeCSS(
|
|
186
|
+
section([
|
|
187
|
+
flowOutlet({
|
|
188
|
+
styles: {
|
|
189
|
+
'place-order-button/display': { desktop: 'none' },
|
|
190
|
+
'payment-option/pointer-events': { desktop: 'none' },
|
|
191
|
+
'payment-option/border-radius': { desktop: '8px' }
|
|
192
|
+
}
|
|
193
|
+
})
|
|
194
|
+
])
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
expect(css).not.toContain('display: none');
|
|
198
|
+
expect(css).not.toContain('pointer-events: none');
|
|
199
|
+
// The style that only changes how it LOOKS still lands.
|
|
200
|
+
expect(css).toContain('border-radius: 8px !important');
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('drops a hide from an ANCESTOR block wrapping the outlet', () => {
|
|
204
|
+
const css = generateThemeCSS(
|
|
205
|
+
section([
|
|
206
|
+
{
|
|
207
|
+
id: 'flow-column',
|
|
208
|
+
type: 'group',
|
|
209
|
+
styles: {
|
|
210
|
+
visibility: { desktop: 'hidden' },
|
|
211
|
+
'pointer-events': { desktop: 'none' },
|
|
212
|
+
display: { desktop: 'flex', mobile: 'none' },
|
|
213
|
+
width: { desktop: '64%' }
|
|
214
|
+
},
|
|
215
|
+
blocks: [flowOutlet()]
|
|
216
|
+
}
|
|
217
|
+
])
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
expect(css).not.toContain('visibility: hidden');
|
|
221
|
+
expect(css).not.toContain('pointer-events: none');
|
|
222
|
+
expect(css).not.toContain('display: none');
|
|
223
|
+
// The column is still a column.
|
|
224
|
+
expect(css).toContain('display: flex');
|
|
225
|
+
expect(css).toContain('width: 64%');
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('drops a hide from the SECTION wrapping the outlet', () => {
|
|
229
|
+
const css = generateThemeCSS([
|
|
230
|
+
{
|
|
231
|
+
id: 'sec',
|
|
232
|
+
styles: { display: { desktop: 'none' } },
|
|
233
|
+
blocks: [flowOutlet()]
|
|
234
|
+
}
|
|
235
|
+
] as never);
|
|
236
|
+
|
|
237
|
+
expect(css).not.toContain('display: none');
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('closes the custom-CSS hatch on the outlet and on an ancestor', () => {
|
|
241
|
+
const outletCss = generateThemeCSS(
|
|
242
|
+
section([
|
|
243
|
+
flowOutlet({
|
|
244
|
+
properties: {
|
|
245
|
+
customCss:
|
|
246
|
+
'& [data-part="place-order-button"] { display: none } & { border: 1px solid red }'
|
|
247
|
+
}
|
|
248
|
+
})
|
|
249
|
+
])
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
expect(outletCss).not.toContain('display: none');
|
|
253
|
+
expect(outletCss).toContain('border: 1px solid red');
|
|
254
|
+
|
|
255
|
+
const ancestorCss = generateThemeCSS(
|
|
256
|
+
section([
|
|
257
|
+
{
|
|
258
|
+
id: 'flow-column',
|
|
259
|
+
type: 'group',
|
|
260
|
+
properties: { customCss: '& { visibility: hidden }' },
|
|
261
|
+
blocks: [flowOutlet()]
|
|
262
|
+
}
|
|
263
|
+
])
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
expect(ancestorCss).not.toContain('visibility: hidden');
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('leaves an unrelated block free to hide itself', () => {
|
|
270
|
+
const css = generateThemeCSS(
|
|
271
|
+
section([
|
|
272
|
+
{
|
|
273
|
+
id: 'promo',
|
|
274
|
+
type: 'group',
|
|
275
|
+
styles: { display: { mobile: 'none' } },
|
|
276
|
+
properties: { customCss: '& { visibility: hidden }' }
|
|
277
|
+
}
|
|
278
|
+
])
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
expect(css).toContain('display: none');
|
|
282
|
+
expect(css).toContain('visibility: hidden');
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
describe('visibility rules never gate a checkout outlet', () => {
|
|
287
|
+
// The claim latches once at page mount and both rule inputs are wrong at
|
|
288
|
+
// that instant (session 'loading', breakpoint defaulted to desktop), so a
|
|
289
|
+
// rule-hidden outlet misses the latch and the composed placement is dead
|
|
290
|
+
// for the whole page load — while the canvas shows it working.
|
|
291
|
+
const mobileOnly = {
|
|
292
|
+
visibilityRules: { enabled: true, allowedBreakpoints: ['mobile'] }
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const context = { authState: 'loading', breakpoint: 'desktop' } as never;
|
|
296
|
+
|
|
297
|
+
it('keeps the outlet mounted despite an unsatisfied rule', () => {
|
|
298
|
+
const [applied] = applyVisibilityRulesToSections(
|
|
299
|
+
[
|
|
300
|
+
{
|
|
301
|
+
id: 'sec',
|
|
302
|
+
blocks: [{ id: 'o', type: 'checkout-flow-outlet', properties: mobileOnly }]
|
|
303
|
+
}
|
|
304
|
+
] as never,
|
|
305
|
+
context
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
expect(applied.blocks[0].hidden).toBe(false);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it('keeps an ANCESTOR of the outlet mounted too', () => {
|
|
312
|
+
const [applied] = applyVisibilityRulesToSections(
|
|
313
|
+
[
|
|
314
|
+
{
|
|
315
|
+
id: 'sec',
|
|
316
|
+
blocks: [
|
|
317
|
+
{
|
|
318
|
+
id: 'col',
|
|
319
|
+
type: 'group',
|
|
320
|
+
properties: mobileOnly,
|
|
321
|
+
blocks: [{ id: 'o', type: 'checkout-flow-outlet' }]
|
|
322
|
+
}
|
|
323
|
+
]
|
|
324
|
+
}
|
|
325
|
+
] as never,
|
|
326
|
+
context
|
|
327
|
+
);
|
|
328
|
+
|
|
329
|
+
expect(applied.blocks[0].hidden).toBe(false);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('keeps the SECTION holding the outlet mounted', () => {
|
|
333
|
+
const [applied] = applyVisibilityRulesToSections(
|
|
334
|
+
[
|
|
335
|
+
{
|
|
336
|
+
id: 'sec',
|
|
337
|
+
properties: mobileOnly,
|
|
338
|
+
blocks: [{ id: 'o', type: 'checkout-summary-outlet' }]
|
|
339
|
+
}
|
|
340
|
+
] as never,
|
|
341
|
+
context
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
expect(applied.hidden).toBe(false);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it('still honours the explicit hidden flag — that one is deliberate', () => {
|
|
348
|
+
const [applied] = applyVisibilityRulesToSections(
|
|
349
|
+
[
|
|
350
|
+
{
|
|
351
|
+
id: 'sec',
|
|
352
|
+
blocks: [{ id: 'o', type: 'checkout-flow-outlet', hidden: true }]
|
|
353
|
+
}
|
|
354
|
+
] as never,
|
|
355
|
+
context
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
expect(applied.blocks[0].hidden).toBe(true);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it('leaves every other block ruled as before', () => {
|
|
362
|
+
const [applied] = applyVisibilityRulesToSections(
|
|
363
|
+
[
|
|
364
|
+
{
|
|
365
|
+
id: 'sec',
|
|
366
|
+
blocks: [{ id: 'promo', type: 'group', properties: mobileOnly }]
|
|
367
|
+
}
|
|
368
|
+
] as never,
|
|
369
|
+
context
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
expect(applied.blocks[0].hidden).toBe(true);
|
|
373
|
+
});
|
|
374
|
+
});
|