@akinon/pz-theme 2.0.36-rc.0 → 2.0.37-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 +16 -31
- package/jest/__mocks__/hookform-resolvers.js +5 -0
- package/jest/__mocks__/next-auth-react.js +5 -0
- package/jest.config.js +3 -0
- package/package.json +4 -4
- package/src/blocks/accordion-block.tsx +35 -3
- package/src/blocks/commerce-format.ts +220 -0
- package/src/blocks/icon-block.tsx +6 -1
- package/src/blocks/image-gallery-block.tsx +1 -1
- package/src/blocks/link-block.tsx +9 -1
- package/src/blocks/slider-block.tsx +2 -1
- package/src/blocks/tab-block.tsx +15 -2
- package/src/blocks/video-block.tsx +6 -0
- package/src/components/__tests__/part-state-preview-scope.test.tsx +148 -0
- package/src/components/action-toolbar.tsx +182 -3
- package/src/components/part-state-preview.tsx +92 -0
- package/src/hooks/__tests__/use-overlay.test.tsx +503 -0
- package/src/hooks/use-designer-features.tsx +16 -1
- package/src/hooks/use-overlay.ts +538 -0
- package/src/hooks/use-visibility-context.ts +14 -2
- package/src/sections/__tests__/divider-section.test.tsx +28 -1
- package/src/sections/contact-form-section.tsx +4 -1
- package/src/sections/coupon-banner-section.tsx +2 -1
- package/src/sections/featured-product-spotlight-section.tsx +4 -4
- package/src/sections/find-in-store-section.tsx +2 -1
- package/src/sections/hover-showcase-section.tsx +2 -1
- package/src/sections/installment-options-section.tsx +2 -1
- package/src/sections/order-tracking-lookup-section.tsx +6 -1
- package/src/sections/posts-slider-section.tsx +8 -3
- package/src/sections/pre-order-launch-banner-section.tsx +2 -1
- package/src/sections/tabs-section.tsx +7 -13
- package/src/theme-block.tsx +81 -16
- package/src/theme-placeholder-client.tsx +8 -1
- package/src/theme-placeholder-wrapper.tsx +17 -3
- package/src/theme-placeholder.tsx +51 -5
- package/src/theme-section.tsx +20 -4
- package/src/utils/__tests__/breakpoint-cascade.test.ts +351 -0
- package/src/utils/__tests__/group-box-chrome.test.ts +83 -0
- package/src/utils/__tests__/iterator-bindings.test.tsx +1178 -0
- package/src/utils/__tests__/part-ownership-guard.test.tsx +298 -0
- package/src/utils/breakpoints.ts +116 -0
- package/src/utils/custom-css.ts +233 -0
- package/src/utils/index.ts +353 -42
- package/src/utils/iterator-utils.ts +675 -62
- package/src/utils/page-context-discovery.test.ts +188 -0
- package/src/utils/page-context-discovery.ts +48 -4
- package/src/utils/part-styles.ts +229 -0
- package/src/utils/row-visibility.ts +273 -0
- package/src/utils/visibility-rules.test.ts +579 -0
- package/src/utils/visibility-rules.ts +346 -3
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import {
|
|
2
|
+
applyVisibilityRulesToSections,
|
|
2
3
|
evaluateVisibilityRules,
|
|
3
4
|
normalizeVisibilityRules,
|
|
4
5
|
VisibilityRuleContext
|
|
5
6
|
} from './visibility-rules';
|
|
6
7
|
import { resetDevWarnCacheForTests } from './dev-warn';
|
|
8
|
+
import type { Section } from '../theme-section';
|
|
7
9
|
|
|
8
10
|
const makeContext = (
|
|
9
11
|
overrides: Partial<VisibilityRuleContext> = {}
|
|
@@ -156,6 +158,583 @@ describe('evaluateVisibilityRules', () => {
|
|
|
156
158
|
});
|
|
157
159
|
});
|
|
158
160
|
|
|
161
|
+
describe('data conditions', () => {
|
|
162
|
+
const pageContext = {
|
|
163
|
+
product: {
|
|
164
|
+
sku: 'TS-1',
|
|
165
|
+
stock: 3,
|
|
166
|
+
inStock: true,
|
|
167
|
+
attributes: { gender: 'Women', category: 'tops' },
|
|
168
|
+
tags: ['sale', 'new-season'],
|
|
169
|
+
images: [{ url: 'https://cdn.example.com/a.jpg' }]
|
|
170
|
+
},
|
|
171
|
+
listing: { totalCount: 0 },
|
|
172
|
+
category: { slug: 'kadin-ust-giyim' }
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const evaluate = (
|
|
176
|
+
conditions: unknown,
|
|
177
|
+
overrides: Partial<VisibilityRuleContext> = {},
|
|
178
|
+
extraRules: Record<string, unknown> = {}
|
|
179
|
+
) =>
|
|
180
|
+
evaluateVisibilityRules(
|
|
181
|
+
{ enabled: true, dataConditions: conditions, ...extraRules },
|
|
182
|
+
makeContext({ pageContext, ...overrides })
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
it('the size guide picks its partial by gender AND category (default match mode)', () => {
|
|
186
|
+
expect(
|
|
187
|
+
evaluate([
|
|
188
|
+
{ path: 'product.attributes.gender', operator: 'equals', value: 'women' },
|
|
189
|
+
{ path: 'product.attributes.category', operator: 'equals', value: 'tops' }
|
|
190
|
+
])
|
|
191
|
+
).toBe(true);
|
|
192
|
+
|
|
193
|
+
expect(
|
|
194
|
+
evaluate([
|
|
195
|
+
{ path: 'product.attributes.gender', operator: 'equals', value: 'women' },
|
|
196
|
+
{ path: 'product.attributes.category', operator: 'equals', value: 'shoes' }
|
|
197
|
+
])
|
|
198
|
+
).toBe(false);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('matches text case-insensitively, since the API says "Women"', () => {
|
|
202
|
+
expect(
|
|
203
|
+
evaluate([
|
|
204
|
+
{ path: 'product.attributes.gender', operator: 'equals', value: 'WOMEN' }
|
|
205
|
+
])
|
|
206
|
+
).toBe(true);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('drives the no-results page off a numeric zero', () => {
|
|
210
|
+
expect(
|
|
211
|
+
evaluate([{ path: 'listing.totalCount', operator: 'equals', value: '0' }])
|
|
212
|
+
).toBe(true);
|
|
213
|
+
expect(
|
|
214
|
+
evaluate([{ path: 'listing.totalCount', operator: 'gt', value: '0' }])
|
|
215
|
+
).toBe(false);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('compares numbers as numbers, not as text', () => {
|
|
219
|
+
// '3' < '10' is false as text; the block depends on it being true.
|
|
220
|
+
expect(
|
|
221
|
+
evaluate([{ path: 'product.stock', operator: 'lt', value: '10' }])
|
|
222
|
+
).toBe(true);
|
|
223
|
+
expect(
|
|
224
|
+
evaluate([{ path: 'product.stock', operator: 'gte', value: '3' }])
|
|
225
|
+
).toBe(true);
|
|
226
|
+
expect(
|
|
227
|
+
evaluate([{ path: 'product.stock', operator: 'lte', value: '2' }])
|
|
228
|
+
).toBe(false);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('a numeric comparison against non-numeric data hides rather than guesses', () => {
|
|
232
|
+
expect(
|
|
233
|
+
evaluate([{ path: 'product.sku', operator: 'gt', value: '5' }])
|
|
234
|
+
).toBe(false);
|
|
235
|
+
expect(
|
|
236
|
+
evaluate([{ path: 'product.stock', operator: 'gt', value: 'many' }])
|
|
237
|
+
).toBe(false);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('drives attribute layout off existence, including array membership', () => {
|
|
241
|
+
expect(
|
|
242
|
+
evaluate([{ path: 'product.attributes.gender', operator: 'exists' }])
|
|
243
|
+
).toBe(true);
|
|
244
|
+
expect(
|
|
245
|
+
evaluate([{ path: 'product.attributes.fit', operator: 'exists' }])
|
|
246
|
+
).toBe(false);
|
|
247
|
+
expect(
|
|
248
|
+
evaluate([{ path: 'product.tags', operator: 'contains', value: 'sale' }])
|
|
249
|
+
).toBe(true);
|
|
250
|
+
expect(
|
|
251
|
+
evaluate([{ path: 'product.tags', operator: 'contains', value: 'outlet' }])
|
|
252
|
+
).toBe(false);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('walks indexes the same way bindings do', () => {
|
|
256
|
+
expect(
|
|
257
|
+
evaluate([{ path: 'product.images[0].url', operator: 'contains', value: 'cdn' }])
|
|
258
|
+
).toBe(true);
|
|
259
|
+
expect(
|
|
260
|
+
evaluate([{ path: 'product.images[4].url', operator: 'exists' }])
|
|
261
|
+
).toBe(false);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
describe('[] — the shape the field picker offers for an array', () => {
|
|
265
|
+
// The picker walks arrays as `listing.products[].price`, so the evaluator
|
|
266
|
+
// has to answer for that shape or every array field it lists is a rule
|
|
267
|
+
// that hides the block forever.
|
|
268
|
+
const listingContext = {
|
|
269
|
+
listing: {
|
|
270
|
+
products: [
|
|
271
|
+
{ sku: 'A', stock: 0, badge: '', price: { value: 10 } },
|
|
272
|
+
{ sku: 'B', stock: 4, price: { value: 20 } }
|
|
273
|
+
]
|
|
274
|
+
},
|
|
275
|
+
product: {
|
|
276
|
+
images: [
|
|
277
|
+
{ url: 'https://cdn.example.com/a.jpg' },
|
|
278
|
+
{ url: 'https://cdn.example.com/b.jpg' }
|
|
279
|
+
],
|
|
280
|
+
tags: ['sale', 'new-season']
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
const evaluateOn = (
|
|
285
|
+
conditions: unknown,
|
|
286
|
+
pageContextOverride: Record<string, unknown> = listingContext,
|
|
287
|
+
extraRules: Record<string, unknown> = {}
|
|
288
|
+
) =>
|
|
289
|
+
evaluateVisibilityRules(
|
|
290
|
+
{ enabled: true, dataConditions: conditions, ...extraRules },
|
|
291
|
+
makeContext({ pageContext: pageContextOverride })
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
it('means "any element matches" for a comparison', () => {
|
|
295
|
+
expect(
|
|
296
|
+
evaluateOn([
|
|
297
|
+
{ path: 'listing.products[].stock', operator: 'gt', value: '0' }
|
|
298
|
+
])
|
|
299
|
+
).toBe(true);
|
|
300
|
+
expect(
|
|
301
|
+
evaluateOn([
|
|
302
|
+
{ path: 'listing.products[].stock', operator: 'gt', value: '99' }
|
|
303
|
+
])
|
|
304
|
+
).toBe(false);
|
|
305
|
+
expect(
|
|
306
|
+
evaluateOn([
|
|
307
|
+
{ path: 'product.images[].url', operator: 'contains', value: 'cdn' }
|
|
308
|
+
])
|
|
309
|
+
).toBe(true);
|
|
310
|
+
expect(
|
|
311
|
+
evaluateOn([{ path: 'product.tags[]', operator: 'equals', value: 'sale' }])
|
|
312
|
+
).toBe(true);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('means "any element has it" for exists, and its exact dual for notExists', () => {
|
|
316
|
+
expect(
|
|
317
|
+
evaluateOn([{ path: 'listing.products[].price.value', operator: 'exists' }])
|
|
318
|
+
).toBe(true);
|
|
319
|
+
// Only the first product carries `badge`, and it is empty: no element has one.
|
|
320
|
+
expect(
|
|
321
|
+
evaluateOn([{ path: 'listing.products[].badge', operator: 'exists' }])
|
|
322
|
+
).toBe(false);
|
|
323
|
+
expect(
|
|
324
|
+
evaluateOn([{ path: 'listing.products[].badge', operator: 'notExists' }])
|
|
325
|
+
).toBe(true);
|
|
326
|
+
expect(
|
|
327
|
+
evaluateOn([{ path: 'listing.products[].sku', operator: 'notExists' }])
|
|
328
|
+
).toBe(false);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('hides when the array is empty or missing', () => {
|
|
332
|
+
expect(
|
|
333
|
+
evaluateOn([{ path: 'listing.products[].sku', operator: 'exists' }], {
|
|
334
|
+
listing: { products: [] }
|
|
335
|
+
})
|
|
336
|
+
).toBe(false);
|
|
337
|
+
expect(
|
|
338
|
+
evaluateOn([{ path: 'listing.products[].sku', operator: 'exists' }], {
|
|
339
|
+
listing: {}
|
|
340
|
+
})
|
|
341
|
+
).toBe(false);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it('walks nested arrays', () => {
|
|
345
|
+
expect(
|
|
346
|
+
evaluateOn(
|
|
347
|
+
[{ path: 'listing.rows[][].sku', operator: 'equals', value: 'B' }],
|
|
348
|
+
{ listing: { rows: [[{ sku: 'A' }], [{ sku: 'B' }]] } }
|
|
349
|
+
)
|
|
350
|
+
).toBe(true);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it('reaches the same answer on the server and after client serialization', () => {
|
|
354
|
+
const rules = {
|
|
355
|
+
enabled: true,
|
|
356
|
+
dataConditions: [
|
|
357
|
+
{ path: 'listing.products[].stock', operator: 'gt', value: '0' },
|
|
358
|
+
{ path: 'listing.products[].badge', operator: 'notExists' }
|
|
359
|
+
]
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
const serverAnswer = evaluateVisibilityRules(
|
|
363
|
+
rules,
|
|
364
|
+
makeContext({ pageContext: listingContext })
|
|
365
|
+
);
|
|
366
|
+
const clientAnswer = evaluateVisibilityRules(
|
|
367
|
+
rules,
|
|
368
|
+
makeContext({
|
|
369
|
+
pageContext: JSON.parse(JSON.stringify(listingContext))
|
|
370
|
+
})
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
expect(serverAnswer).toBe(true);
|
|
374
|
+
expect(clientAnswer).toBe(serverAnswer);
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
describe('a path the evaluator cannot read at all', () => {
|
|
379
|
+
beforeEach(() => {
|
|
380
|
+
resetDevWarnCacheForTests();
|
|
381
|
+
jest.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
afterEach(() => {
|
|
385
|
+
(console.warn as jest.Mock).mockRestore();
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
const unreadable = [
|
|
389
|
+
'product.images[*].url',
|
|
390
|
+
'product.attributes.',
|
|
391
|
+
'.product.sku',
|
|
392
|
+
'product..sku',
|
|
393
|
+
'product[attributes].gender',
|
|
394
|
+
'product.__proto__.polluted',
|
|
395
|
+
'product.constructor.name'
|
|
396
|
+
];
|
|
397
|
+
|
|
398
|
+
it('never satisfies a condition — not even notExists', () => {
|
|
399
|
+
unreadable.forEach(path => {
|
|
400
|
+
expect(evaluate([{ path, operator: 'exists' }])).toBe(false);
|
|
401
|
+
expect(evaluate([{ path, operator: 'notExists' }])).toBe(false);
|
|
402
|
+
expect(evaluate([{ path, operator: 'equals', value: 'women' }])).toBe(
|
|
403
|
+
false
|
|
404
|
+
);
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it('says so in development instead of failing silently', () => {
|
|
409
|
+
evaluate([{ path: 'product.images[*].url', operator: 'exists' }]);
|
|
410
|
+
|
|
411
|
+
expect(console.warn).toHaveBeenCalledWith(
|
|
412
|
+
'[pz-theme:visibilityRules] Cannot read the field path "product.images[*].url"; the condition never passes (content stays hidden)'
|
|
413
|
+
);
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
describe('a path that reaches past the payload onto the prototype', () => {
|
|
418
|
+
// Naming __proto__/constructor/prototype is a blocklist, and the rest of
|
|
419
|
+
// Object.prototype answers the same way: every one of these resolves to a
|
|
420
|
+
// function, which is neither null nor empty, so `exists` would pass on
|
|
421
|
+
// every page ever rendered.
|
|
422
|
+
const inherited = [
|
|
423
|
+
'product.toString',
|
|
424
|
+
'product.valueOf',
|
|
425
|
+
'product.hasOwnProperty',
|
|
426
|
+
'product.isPrototypeOf',
|
|
427
|
+
'product.toLocaleString',
|
|
428
|
+
'product.propertyIsEnumerable'
|
|
429
|
+
];
|
|
430
|
+
|
|
431
|
+
it('reads as absent rather than as a value present on every page', () => {
|
|
432
|
+
inherited.forEach(path => {
|
|
433
|
+
expect(evaluate([{ path, operator: 'exists' }])).toBe(false);
|
|
434
|
+
// Absent, not unreadable: the shape is fine, the payload lacks it.
|
|
435
|
+
expect(evaluate([{ path, operator: 'notExists' }])).toBe(true);
|
|
436
|
+
expect(evaluate([{ path, operator: 'equals', value: 'x' }])).toBe(false);
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it('ignores a value inherited from a prototype the server would not serialize', () => {
|
|
441
|
+
const product = Object.create({ leaked: 'yes' }) as Record<string, unknown>;
|
|
442
|
+
product.sku = 'TS-1';
|
|
443
|
+
|
|
444
|
+
expect(
|
|
445
|
+
evaluateVisibilityRules(
|
|
446
|
+
{
|
|
447
|
+
enabled: true,
|
|
448
|
+
dataConditions: [{ path: 'product.leaked', operator: 'exists' }]
|
|
449
|
+
},
|
|
450
|
+
makeContext({ pageContext: { product } })
|
|
451
|
+
)
|
|
452
|
+
).toBe(false);
|
|
453
|
+
expect(
|
|
454
|
+
evaluateVisibilityRules(
|
|
455
|
+
{
|
|
456
|
+
enabled: true,
|
|
457
|
+
dataConditions: [{ path: 'product.sku', operator: 'exists' }]
|
|
458
|
+
},
|
|
459
|
+
makeContext({ pageContext: { product } })
|
|
460
|
+
)
|
|
461
|
+
).toBe(true);
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it('still reads own members that share a name with a built-in', () => {
|
|
465
|
+
expect(
|
|
466
|
+
evaluateVisibilityRules(
|
|
467
|
+
{
|
|
468
|
+
enabled: true,
|
|
469
|
+
dataConditions: [
|
|
470
|
+
{ path: 'product.images.length', operator: 'equals', value: '2' }
|
|
471
|
+
]
|
|
472
|
+
},
|
|
473
|
+
makeContext({ pageContext: { product: { images: ['a', 'b'] } } })
|
|
474
|
+
)
|
|
475
|
+
).toBe(true);
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
describe('a path that does not resolve', () => {
|
|
480
|
+
it('hides for every comparison operator — never "visible by accident"', () => {
|
|
481
|
+
const operators = ['equals', 'contains', 'gt', 'gte', 'lt', 'lte'];
|
|
482
|
+
operators.forEach(operator => {
|
|
483
|
+
expect(
|
|
484
|
+
evaluate([{ path: 'product.attributes.gendr', operator, value: '1' }])
|
|
485
|
+
).toBe(false);
|
|
486
|
+
});
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
it('hides for notEquals too: absence is not a different value', () => {
|
|
490
|
+
expect(
|
|
491
|
+
evaluate([
|
|
492
|
+
{ path: 'product.attributes.gendr', operator: 'notEquals', value: 'men' }
|
|
493
|
+
])
|
|
494
|
+
).toBe(false);
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
it('answers true only for notExists, the operator that asks about absence', () => {
|
|
498
|
+
expect(
|
|
499
|
+
evaluate([{ path: 'product.sizeChart', operator: 'notExists' }])
|
|
500
|
+
).toBe(true);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it('treats an empty object as absent — no attributes means no attribute', () => {
|
|
504
|
+
expect(
|
|
505
|
+
evaluateVisibilityRules(
|
|
506
|
+
{
|
|
507
|
+
enabled: true,
|
|
508
|
+
dataConditions: [{ path: 'product.attributes', operator: 'exists' }]
|
|
509
|
+
},
|
|
510
|
+
makeContext({ pageContext: { product: { attributes: {} } } })
|
|
511
|
+
)
|
|
512
|
+
).toBe(false);
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
it('treats an empty string and an empty array as absent', () => {
|
|
516
|
+
expect(
|
|
517
|
+
evaluateVisibilityRules(
|
|
518
|
+
{
|
|
519
|
+
enabled: true,
|
|
520
|
+
dataConditions: [{ path: 'product.badge', operator: 'exists' }]
|
|
521
|
+
},
|
|
522
|
+
makeContext({ pageContext: { product: { badge: ' ', tags: [] } } })
|
|
523
|
+
)
|
|
524
|
+
).toBe(false);
|
|
525
|
+
expect(
|
|
526
|
+
evaluateVisibilityRules(
|
|
527
|
+
{
|
|
528
|
+
enabled: true,
|
|
529
|
+
dataConditions: [{ path: 'product.tags', operator: 'notExists' }]
|
|
530
|
+
},
|
|
531
|
+
makeContext({ pageContext: { product: { badge: '', tags: [] } } })
|
|
532
|
+
)
|
|
533
|
+
).toBe(true);
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
it('hides when there is no page context at all (wrong page type)', () => {
|
|
537
|
+
expect(
|
|
538
|
+
evaluateVisibilityRules(
|
|
539
|
+
{
|
|
540
|
+
enabled: true,
|
|
541
|
+
dataConditions: [
|
|
542
|
+
{ path: 'product.attributes.gender', operator: 'equals', value: 'women' }
|
|
543
|
+
]
|
|
544
|
+
},
|
|
545
|
+
makeContext()
|
|
546
|
+
)
|
|
547
|
+
).toBe(false);
|
|
548
|
+
});
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
it('supports any-of for per-category banners', () => {
|
|
552
|
+
const conditions = [
|
|
553
|
+
{ path: 'category.slug', operator: 'equals', value: 'erkek' },
|
|
554
|
+
{ path: 'category.slug', operator: 'equals', value: 'kadin-ust-giyim' }
|
|
555
|
+
];
|
|
556
|
+
|
|
557
|
+
expect(evaluate(conditions, {}, { dataMatchMode: 'any' })).toBe(true);
|
|
558
|
+
expect(evaluate(conditions)).toBe(false);
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
it('ignores an unfinished row instead of blanking the block', () => {
|
|
562
|
+
expect(evaluate([{ path: ' ', operator: 'equals', value: 'women' }])).toBe(
|
|
563
|
+
true
|
|
564
|
+
);
|
|
565
|
+
expect(evaluate([{ operator: 'exists' }, 'nonsense', null])).toBe(true);
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
it('is skipped on the designer canvas so a hidden block stays styleable', () => {
|
|
569
|
+
const rules = {
|
|
570
|
+
enabled: true,
|
|
571
|
+
dataConditions: [
|
|
572
|
+
{ path: 'product.attributes.gender', operator: 'equals', value: 'men' }
|
|
573
|
+
]
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
expect(evaluateVisibilityRules(rules, makeContext({ pageContext }))).toBe(
|
|
577
|
+
false
|
|
578
|
+
);
|
|
579
|
+
expect(
|
|
580
|
+
evaluateVisibilityRules(
|
|
581
|
+
rules,
|
|
582
|
+
makeContext({ pageContext, isDesigner: true })
|
|
583
|
+
)
|
|
584
|
+
).toBe(true);
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
it('still applies visitor conditions on the canvas', () => {
|
|
588
|
+
expect(
|
|
589
|
+
evaluateVisibilityRules(
|
|
590
|
+
{
|
|
591
|
+
enabled: true,
|
|
592
|
+
allowedBreakpoints: ['mobile'],
|
|
593
|
+
dataConditions: [{ path: 'product.sku', operator: 'exists' }]
|
|
594
|
+
},
|
|
595
|
+
makeContext({ pageContext, isDesigner: true, breakpoint: 'desktop' })
|
|
596
|
+
)
|
|
597
|
+
).toBe(false);
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
it('reaches the same answer on the server and after client serialization', () => {
|
|
601
|
+
const rules = {
|
|
602
|
+
enabled: true,
|
|
603
|
+
dataMatchMode: 'all',
|
|
604
|
+
dataConditions: [
|
|
605
|
+
{ path: 'product.attributes.gender', operator: 'equals', value: 'women' },
|
|
606
|
+
{ path: 'product.stock', operator: 'gt', value: '0' },
|
|
607
|
+
{ path: 'product.tags', operator: 'contains', value: 'sale' },
|
|
608
|
+
{ path: 'product.sizeChart', operator: 'notExists' }
|
|
609
|
+
]
|
|
610
|
+
};
|
|
611
|
+
|
|
612
|
+
// The client receives the page context as serialized props; a rule that
|
|
613
|
+
// answered differently after that round trip would hydrate a different tree.
|
|
614
|
+
const serverAnswer = evaluateVisibilityRules(
|
|
615
|
+
rules,
|
|
616
|
+
makeContext({ pageContext })
|
|
617
|
+
);
|
|
618
|
+
const clientAnswer = evaluateVisibilityRules(
|
|
619
|
+
rules,
|
|
620
|
+
makeContext({ pageContext: JSON.parse(JSON.stringify(pageContext)) })
|
|
621
|
+
);
|
|
622
|
+
|
|
623
|
+
expect(serverAnswer).toBe(true);
|
|
624
|
+
expect(clientAnswer).toBe(serverAnswer);
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
it('normalizes conditions to a stable shape', () => {
|
|
628
|
+
const rules = normalizeVisibilityRules({
|
|
629
|
+
enabled: true,
|
|
630
|
+
dataMatchMode: 'weird',
|
|
631
|
+
dataConditions: [
|
|
632
|
+
{ path: ' product.sku ', operator: 'unsupported', value: 12 },
|
|
633
|
+
{ path: '' }
|
|
634
|
+
]
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
expect(rules.dataMatchMode).toBe('all');
|
|
638
|
+
expect(rules.dataConditions).toEqual([
|
|
639
|
+
{ path: 'product.sku', operator: 'exists', value: '12' }
|
|
640
|
+
]);
|
|
641
|
+
});
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
describe('nothing that renders today changes', () => {
|
|
645
|
+
const legacyRuleShapes: unknown[] = [
|
|
646
|
+
undefined,
|
|
647
|
+
null,
|
|
648
|
+
{},
|
|
649
|
+
{ enabled: false, authState: 'authenticated' },
|
|
650
|
+
{ enabled: true },
|
|
651
|
+
{ enabled: true, authState: 'guest' },
|
|
652
|
+
{ enabled: true, authState: 'authenticated' },
|
|
653
|
+
{ enabled: true, allowedBreakpoints: ['mobile'] },
|
|
654
|
+
{ enabled: true, allowedBreakpoints: ['desktop'] },
|
|
655
|
+
{ enabled: true, allowedLocales: ['en'] },
|
|
656
|
+
{ enabled: true, allowedLocales: ['tr'] },
|
|
657
|
+
{ enabled: true, allowedCurrencies: ['usd'] },
|
|
658
|
+
{ enabled: true, allowedCurrencies: ['TRY'] },
|
|
659
|
+
{ enabled: true, pathnameMatchType: 'equals', pathnameValue: '/' },
|
|
660
|
+
{ enabled: true, pathnameMatchType: 'startsWith', pathnameValue: '/account' },
|
|
661
|
+
{ enabled: true, pathnameMatchType: 'contains', pathnameValue: 'x' },
|
|
662
|
+
{ enabled: true, pathnameMatchType: 'equals', pathnameValue: '' },
|
|
663
|
+
{ enabled: true, queryParamKey: 'campaign' },
|
|
664
|
+
{ enabled: true, queryParamKey: 'campaign', queryParamValue: 'x' },
|
|
665
|
+
'malformed',
|
|
666
|
+
[1, 2, 3]
|
|
667
|
+
];
|
|
668
|
+
|
|
669
|
+
it('a rule authored before data conditions answers the same with data present', () => {
|
|
670
|
+
// Page context and designer mode are new inputs; for every rule shape that
|
|
671
|
+
// exists in the wild today they must be inert.
|
|
672
|
+
legacyRuleShapes.forEach(rules => {
|
|
673
|
+
const before = evaluateVisibilityRules(rules, makeContext());
|
|
674
|
+
const withData = evaluateVisibilityRules(
|
|
675
|
+
rules,
|
|
676
|
+
makeContext({
|
|
677
|
+
pageContext: { product: { sku: 'X' }, listing: { totalCount: 12 } }
|
|
678
|
+
})
|
|
679
|
+
);
|
|
680
|
+
const withDesigner = evaluateVisibilityRules(
|
|
681
|
+
rules,
|
|
682
|
+
makeContext({
|
|
683
|
+
pageContext: { product: { sku: 'X' } },
|
|
684
|
+
isDesigner: true
|
|
685
|
+
})
|
|
686
|
+
);
|
|
687
|
+
|
|
688
|
+
expect([withData, withDesigner]).toEqual([before, before]);
|
|
689
|
+
});
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
it('leaves the section tree byte-identical when no rule opts in', () => {
|
|
693
|
+
const sections: Section[] = [
|
|
694
|
+
{
|
|
695
|
+
id: 'section-1',
|
|
696
|
+
type: 'default',
|
|
697
|
+
name: 'hero',
|
|
698
|
+
label: 'Hero',
|
|
699
|
+
order: 0,
|
|
700
|
+
hidden: false,
|
|
701
|
+
properties: {},
|
|
702
|
+
styles: {},
|
|
703
|
+
blocks: [
|
|
704
|
+
{
|
|
705
|
+
id: 'block-1',
|
|
706
|
+
type: 'text',
|
|
707
|
+
label: 'Title',
|
|
708
|
+
order: 0,
|
|
709
|
+
hidden: false,
|
|
710
|
+
properties: {},
|
|
711
|
+
styles: {},
|
|
712
|
+
blocks: [
|
|
713
|
+
{
|
|
714
|
+
id: 'block-1-1',
|
|
715
|
+
type: 'text',
|
|
716
|
+
label: 'Nested',
|
|
717
|
+
order: 0,
|
|
718
|
+
hidden: true,
|
|
719
|
+
properties: {},
|
|
720
|
+
styles: {},
|
|
721
|
+
blocks: []
|
|
722
|
+
}
|
|
723
|
+
]
|
|
724
|
+
}
|
|
725
|
+
]
|
|
726
|
+
}
|
|
727
|
+
] as unknown as Section[];
|
|
728
|
+
|
|
729
|
+
const applied = applyVisibilityRulesToSections(
|
|
730
|
+
sections,
|
|
731
|
+
makeContext({ pageContext: { product: { sku: 'X' } } })
|
|
732
|
+
);
|
|
733
|
+
|
|
734
|
+
expect(JSON.stringify(applied)).toBe(JSON.stringify(sections));
|
|
735
|
+
});
|
|
736
|
+
});
|
|
737
|
+
|
|
159
738
|
describe('normalizeVisibilityRules — malformed input', () => {
|
|
160
739
|
let warnSpy: jest.SpyInstance;
|
|
161
740
|
|