From c418a4bbc666d2ece0a1d6ea2f50f7fea3f0701e Mon Sep 17 00:00:00 2001 From: decobot Date: Fri, 26 Jun 2026 18:20:20 -0300 Subject: [PATCH] feat(cms-sections): variant drag-and-drop reorder + variant-aware preview - Reorder section and page variants via dnd-kit - Force the preview to render the selected variant through the deco runtime's x-deco-matchers-override (section + page level), only on explicit variant click - Preserve preview scroll across override navigations/save-reloads via the injected CMS script (sessionStorage + retried restore) Co-authored-by: Cursor --- apps/mesh/ct/harness/variant-harnesses.tsx | 2 + .../sandbox/preview/cms-editor-script.ts | 39 ++ .../components/sandbox/preview/preview.tsx | 86 +++-- .../sections-editor/section-variant-list.tsx | 334 ++++++++++++++---- .../sections-editor/section-variants.test.ts | 21 ++ .../sections-editor/section-variants.ts | 24 ++ .../sections-editor/sections-editor.tsx | 184 +++++++++- .../variant-matcher-override.test.ts | 154 ++++++++ .../variant-matcher-override.ts | 173 +++++++++ 9 files changed, 917 insertions(+), 100 deletions(-) create mode 100644 apps/mesh/src/web/components/sections-editor/variant-matcher-override.test.ts create mode 100644 apps/mesh/src/web/components/sections-editor/variant-matcher-override.ts diff --git a/apps/mesh/ct/harness/variant-harnesses.tsx b/apps/mesh/ct/harness/variant-harnesses.tsx index 28d98969de..86737f143e 100644 --- a/apps/mesh/ct/harness/variant-harnesses.tsx +++ b/apps/mesh/ct/harness/variant-harnesses.tsx @@ -35,12 +35,14 @@ export function SectionVariantListHarness({ return (
push({ type: "select", index })} onDuplicate={(index) => push({ type: "duplicate", index })} onDelete={(index) => push({ type: "delete", index })} onRemoveAll={() => push({ type: "removeAll" })} + onReorder={(from, to) => push({ type: "reorder", from, to })} onAdd={() => push({ type: "add" })} /> diff --git a/apps/mesh/src/web/components/sandbox/preview/cms-editor-script.ts b/apps/mesh/src/web/components/sandbox/preview/cms-editor-script.ts index b40df9b9cd..4ad1ac903a 100644 --- a/apps/mesh/src/web/components/sandbox/preview/cms-editor-script.ts +++ b/apps/mesh/src/web/components/sandbox/preview/cms-editor-script.ts @@ -24,6 +24,45 @@ export const CMS_EDITOR_SCRIPT = `(function() { if (window.__cmsEditorActive) return; window.__cmsEditorActive = true; + // Preserve scroll across variant-override navigations and save-reloads. + // Selecting a variant rebuilds the iframe src (new x-deco-matchers-override) + // or reloads it, which would otherwise jump to the top. We persist the scroll + // offset to sessionStorage (survives reload, same-origin) keyed by pathname + // (the override only changes the query, so the key is stable), then restore it + // on the next load — retried for ~1s so it outlasts re-hydration and lazy + // sections that grow the page after load. The recency guard avoids restoring a + // stale offset on a genuinely fresh visit. + var SCROLL_KEY = "__cms_preview_scroll:" + location.pathname; + var saveScroll = function() { + try { + sessionStorage.setItem(SCROLL_KEY, JSON.stringify({ + x: window.scrollX, y: window.scrollY, t: Date.now() + })); + } catch (_) {} + }; + var saveScrollPending = false; + window.addEventListener("scroll", function() { + if (saveScrollPending) return; + saveScrollPending = true; + requestAnimationFrame(function() { saveScrollPending = false; saveScroll(); }); + }, { passive: true }); + (function restoreScroll() { + var raw = null; + try { raw = sessionStorage.getItem(SCROLL_KEY); } catch (_) {} + if (!raw) return; + var saved = null; + try { saved = JSON.parse(raw); } catch (_) { return; } + if (!saved || (Date.now() - saved.t) > 10000) return; + if (!saved.x && !saved.y) return; + var attempts = 0; + var apply = function() { + attempts++; + window.scrollTo(saved.x || 0, saved.y || 0); + if (attempts < 8) setTimeout(apply, 120); + }; + apply(); + })(); + var highlight = document.createElement("div"); highlight.style.cssText = "position:absolute;pointer-events:none;outline:2px solid #06b6d4;background:rgba(6,182,212,0.06);border-radius:2px;z-index:2147483647;display:none;"; document.body.appendChild(highlight); diff --git a/apps/mesh/src/web/components/sandbox/preview/preview.tsx b/apps/mesh/src/web/components/sandbox/preview/preview.tsx index 0e04746867..46dce1fc6d 100644 --- a/apps/mesh/src/web/components/sandbox/preview/preview.tsx +++ b/apps/mesh/src/web/components/sandbox/preview/preview.tsx @@ -42,6 +42,7 @@ import { DropdownMenuTrigger, } from "@deco/ui/components/dropdown-menu.tsx"; import { useDecofile } from "@/web/components/sections-editor/use-decofile"; +import { withVariantMatcherOverride } from "@/web/components/sections-editor/variant-matcher-override"; import { parseSections } from "@/web/components/sections-editor/parse-sections"; import { getPageVariantSectionsAt } from "@/web/components/sections-editor/page-variants"; import { useLiveMeta } from "@/web/components/sections-editor/use-live-meta"; @@ -209,6 +210,11 @@ export function PreviewContent() { const [cmsSelectedSectionIndex, setCmsSelectedSectionIndex] = useState< number | null >(null); + // `x-deco-matchers-override` params forcing the preview to render the section + // variant currently selected in the sections editor (CMS mode only). + const [variantOverrideParams, setVariantOverrideParams] = useState< + string[] | null + >(null); const [panelWidth, setPanelWidth] = useState(384); const isResizingRef = useRef(false); const resizeStartXRef = useRef(0); @@ -358,10 +364,13 @@ export function PreviewContent() { const iframeSrc = previewState.kind === "iframe" - ? withDeviceHint( - directPreviewUrl ?? - new URL(currentPath, previewState.previewUrl).href, - previewDeviceSize, + ? withVariantMatcherOverride( + withDeviceHint( + directPreviewUrl ?? + new URL(currentPath, previewState.previewUrl).href, + previewDeviceSize, + ), + sectionsOpen && variantOverrideParams ? variantOverrideParams : [], ) : null; @@ -533,7 +542,10 @@ export function PreviewContent() { // Leaving code mode clears the "View JSON" deep-link so re-entering code // mode later opens the file tree, not the previously-viewed page JSON. if (mode !== "code") setCodeFilePath(null); - if (mode !== "cms") setCmsSelectedSectionIndex(null); + if (mode !== "cms") { + setCmsSelectedSectionIndex(null); + setVariantOverrideParams(null); + } if (prev === "visual") deactivateVisualEditor(); if (prev === "cms") deactivateCmsEditor(); if (mode === "visual") injectVisualEditor(); @@ -548,6 +560,40 @@ export function PreviewContent() { iframe.src = iframeSrc; }; + // Selecting/reordering a variant changes `iframeSrc` (override params), which + // re-navigates the iframe. Scroll position is preserved entirely inside the + // injected CMS script (it persists scroll to sessionStorage and restores it + // after the reload, surviving re-hydration), so nothing to capture here. + const handleVariantPreviewOverride = (params: string[] | null) => { + setVariantOverrideParams(params); + }; + + // Reload the preview after a save while preventing the iframe from stealing + // focus. Scroll is restored by the injected CMS script (see above). + const reloadPreviewPreservingScroll = () => { + const iframe = previewIframeRef.current; + if (!iframe) return; + const focused = document.activeElement as HTMLElement | null; + const prevTabIndex = iframe.tabIndex; + iframe.tabIndex = -1; + iframe.style.pointerEvents = "none"; + iframe.blur(); + try { + iframe.contentWindow?.location.reload(); + } catch { + const src = iframeSrcRef.current; + if (src) iframe.src = src; + } + const restore = () => { + iframe.tabIndex = prevTabIndex; + iframe.style.pointerEvents = ""; + focused?.focus(); + iframe.removeEventListener("load", restore); + }; + iframe.addEventListener("load", restore); + setTimeout(restore, 3000); + }; + const handleHardReload = () => { if (!previewIframeRef.current || !iframeSrc) return; const sep = iframeSrc.includes("?") ? "&" : "?"; @@ -1084,31 +1130,10 @@ export function PreviewContent() { activeGlobalSection ? null : cmsSelectedSectionIndex } onSaved={() => { - setTimeout(() => { - const iframe = previewIframeRef.current; - if (!iframe) return; - // Prevent iframe from stealing focus during reload - const focused = - document.activeElement as HTMLElement | null; - const prevTabIndex = iframe.tabIndex; - iframe.tabIndex = -1; - iframe.style.pointerEvents = "none"; - iframe.blur(); - try { - iframe.contentWindow?.location.reload(); - } catch { - const src = iframeSrcRef.current; - if (src) iframe.src = src; - } - const restore = () => { - iframe.tabIndex = prevTabIndex; - iframe.style.pointerEvents = ""; - focused?.focus(); - iframe.removeEventListener("load", restore); - }; - iframe.addEventListener("load", restore); - setTimeout(restore, 3000); - }, DEV_SERVER_SETTLE_MS); + setTimeout( + reloadPreviewPreservingScroll, + DEV_SERVER_SETTLE_MS, + ); }} initialEditSeo={cmsInitialEditSeo} onExitSeo={() => setCmsInitialEditSeo(false)} @@ -1120,6 +1145,7 @@ export function PreviewContent() { toast.error("Invalid page block key"); } }} + onVariantPreviewOverride={handleVariantPreviewOverride} />
diff --git a/apps/mesh/src/web/components/sections-editor/section-variant-list.tsx b/apps/mesh/src/web/components/sections-editor/section-variant-list.tsx index 8cfcd3b1c9..ae66db4cdd 100644 --- a/apps/mesh/src/web/components/sections-editor/section-variant-list.tsx +++ b/apps/mesh/src/web/components/sections-editor/section-variant-list.tsx @@ -1,3 +1,4 @@ +import { useRef, useState } from "react"; import { Button } from "@deco/ui/components/button.tsx"; import { Tooltip, @@ -6,6 +7,7 @@ import { } from "@deco/ui/components/tooltip.tsx"; import { Copy01, + DotsGrid, DotsHorizontal, LayoutAlt01, Plus, @@ -18,6 +20,25 @@ import { DropdownMenuTrigger, } from "@deco/ui/components/dropdown-menu.tsx"; import { cn } from "@deco/ui/lib/utils.js"; +import { + DndContext, + DragOverlay, + KeyboardSensor, + PointerSensor, + closestCenter, + useSensor, + useSensors, + type DragEndEvent, + type DragStartEvent, +} from "@dnd-kit/core"; +import { + SortableContext, + arrayMove, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; const VARIANT_ICON_COLOR = "oklch(0.65 0.15 160)"; const VARIANT_ROW_CLASS = @@ -32,25 +53,135 @@ export interface SectionVariantEntry { label: string; } -function VariantRow({ - variant, +interface SortableVariantEntry { + id: string; + index: number; + label: string; +} + +function variantsDisplayKey(variants: SectionVariantEntry[]): string { + return variants.map((variant) => variant.label).join("\n"); +} + +function createEntries( + variants: SectionVariantEntry[], +): SortableVariantEntry[] { + return variants.map((variant) => ({ + id: crypto.randomUUID(), + index: variant.index, + label: variant.label, + })); +} + +function remapEntryIndices( + entries: SortableVariantEntry[], +): SortableVariantEntry[] { + return entries.map((entry, index) => ({ ...entry, index })); +} + +function VariantRowContent({ + label, + canDelete, + dragging, + onDuplicate, + onDelete, +}: { + label: string; + canDelete: boolean; + dragging?: boolean; + onDuplicate?: () => void; + onDelete?: () => void; +}) { + return ( + <> + + + + {label} + + + {!dragging && ( + + + + + + { + e.stopPropagation(); + onDuplicate?.(); + }} + > + + Duplicate + + { + e.stopPropagation(); + onDelete?.(); + }} + > + + Delete + + + + )} + + ); +} + +function SortableVariantRow({ + entry, selected, canDelete, onSelect, onDuplicate, onDelete, }: { - variant: SectionVariantEntry; + entry: SortableVariantEntry; selected: boolean; canDelete: boolean; onSelect: () => void; onDuplicate: () => void; onDelete: () => void; }) { + const { attributes, listeners, setNodeRef, transform, isDragging } = + useSortable({ id: entry.id, animateLayoutChanges: () => false }); + + const style = { + transform: CSS.Transform.toString( + transform ? { ...transform, x: 0 } : null, + ), + opacity: isDragging ? 0 : undefined, + }; + return (
{ if (e.target !== e.currentTarget) return; @@ -60,78 +191,125 @@ function VariantRow({ } }} className={cn( - "group flex select-none items-center gap-2 rounded-md px-2 py-2.5 transition-colors cursor-pointer", + "group flex select-none items-center gap-2 rounded-md px-2 py-2.5 transition-colors touch-none", + isDragging ? "cursor-grabbing" : "cursor-grab", selected ? VARIANT_SELECTED_ROW_CLASS : VARIANT_ROW_CLASS, )} > - - - {variant.label} - - - - - - - - { - e.stopPropagation(); - onDuplicate(); - }} - > - - Duplicate - - { - e.stopPropagation(); - onDelete(); - }} - > - - Delete - - -
); } export function SectionVariantList({ + listKey, variants, selectedIndex, onSelect, onDuplicate, onDelete, onRemoveAll, + onReorder, onAdd, }: { + listKey: string; variants: SectionVariantEntry[]; selectedIndex: number; onSelect: (index: number) => void; onDuplicate: (index: number) => void; onDelete: (index: number) => void; onRemoveAll: () => void; + onReorder: (fromIndex: number, toIndex: number) => void; onAdd: () => void; }) { const canDelete = variants.length > 1; + const [entries, setEntries] = useState(() => + createEntries(variants), + ); + const [activeEntry, setActiveEntry] = useState( + null, + ); + const [prevListKey, setPrevListKey] = useState(listKey); + const [prevVariantCount, setPrevVariantCount] = useState(variants.length); + const [prevDisplayKey, setPrevDisplayKey] = useState(() => + variantsDisplayKey(variants), + ); + const suppressClickRef = useRef(false); + + const displayKey = variantsDisplayKey(variants); + + if (prevListKey !== listKey) { + setPrevListKey(listKey); + setPrevVariantCount(variants.length); + setPrevDisplayKey(displayKey); + setEntries(createEntries(variants)); + } else if (prevVariantCount !== variants.length) { + setPrevVariantCount(variants.length); + setPrevDisplayKey(displayKey); + setEntries(createEntries(variants)); + } else if (prevDisplayKey !== displayKey) { + setPrevDisplayKey(displayKey); + setEntries((current) => + variants.map((variant, index) => { + const prior = current[index]; + return { + id: prior?.id ?? crypto.randomUUID(), + index: variant.index, + label: variant.label, + }; + }), + ); + } + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + const entryIds = entries.map((entry) => entry.id); + + const handleDragStart = (event: DragStartEvent) => { + const id = String(event.active.id); + setActiveEntry(entries.find((entry) => entry.id === id) ?? null); + }; + + const handleDragEnd = (event: DragEndEvent) => { + setActiveEntry(null); + + const { active, over } = event; + if (!over || active.id === over.id) return; + + const oldIndex = entryIds.indexOf(String(active.id)); + const newIndex = entryIds.indexOf(String(over.id)); + if (oldIndex === -1 || newIndex === -1) return; + + setEntries((current) => + remapEntryIndices(arrayMove([...current], oldIndex, newIndex)), + ); + suppressClickRef.current = true; + requestAnimationFrame(() => { + suppressClickRef.current = false; + }); + onReorder(oldIndex, newIndex); + }; + + const handleDragCancel = () => { + setActiveEntry(null); + }; + + const handleSelect = (index: number) => { + if (suppressClickRef.current) return; + onSelect(index); + }; + return (
@@ -171,19 +349,49 @@ export function SectionVariantList({
-
- {variants.map((variant) => ( - onSelect(variant.index)} - onDuplicate={() => onDuplicate(variant.index)} - onDelete={() => onDelete(variant.index)} - /> - ))} -
+ + +
+ {entries.map((entry) => ( + handleSelect(entry.index)} + onDuplicate={() => onDuplicate(entry.index)} + onDelete={() => onDelete(entry.index)} + /> + ))} +
+
+ + + {activeEntry ? ( +
+ +
+ ) : null} +
+
); } diff --git a/apps/mesh/src/web/components/sections-editor/section-variants.test.ts b/apps/mesh/src/web/components/sections-editor/section-variants.test.ts index 0c6390fbd3..e851866614 100644 --- a/apps/mesh/src/web/components/sections-editor/section-variants.test.ts +++ b/apps/mesh/src/web/components/sections-editor/section-variants.test.ts @@ -10,6 +10,7 @@ import { isDefaultVariantRule, parseSectionFlagVariants, pickVariantToKeepIndex, + reorderMultivariateSectionVariant, showSection, toggleSectionLazyRender, unwrapVariantSectionValue, @@ -175,6 +176,26 @@ describe("section-variants", () => { expect(deleteMultivariateSectionVariant(updated, 0)).toBeNull(); }); + it("reorderMultivariateSectionVariant moves a variant and preserves the rest", () => { + const mvObj = { + __resolveType: "website/flags/multivariate/section.ts", + variants: [ + { value: { title: "A" } }, + { value: { title: "B" } }, + { value: { title: "C" } }, + ], + }; + + const moved = reorderMultivariateSectionVariant(mvObj, 0, 2) as { + variants: Array<{ value: { title: string } }>; + }; + expect(moved.variants.map((v) => v.value.title)).toEqual(["B", "C", "A"]); + + // out-of-range / no-op returns the original object + expect(reorderMultivariateSectionVariant(mvObj, 1, 1)).toBe(mvObj); + expect(reorderMultivariateSectionVariant(mvObj, 0, 9)).toBe(mvObj); + }); + it("isDefaultVariantRule treats always matchers as default", () => { expect(isDefaultVariantRule(undefined)).toBe(false); expect(isDefaultVariantRule({})).toBe(true); diff --git a/apps/mesh/src/web/components/sections-editor/section-variants.ts b/apps/mesh/src/web/components/sections-editor/section-variants.ts index bfc91d2315..0a4271fdba 100644 --- a/apps/mesh/src/web/components/sections-editor/section-variants.ts +++ b/apps/mesh/src/web/components/sections-editor/section-variants.ts @@ -341,6 +341,30 @@ export function duplicateMultivariateSectionVariant( return { ...mvObj, variants }; } +export function reorderMultivariateSectionVariant( + mvObj: Record, + fromIndex: number, + toIndex: number, +): Record { + const variants = [ + ...((mvObj.variants as Array>) ?? []), + ]; + if ( + fromIndex === toIndex || + fromIndex < 0 || + toIndex < 0 || + fromIndex >= variants.length || + toIndex >= variants.length + ) { + return mvObj; + } + + const [moved] = variants.splice(fromIndex, 1); + if (!moved) return mvObj; + variants.splice(toIndex, 0, moved); + return { ...mvObj, variants }; +} + export function deleteMultivariateSectionVariant( mvObj: Record, variantIndex: number, diff --git a/apps/mesh/src/web/components/sections-editor/sections-editor.tsx b/apps/mesh/src/web/components/sections-editor/sections-editor.tsx index 144f32d431..b84a38f27e 100644 --- a/apps/mesh/src/web/components/sections-editor/sections-editor.tsx +++ b/apps/mesh/src/web/components/sections-editor/sections-editor.tsx @@ -76,6 +76,7 @@ import { buildPageSectionsFromVariants, countSavedMatcherBlockReferences, getLastVariantIndex, + isMultivariateArrayWrapper, parsePageVariants, type PageVariant, } from "./page-variants"; @@ -94,12 +95,18 @@ import { hideSection, parseSectionFlagVariants, rebuildSectionWithMultivariate, + reorderMultivariateSectionVariant, showSection, toggleSectionLazyRender, unwrapVariantSectionValue, updateMultivariateSectionVariantRule, updateMultivariateSectionVariantValue, } from "./section-variants"; +import { + buildPageVariantOverrideParams, + buildSectionVariantOverrideParams, + type PageVariantInfo, +} from "./variant-matcher-override"; import { PageJsonDialog } from "./page-json-dialog"; import { createReferencedBlockSaver } from "./save-referenced-block"; @@ -440,6 +447,7 @@ export function SectionsEditor({ initialEditSeo = false, onExitSeo, onViewJsonFile, + onVariantPreviewOverride, }: { orgSlug: string; virtualMcpId: string; @@ -469,6 +477,13 @@ export function SectionsEditor({ * Hosts without a file surface (Content tab) omit this and get the modal. */ onViewJsonFile?: (pageKey: string) => void; + /** + * Called when the selected section variant changes so the host can force the + * preview iframe to render that variant via `x-deco-matchers-override`. + * Passes `null` when no variant override should be applied (non-multivariate + * section, or nothing selected). + */ + onVariantPreviewOverride?: (params: string[] | null) => void; }) { const previewFetchParams = previewReady ? { orgSlug, virtualMcpId, branch } @@ -725,6 +740,9 @@ export function SectionsEditor({ const activeVariant = pageVariants[safeVariantIndex]; const rawSections: RawSection[] = activeVariant?.sections ?? []; const parsedSections = parseSections(rawSections, decofile); + // Page is page-level multivariate when `sections` is a multivariate wrapper. + const pageIsMultivariate = + !isGlobalBlockMode && isMultivariateArrayWrapper(pageData?.sections); // Global blocks open directly into the section form (single saved block). if ( @@ -765,10 +783,78 @@ export function SectionsEditor({ selectedSectionIndex, }; + // Force the preview iframe to render the selected variant via the deco + // runtime's `x-deco-matchers-override`. `null` clears any prior override. + const currentPageVariantInfo = (): PageVariantInfo => ({ + multivariate: pageIsMultivariate, + index: safeVariantIndex, + variants: pageVariants.map((v) => ({ rule: v.rule })), + }); + + // Force the preview to render a specific *page* variant. Pass the target + // index/variants explicitly so callers can drive the override before the + // render-scope `safeVariantIndex` reflects a pending state change. + const emitPageVariantOverride = ( + variants: Array<{ rule?: Record }>, + index: number, + ) => { + if (!onVariantPreviewOverride) return; + if (!activePageKey || !pageIsMultivariate || variants.length <= 1) { + onVariantPreviewOverride(null); + return; + } + const params = buildPageVariantOverrideParams( + activePageKey, + { multivariate: true, index, variants }, + decofile, + meta, + ); + onVariantPreviewOverride(params.length > 0 ? params : null); + }; + + const syncVariantPreviewOverride = ( + mvObj: Record | null, + sectionIndex: number, + variantIndex: number, + ) => { + if (!onVariantPreviewOverride) return; + if (!activePageKey) { + onVariantPreviewOverride(null); + return; + } + const pageInfo = currentPageVariantInfo(); + const pageParams = buildPageVariantOverrideParams( + activePageKey, + pageInfo, + decofile, + meta, + ); + if (!mvObj || sectionIndex < 0) { + onVariantPreviewOverride(pageParams.length > 0 ? pageParams : null); + return; + } + const sectionParams = buildSectionVariantOverrideParams({ + pageKey: activePageKey, + page: pageInfo, + sectionIndex, + sectionLazy: parsedSections[sectionIndex]?.isLazy ?? false, + mvObj, + selectedVariantIndex: variantIndex, + decofile, + meta, + }); + const params = [...pageParams, ...sectionParams]; + onVariantPreviewOverride(params.length > 0 ? params : null); + }; + const applySectionVariant = ( rawSection: RawSection, parsed: ParsedSection, variantIndex: number, + sectionIndex: number = selectedSectionIndex ?? -1, + // Only force the preview to this variant on an explicit variant-row click. + // Merely entering/auto-selecting a section must not re-navigate the iframe. + syncPreview = false, ) => { const mvObj = getMultivariateSectionObject(rawSection, parsed); if (!mvObj) { @@ -776,6 +862,8 @@ export function SectionsEditor({ setActiveResolveType(null); setSectionRuleResolveType(null); setSectionRuleFormValue(null); + if (syncPreview) + syncVariantPreviewOverride(null, sectionIndex, variantIndex); return; } @@ -786,6 +874,8 @@ export function SectionsEditor({ setActiveResolveType(null); setSectionRuleResolveType(null); setSectionRuleFormValue(null); + if (syncPreview) + syncVariantPreviewOverride(null, sectionIndex, variantIndex); return; } @@ -800,6 +890,8 @@ export function SectionsEditor({ ); setSectionRuleResolveType(resolveType); setSectionRuleFormValue(formValue); + if (syncPreview) + syncVariantPreviewOverride(mvObj, sectionIndex, variantIndex); }; // Auto-select section when parent signals a click-through from the preview. @@ -817,7 +909,7 @@ export function SectionsEditor({ setFieldBreadcrumbs([]); setFormResetKey((key) => key + 1); if (parsed.isMultivariate) { - applySectionVariant(rawSection, parsed, 0); + applySectionVariant(rawSection, parsed, 0, externalSelectedIndex); } else { const unwrapped = unwrapSection(rawSection, parsed, decofile); if (!unwrapped) { @@ -1007,7 +1099,7 @@ export function SectionsEditor({ if (!rawSection || !parsed) return; if (parsed.isMultivariate) { - applySectionVariant(rawSection, parsed, 0); + applySectionVariant(rawSection, parsed, 0, index); return; } @@ -1017,6 +1109,7 @@ export function SectionsEditor({ setActiveResolveType(null); setSectionRuleResolveType(null); setSectionRuleFormValue(null); + syncVariantPreviewOverride(null, index, 0); return; } @@ -1024,6 +1117,7 @@ export function SectionsEditor({ setActiveResolveType(unwrapped.resolveType); setSectionRuleResolveType(null); setSectionRuleFormValue(null); + syncVariantPreviewOverride(null, index, 0); }; const handleFormChange = (val: unknown) => { @@ -1242,7 +1336,13 @@ export function SectionsEditor({ setActiveSectionVariantIndex(variantIndex); setFieldBreadcrumbs([]); setFormResetKey((key) => key + 1); - applySectionVariant(rawSection, parsed, variantIndex); + applySectionVariant( + rawSection, + parsed, + variantIndex, + selectedSectionIndex, + true, + ); }; const handleDeleteSectionVariant = (variantIndex: number) => { @@ -1318,6 +1418,54 @@ export function SectionsEditor({ savePageSections(updatedSections); }; + const handleReorderSectionVariant = (fromIndex: number, toIndex: number) => { + if (selectedSectionIndex === null || !activePageKey) return; + if (fromIndex === toIndex) return; + const rawSection = rawSections[selectedSectionIndex]; + const parsed = parsedSections[selectedSectionIndex]; + if (!rawSection || !parsed?.isMultivariate) return; + + const mvObj = getMultivariateSectionObject(rawSection, parsed); + if (!mvObj) return; + + const updatedMvObj = reorderMultivariateSectionVariant( + mvObj, + fromIndex, + toIndex, + ); + + const updatedSections = [...rawSections]; + updatedSections[selectedSectionIndex] = rebuildSectionWithMultivariate( + rawSection, + parsed, + updatedMvObj, + ); + + // Keep the selected variant pointing at the same variant after the move. + let nextIndex = activeSectionVariantIndex; + if (activeSectionVariantIndex === fromIndex) { + nextIndex = toIndex; + } else if ( + fromIndex < activeSectionVariantIndex && + toIndex >= activeSectionVariantIndex + ) { + nextIndex = activeSectionVariantIndex - 1; + } else if ( + fromIndex > activeSectionVariantIndex && + toIndex <= activeSectionVariantIndex + ) { + nextIndex = activeSectionVariantIndex + 1; + } + + setActiveSectionVariantIndex(nextIndex); + applySectionVariant( + updatedSections[selectedSectionIndex], + parsed, + nextIndex, + ); + savePageSections(updatedSections); + }; + const handleAddSectionVariant = (index?: number) => { const sectionIndex = index ?? selectedSectionIndex; if (sectionIndex === null || !activePageKey) return; @@ -1344,7 +1492,12 @@ export function SectionsEditor({ setActiveSectionVariantIndex(result.newVariantIndex); setFieldBreadcrumbs([]); setFormResetKey((key) => key + 1); - applySectionVariant(result.section, nextParsed, result.newVariantIndex); + applySectionVariant( + result.section, + nextParsed, + result.newVariantIndex, + sectionIndex, + ); savePageSections(updatedSections); }; @@ -1386,6 +1539,7 @@ export function SectionsEditor({ setActiveResolveType(unwrapped.resolveType); } + syncVariantPreviewOverride(null, selectedSectionIndex, 0); savePageSections(updatedSections); }; @@ -1835,18 +1989,32 @@ export function SectionsEditor({ ); setRuleResolveType(resolveType); setRuleFormValue(formValue); + emitPageVariantOverride( + pageVariants.map((v) => ({ rule: v.rule })), + index, + ); }; const handleReorderPageVariants = (fromIndex: number, toIndex: number) => { if (fromIndex === toIndex) return; + let nextIndex = safeVariantIndex; if (safeVariantIndex === fromIndex) { - setActiveVariantIndex(toIndex); + nextIndex = toIndex; } else if (fromIndex < safeVariantIndex && toIndex >= safeVariantIndex) { - setActiveVariantIndex(safeVariantIndex - 1); + nextIndex = safeVariantIndex - 1; } else if (fromIndex > safeVariantIndex && toIndex <= safeVariantIndex) { - setActiveVariantIndex(safeVariantIndex + 1); + nextIndex = safeVariantIndex + 1; } + setActiveVariantIndex(nextIndex); + + // Keep the preview override pointing at the same variant after the move. + const reorderedRules = arrayMove( + pageVariants.map((v) => ({ rule: v.rule })), + fromIndex, + toIndex, + ); + emitPageVariantOverride(reorderedRules, nextIndex); mutatePageVariants((variants) => arrayMove(variants, fromIndex, toIndex)); }; @@ -2286,6 +2454,7 @@ export function SectionsEditor({ {isEditingMultivariateSection && sectionFlagVariants.length > 0 && ( <> ({ index, label: variant.label, @@ -2295,6 +2464,7 @@ export function SectionsEditor({ onDuplicate={handleDuplicateSectionVariant} onDelete={handleDeleteSectionVariant} onRemoveAll={handleRemoveAllSectionVariants} + onReorder={handleReorderSectionVariant} onAdd={() => handleAddSectionVariant()} /> {isEditingMultivariateSection && ( diff --git a/apps/mesh/src/web/components/sections-editor/variant-matcher-override.test.ts b/apps/mesh/src/web/components/sections-editor/variant-matcher-override.test.ts new file mode 100644 index 0000000000..b57a8e219b --- /dev/null +++ b/apps/mesh/src/web/components/sections-editor/variant-matcher-override.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from "bun:test"; +import { + buildPageVariantOverrideParams, + buildSectionVariantOverrideParams, + MATCHER_OVERRIDE_QS, + type PageVariantInfo, + withVariantMatcherOverride, +} from "./variant-matcher-override"; + +const ALWAYS = { __resolveType: "website/matchers/always.ts" }; +const inline = (rt: string) => ({ __resolveType: rt }); + +function mv(...rules: Array>): Record { + return { + __resolveType: "website/flags/multivariate/section.ts", + variants: rules.map((rule) => ({ + value: { __resolveType: "site/sections/Hero.tsx" }, + rule, + })), + }; +} + +const SINGLE_PAGE: PageVariantInfo = { + multivariate: false, + index: 0, + variants: [{ rule: ALWAYS }], +}; + +describe("buildSectionVariantOverrideParams", () => { + it("forces the selected variant on and earlier variants off", () => { + const params = buildSectionVariantOverrideParams({ + pageKey: "pages-Home-1", + page: SINGLE_PAGE, + sectionIndex: 2, + sectionLazy: false, + mvObj: mv(ALWAYS, inline("website/matchers/device.ts"), ALWAYS), + selectedVariantIndex: 1, + decofile: {}, + meta: null, + }); + + expect(params).toEqual([ + "pages-Home-1@sections.2.variants.0.rule=0", + "pages-Home-1@sections.2.variants.1.rule=1", + ]); + }); + + it("inserts `.section` for lazy-wrapped sections", () => { + const params = buildSectionVariantOverrideParams({ + pageKey: "pages-home-c4bcbfb771e9", + page: SINGLE_PAGE, + sectionIndex: 9, + sectionLazy: true, + mvObj: mv(ALWAYS, ALWAYS), + selectedVariantIndex: 0, + decofile: {}, + meta: null, + }); + + expect(params).toEqual([ + "pages-home-c4bcbfb771e9@sections.9.section.variants.0.rule=1", + ]); + }); + + it("nests under the active page variant for multivariate pages", () => { + const params = buildSectionVariantOverrideParams({ + pageKey: "pages-Home-1", + page: { multivariate: true, index: 2, variants: [{}, {}, {}] }, + sectionIndex: 4, + sectionLazy: false, + mvObj: mv(ALWAYS, ALWAYS), + selectedVariantIndex: 1, + decofile: {}, + meta: null, + }); + + expect(params).toEqual([ + "pages-Home-1@sections.variants.2.value.4.variants.0.rule=0", + "pages-Home-1@sections.variants.2.value.4.variants.1.rule=1", + ]); + }); + + it("uses the block resolveType as id for saved matcher block references", () => { + const savedKey = "matchers-Black-Friday-abc"; + const decofile = { + [savedKey]: { __resolveType: "website/matchers/date.ts", name: "BF" }, + }; + const params = buildSectionVariantOverrideParams({ + pageKey: "pages-Home-1", + page: SINGLE_PAGE, + sectionIndex: 1, + sectionLazy: false, + mvObj: mv(ALWAYS, inline(savedKey)), + selectedVariantIndex: 1, + decofile, + meta: null, + }); + + expect(params).toEqual([ + "pages-Home-1@sections.1.variants.0.rule=0", + `${savedKey}=1`, + ]); + }); +}); + +describe("buildPageVariantOverrideParams", () => { + it("returns [] for a non-multivariate page", () => { + expect( + buildPageVariantOverrideParams("pages-Home-1", SINGLE_PAGE, {}, null), + ).toEqual([]); + }); + + it("forces the active page variant", () => { + const params = buildPageVariantOverrideParams( + "pages-Home-1", + { multivariate: true, index: 1, variants: [{ rule: ALWAYS }, {}, {}] }, + {}, + null, + ); + expect(params).toEqual([ + "pages-Home-1@sections.variants.0.rule=0", + "pages-Home-1@sections.variants.1.rule=1", + ]); + }); +}); + +describe("withVariantMatcherOverride", () => { + it("appends each param as a repeated query key", () => { + const href = withVariantMatcherOverride( + "https://site.example.com/?deviceHint=desktop", + ["a@sections.0.variants.0.rule=0", "a@sections.0.variants.1.rule=1"], + ); + const url = new URL(href); + expect(url.searchParams.getAll(MATCHER_OVERRIDE_QS)).toEqual([ + "a@sections.0.variants.0.rule=0", + "a@sections.0.variants.1.rule=1", + ]); + expect(url.searchParams.get("deviceHint")).toBe("desktop"); + }); + + it("returns the href unchanged when there are no params", () => { + const href = "https://site.example.com/?deviceHint=desktop"; + expect(withVariantMatcherOverride(href, [])).toBe(href); + }); + + it("replaces any pre-existing override params", () => { + const href = withVariantMatcherOverride( + `https://site.example.com/?${MATCHER_OVERRIDE_QS}=stale=1`, + ["fresh=1"], + ); + const url = new URL(href); + expect(url.searchParams.getAll(MATCHER_OVERRIDE_QS)).toEqual(["fresh=1"]); + }); +}); diff --git a/apps/mesh/src/web/components/sections-editor/variant-matcher-override.ts b/apps/mesh/src/web/components/sections-editor/variant-matcher-override.ts new file mode 100644 index 0000000000..6bd1b88c35 --- /dev/null +++ b/apps/mesh/src/web/components/sections-editor/variant-matcher-override.ts @@ -0,0 +1,173 @@ +import { + getSavedMatcherBlockKey, + isSavedMatcherBlockReference, +} from "./matcher-rules"; +import type { LiveMeta } from "./resolve-schema"; + +/** + * Query-param name the deco runtime reads to force matcher activation. + * See `@deco/deco` `blocks/matcher.ts` (`x-deco-matchers-override`). + */ +export const MATCHER_OVERRIDE_QS = "x-deco-matchers-override"; + +/** + * Page-level variant context. A page's `sections` is either a plain array + * (single variant) or a multivariate wrapper (`{ variants: [{ rule, value }] }`). + */ +export interface PageVariantInfo { + /** `page.sections` is a multivariate wrapper (page-level variants exist). */ + multivariate: boolean; + /** Active page variant index (0 when not multivariate). */ + index: number; + /** Page variant entries — only each entry's `rule` is read. */ + variants: Array<{ rule?: Record }>; +} + +interface OverrideParamsArgs { + pageKey: string; + /** + * Dotted resolve-chain path from the page resolvable up to (and including) + * the `variants` array that holds the matcher rules — e.g. + * `sections.variants` for page variants, or + * `sections.3.section.variants` for a lazy section's variants. + */ + prefix: string; + variants: Array<{ rule?: Record }>; + selectedIndex: number; + decofile: Record; + meta?: LiveMeta | null; +} + +/** + * Build `"=<1|0>"` override values for one multivariate array. + * + * The deco runtime (`blocks/matcher.ts`) computes each matcher's unique id by + * walking the resolve chain back to the nearest *named resolvable* (a decofile + * key), skipping resolver/dangling entries, joining `prop` names with `.` and + * prefixing the resolvable with `@`. So an inline matcher's id is + * `@..rule`; a *saved matcher block* reference is itself + * a named resolvable, so its id is just the block key. + * + * For the selected index the matcher is forced on (`=1`) and earlier ones off + * (`=0`); the runtime renders the first matching entry, so later ones are left + * untouched. + */ +function overrideParamsForVariants({ + pageKey, + prefix, + variants, + selectedIndex, + decofile, + meta, +}: OverrideParamsArgs): string[] { + if (!pageKey || selectedIndex < 0 || selectedIndex >= variants.length) { + return []; + } + + const params: string[] = []; + for (let idx = 0; idx <= selectedIndex; idx++) { + const variant = variants[idx]; + if (!variant) continue; + const rule = variant.rule as Record | undefined; + const activation = idx === selectedIndex ? "1" : "0"; + + const savedBlockKey = isSavedMatcherBlockReference(rule, decofile, meta) + ? getSavedMatcherBlockKey(rule, decofile, meta) + : null; + + const id = savedBlockKey ?? `${pageKey}@${prefix}.${idx}.rule`; + params.push(`${id}=${activation}`); + } + + return params; +} + +/** + * Force the preview to render the active *page* variant. Returns `[]` when the + * page is not multivariate (nothing to force). + */ +export function buildPageVariantOverrideParams( + pageKey: string, + page: PageVariantInfo, + decofile: Record, + meta?: LiveMeta | null, +): string[] { + if (!page.multivariate || page.variants.length <= 1) return []; + return overrideParamsForVariants({ + pageKey, + prefix: "sections.variants", + variants: page.variants, + selectedIndex: page.index, + decofile, + meta, + }); +} + +export interface SectionVariantOverrideArgs { + pageKey: string; + page: PageVariantInfo; + /** Index of the multivariate section within the active page variant's sections. */ + sectionIndex: number; + /** The section is wrapped in a Lazy block (adds a `.section` chain segment). */ + sectionLazy: boolean; + /** The multivariate section object (`{ variants: [{ value, rule }] }`). */ + mvObj: Record; + selectedVariantIndex: number; + decofile: Record; + meta?: LiveMeta | null; +} + +/** + * Force the preview to render the selected *section* variant. The resolve-chain + * prefix accounts for page-level variant nesting (`sections.variants..value`) + * and Lazy wrapping (`.section`). + */ +export function buildSectionVariantOverrideParams({ + pageKey, + page, + sectionIndex, + sectionLazy, + mvObj, + selectedVariantIndex, + decofile, + meta, +}: SectionVariantOverrideArgs): string[] { + if (sectionIndex < 0) return []; + + const sectionBase = page.multivariate + ? `sections.variants.${page.index}.value` + : "sections"; + const prefix = `${sectionBase}.${sectionIndex}${ + sectionLazy ? ".section" : "" + }.variants`; + + const variants = Array.isArray(mvObj.variants) + ? (mvObj.variants as Array<{ rule?: Record }>) + : []; + + return overrideParamsForVariants({ + pageKey, + prefix, + variants, + selectedIndex: selectedVariantIndex, + decofile, + meta, + }); +} + +/** Append override params to a preview URL, returning the new href. */ +export function withVariantMatcherOverride( + href: string, + params: string[], +): string { + if (params.length === 0) return href; + const url = new URL(href, "http://local"); + url.searchParams.delete(MATCHER_OVERRIDE_QS); + for (const param of params) { + url.searchParams.append(MATCHER_OVERRIDE_QS, param); + } + // Preserve relative hrefs when the input had no origin. + return href.startsWith("http") + ? url.toString() + : `${url.pathname}${url.search}${url.hash}`; +}