@decocms/blocks 7.20.7 → 7.20.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/blocks",
3
- "version": "7.20.7",
3
+ "version": "7.20.9",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -638,3 +638,73 @@ describe("resolveDecoPage — #277 client-side navigation disables deferral", ()
638
638
  expect(result?.deferredSections).toHaveLength(0);
639
639
  });
640
640
  });
641
+
642
+ // ---------------------------------------------------------------------------
643
+ // Hidden array items — a `never`-gated multivariate wrapper must vanish from
644
+ // the array, not survive as a `null` hole the section renders as empty.
645
+ //
646
+ // The admin (Studio) hides an array item by wrapping it in
647
+ // `{ __resolveType: "website/flags/multivariate.ts", variants: [{ value, rule:
648
+ // { __resolveType: "website/matchers/never.ts" } }] }`. `never` never matches,
649
+ // so the wrapper resolves to "not present" and the item is dropped.
650
+ // ---------------------------------------------------------------------------
651
+
652
+ describe("hidden array items (never matcher)", () => {
653
+ const NEVER = "website/matchers/never.ts";
654
+ const ALWAYS = "website/matchers/always.ts";
655
+ const MULTIVARIATE = "website/flags/multivariate.ts";
656
+
657
+ const hidden = (value: unknown) => ({
658
+ __resolveType: MULTIVARIATE,
659
+ variants: [{ value, rule: { __resolveType: NEVER } }],
660
+ });
661
+
662
+ it("drops a hidden item from an array instead of leaving a null hole", async () => {
663
+ const benefits = [
664
+ { label: "Parcelamento" },
665
+ { label: "Frete" },
666
+ hidden({ label: "RetiradaLoja" }),
667
+ hidden({ label: "EntregaExpressa" }),
668
+ ];
669
+
670
+ const result = (await resolveValue({ benefits }, undefined, {})) as {
671
+ benefits: unknown[];
672
+ };
673
+
674
+ expect(result.benefits).toEqual([{ label: "Parcelamento" }, { label: "Frete" }]);
675
+ // No null holes.
676
+ expect(result.benefits).not.toContain(null);
677
+ });
678
+
679
+ it("keeps a visible (always-gated) item in the array", async () => {
680
+ const benefits = [
681
+ {
682
+ __resolveType: MULTIVARIATE,
683
+ variants: [{ value: { label: "Shown" }, rule: { __resolveType: ALWAYS } }],
684
+ },
685
+ hidden({ label: "Hidden" }),
686
+ ];
687
+
688
+ const result = (await resolveValue({ benefits }, undefined, {})) as {
689
+ benefits: unknown[];
690
+ };
691
+
692
+ expect(result.benefits).toEqual([{ label: "Shown" }]);
693
+ });
694
+
695
+ it("preserves a legitimate null value in an array (matched variant resolving to null)", async () => {
696
+ // A matched variant whose value is literally null stays — only the
697
+ // no-match sentinel is filtered, not every null.
698
+ const items = [
699
+ {
700
+ __resolveType: MULTIVARIATE,
701
+ variants: [{ value: null, rule: { __resolveType: ALWAYS } }],
702
+ },
703
+ { label: "kept" },
704
+ ];
705
+
706
+ const result = (await resolveValue({ items }, undefined, {})) as { items: unknown[] };
707
+
708
+ expect(result.items).toEqual([null, { label: "kept" }]);
709
+ });
710
+ });
@@ -780,7 +780,14 @@ async function resolveProps(
780
780
  async function internalResolve(value: unknown, rctx: ResolveContext): Promise<unknown> {
781
781
  if (!value || typeof value !== "object") return value;
782
782
  if (Array.isArray(value)) {
783
- return Promise.all(value.map((item) => internalResolve(item, rctx)));
783
+ const resolved = await Promise.all(value.map((item) => internalResolve(item, rctx)));
784
+ // Drop items that resolved to "not present" (`undefined`) — e.g. a hidden
785
+ // array item (a multivariate wrapped in a `never` matcher, see the admin's
786
+ // hideArrayItem) where no variant matched. A `never`-gated item must vanish
787
+ // from the array, NOT become a `null` hole the section then renders as an
788
+ // empty card. Legitimate `null` values (a matched variant that resolved to
789
+ // null, a failed loader) are kept — only the no-match sentinel is filtered.
790
+ return resolved.filter((item) => item !== undefined);
784
791
  }
785
792
 
786
793
  const obj = value as Record<string, unknown>;
@@ -833,7 +840,10 @@ async function internalResolve(value: unknown, rctx: ResolveContext): Promise<un
833
840
  resolveType === WELL_KNOWN_TYPES.MULTIVARIATE_SECTION
834
841
  ) {
835
842
  const variants = obj.variants as Array<{ value: unknown; rule?: unknown }> | undefined;
836
- if (!variants || variants.length === 0) return null;
843
+ // No variant matched (incl. a single `never`-gated variant, i.e. a hidden
844
+ // item) → resolve to `undefined`, the "not present" sentinel the array
845
+ // branch filters out, rather than `null`, which would survive as a hole.
846
+ if (!variants || variants.length === 0) return undefined;
837
847
 
838
848
  for (const variant of variants) {
839
849
  const rule = variant.rule as Record<string, unknown> | undefined;
@@ -841,7 +851,7 @@ async function internalResolve(value: unknown, rctx: ResolveContext): Promise<un
841
851
  return internalResolve(variant.value, childCtx);
842
852
  }
843
853
  }
844
- return null;
854
+ return undefined;
845
855
  }
846
856
 
847
857
  // Commerce loaders
@@ -145,6 +145,9 @@ export function parseRedirectsCsv(csv: string): Redirect[] {
145
145
 
146
146
  const [from, to, type] = parts;
147
147
  if (!from || !to) continue;
148
+ // Skip a header row (`from,to[,type]`). Robust for CSVs with or without a
149
+ // header, and for a header repeated when multiple files are concatenated.
150
+ if (from.toLowerCase() === "from" && to.toLowerCase() === "to") continue;
148
151
 
149
152
  redirects.push({
150
153
  from: normalizePath(from),