From 0e6ab1020d1c27a586473457bc1fcc52cfb1938c Mon Sep 17 00:00:00 2001 From: guitavano Date: Thu, 18 Jun 2026 17:37:32 -0300 Subject: [PATCH 1/8] fix(cms): list and edit Tanstack installed apps in Content tab Merge app catalog from store, manifest.blocks.apps, and decofile without manifestHasApp gate; resolve app schemas with btoa-safe base64 lookup and legacy resolveType aliases; fix Buffer crash in the browser. Co-authored-by: Cursor --- .../sandbox/content/app-catalog.test.ts | 29 ++++ .../components/sandbox/content/app-catalog.ts | 124 ++++++++---------- .../components/sections-editor/page-list.tsx | 21 ++- .../sections-editor/resolve-schema.test.ts | 36 +++++ .../sections-editor/resolve-schema.ts | 69 ++++++++-- 5 files changed, 196 insertions(+), 83 deletions(-) diff --git a/apps/mesh/src/web/components/sandbox/content/app-catalog.test.ts b/apps/mesh/src/web/components/sandbox/content/app-catalog.test.ts index 97480a9702..92ac5f6138 100644 --- a/apps/mesh/src/web/components/sandbox/content/app-catalog.test.ts +++ b/apps/mesh/src/web/components/sandbox/content/app-catalog.test.ts @@ -138,4 +138,33 @@ describe("app-catalog", () => { app: "vtex", }); }); + + it("lists installed custom/local apps without manifest or store entries", () => { + const emptyMeta: LiveMeta = { + manifest: { blocks: { apps: { anyOf: [] } } }, + schema: {}, + }; + const decofile = { + "app-tags": { + __resolveType: "site/apps/local/app-tags.ts", + account: "lojabagaggio", + }, + }; + + const catalog = buildAppCatalog([], emptyMeta, decofile); + + expect(catalog).toEqual([ + { + id: "local-app-tags", + app: "app-tags", + vendor: "local", + title: "App Tags", + description: "", + category: "Custom", + resolveType: "site/apps/local/app-tags.ts", + blockKey: "app-tags", + installed: true, + }, + ]); + }); }); diff --git a/apps/mesh/src/web/components/sandbox/content/app-catalog.ts b/apps/mesh/src/web/components/sandbox/content/app-catalog.ts index 8ea10c6749..8dda15ae51 100644 --- a/apps/mesh/src/web/components/sandbox/content/app-catalog.ts +++ b/apps/mesh/src/web/components/sandbox/content/app-catalog.ts @@ -1,9 +1,10 @@ import { isSiteAppBlock, SITE_APP_RESOLVE_TYPE, - type AppEntry, + appLabel, } from "@/web/components/sections-editor/page-list"; import { + isDecoAppResolveType, resolveBlockSchemaMetadata, type LiveMeta, } from "@/web/components/sections-editor/resolve-schema"; @@ -55,6 +56,18 @@ export function parseAppResolveType( return null; } +function parseAppIdentityFromBlockKey( + blockKey: string, +): { vendor: string; app: string } | null { + const blockIdMatch = blockKey.match(/^([^-]+)-(.+)$/); + if (!blockIdMatch) return null; + return { vendor: blockIdMatch[1]!, app: blockIdMatch[2]! }; +} + +function installedAppCategory(vendor: string): string { + return vendor === "local" ? "Custom" : "Installed"; +} + function findInstalledBlockKey( vendor: string, app: string, @@ -126,6 +139,37 @@ function catalogEntryFromManifestApp( }; } +function catalogEntryFromInstalledBlock( + blockKey: string, + block: Record, + meta: LiveMeta, +): AppCatalogEntry | null { + const resolveType = block.__resolveType; + if (typeof resolveType !== "string") return null; + if (isSiteAppBlock(blockKey, block)) return null; + if (!isDecoAppResolveType(resolveType)) return null; + + const parsed = + parseAppResolveType(resolveType) ?? parseAppIdentityFromBlockKey(blockKey); + if (!parsed) return null; + + const metadata = resolveBlockSchemaMetadata(resolveType, meta); + const { vendor, app } = parsed; + + return { + id: appBlockId(vendor, app), + app, + vendor, + title: metadata.title ?? appLabel(blockKey, block, meta), + description: metadata.description ?? "", + category: installedAppCategory(vendor), + logo: metadata.logo ?? metadata.icon, + resolveType, + blockKey, + installed: true, + }; +} + /** * Merges the deco app store, manifest schema apps, and installed decofile * blocks — mirrors admin's Apps view data sources. @@ -149,23 +193,18 @@ export function buildAppCatalog( byId.set(entry.id, entry); } - // Installed apps missing from store/schema (legacy block ids, local apps). - for (const installed of listInstalledAppEntries(decofile, meta)) { - const parsed = parseAppResolveType(installed.resolveType); - if (!parsed) continue; - const id = appBlockId(parsed.vendor, parsed.app); - if (byId.has(id)) continue; - byId.set(id, { - id, - app: parsed.app, - vendor: parsed.vendor, - title: installed.name, - description: "", - category: "Installed", - resolveType: installed.resolveType, - blockKey: installed.key, - installed: true, - }); + // Installed custom/local apps and legacy block ids missing from store + manifest. + for (const [blockKey, val] of Object.entries(decofile)) { + if (blockKey.includes("/")) continue; + if (!val || typeof val !== "object" || Array.isArray(val)) continue; + + const entry = catalogEntryFromInstalledBlock( + blockKey, + val as Record, + meta, + ); + if (!entry || byId.has(entry.id)) continue; + byId.set(entry.id, entry); } return [...byId.values()].sort(compareAppCatalogEntries); @@ -180,52 +219,3 @@ function compareAppCatalogEntries( } return a.title.localeCompare(b.title); } - -function manifestHasApp(meta: LiveMeta, vendor: string, app: string): boolean { - const apps = meta.manifest?.blocks?.apps ?? {}; - for (const alias of [ - appResolveType(vendor, app), - `site/apps/${vendor}/${app}.tsx`, - `${vendor}/apps/${app}.ts`, - `${vendor}/apps/${app}.tsx`, - ]) { - if (alias in apps) return true; - } - return false; -} - -function listInstalledAppEntries( - decofile: Record, - meta: LiveMeta, -): AppEntry[] { - const entries: AppEntry[] = []; - - for (const [key, val] of Object.entries(decofile)) { - if (key.includes("/")) continue; - if (!val || typeof val !== "object" || Array.isArray(val)) continue; - - const obj = val as Record; - const resolveType = obj.__resolveType; - if (typeof resolveType !== "string") continue; - if (isSiteAppBlock(key, obj)) continue; - - const parsed = - parseAppResolveType(resolveType) ?? - (() => { - const blockIdMatch = key.match(/^([^-]+)-(.+)$/); - return blockIdMatch - ? { vendor: blockIdMatch[1]!, app: blockIdMatch[2]! } - : null; - })(); - - if (!parsed || !manifestHasApp(meta, parsed.vendor, parsed.app)) continue; - - entries.push({ - key, - name: key.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()), - resolveType, - }); - } - - return entries; -} diff --git a/apps/mesh/src/web/components/sections-editor/page-list.tsx b/apps/mesh/src/web/components/sections-editor/page-list.tsx index 26a27cb08d..40ff5205c3 100644 --- a/apps/mesh/src/web/components/sections-editor/page-list.tsx +++ b/apps/mesh/src/web/components/sections-editor/page-list.tsx @@ -1,4 +1,5 @@ import { + isDecoAppResolveType, isResolvableManifestApp, resolveBlockSchemaMetadata, type LiveMeta, @@ -159,7 +160,8 @@ export function findSiteAppEntry( const resolveType = obj.__resolveType; if ( typeof resolveType === "string" && - isResolvableManifestApp(meta, resolveType) + (isSiteAppBlock(SITE_APP_BLOCK_KEY, obj) || + isResolvableManifestApp(meta, resolveType)) ) { return { key: SITE_APP_BLOCK_KEY, @@ -174,13 +176,17 @@ export function findSiteAppEntry( if (!val || typeof val !== "object" || Array.isArray(val)) continue; const obj = val as Record; - if (obj.__resolveType !== SITE_APP_RESOLVE_TYPE) continue; - if (!isResolvableManifestApp(meta, SITE_APP_RESOLVE_TYPE)) continue; + if (!isSiteAppBlock(key, obj)) continue; + + const resolveType = + typeof obj.__resolveType === "string" + ? obj.__resolveType + : SITE_APP_RESOLVE_TYPE; return { key, name: appLabel(key, obj, meta), - resolveType: SITE_APP_RESOLVE_TYPE, + resolveType, }; } @@ -204,7 +210,12 @@ export function extractApps( if (PAGE_RESOLVE_TYPES.has(resolveType)) continue; if (typeof obj.path === "string") continue; if (isSiteAppBlock(key, obj)) continue; - if (!isResolvableManifestApp(meta, resolveType)) continue; + if ( + !isResolvableManifestApp(meta, resolveType) && + !isDecoAppResolveType(resolveType) + ) { + continue; + } apps.push({ key, diff --git a/apps/mesh/src/web/components/sections-editor/resolve-schema.test.ts b/apps/mesh/src/web/components/sections-editor/resolve-schema.test.ts index 8044f71f03..1fe8a6f66a 100644 --- a/apps/mesh/src/web/components/sections-editor/resolve-schema.test.ts +++ b/apps/mesh/src/web/components/sections-editor/resolve-schema.test.ts @@ -163,6 +163,42 @@ describe("resolveSchema – app resolveType aliases", () => { expect(resolved?.properties?.seo?.title).toBe("SEO"); }); + test("resolves tanstack app schemas from base64 definition keys", () => { + const resolveType = "site/apps/local/app-tags.ts"; + const encoded = Buffer.from(resolveType).toString("base64"); + const meta: LiveMeta = { + manifest: { blocks: {} }, + schema: { + definitions: { + [encoded]: { + title: resolveType, + type: "object", + allOf: [ + { + $ref: "#/definitions/AppTagsProps", + }, + ], + properties: { + __resolveType: { + type: "string", + enum: [resolveType], + }, + }, + }, + AppTagsProps: { + type: "object", + properties: { + account: { type: "string", title: "Account Name" }, + }, + }, + }, + }, + }; + + const resolved = resolveSchema(resolveType, meta); + expect(resolved?.properties?.account?.title).toBe("Account Name"); + }); + test("prefers section array over page multivariate flag for site global", () => { const meta: LiveMeta = { manifest: { diff --git a/apps/mesh/src/web/components/sections-editor/resolve-schema.ts b/apps/mesh/src/web/components/sections-editor/resolve-schema.ts index d6cdb4fdda..021581c6b9 100644 --- a/apps/mesh/src/web/components/sections-editor/resolve-schema.ts +++ b/apps/mesh/src/web/components/sections-editor/resolve-schema.ts @@ -71,6 +71,12 @@ function isArraySchemaBranch(schema: RawSchema): boolean { // `format` field natively this guard becomes a no-op. const VIDEO_WIDGET_REF_KEY = "VideoWidget"; +/** Base64 encode resolveType keys — browser-safe (btoa), Node fallback in tests. */ +function toBase64(str: string): string { + if (typeof btoa === "function") return btoa(str); + return Buffer.from(str).toString("base64"); +} + function parseSiteAppResolveType( resolveType: string, ): { vendor: string; app: string } | null { @@ -112,19 +118,37 @@ function lookupManifestBlockSchema( const parsed = parseSiteAppResolveType(resolveType) ?? parseLegacyAppResolveType(resolveType); - if (!parsed) return {}; - - for (const alias of appManifestResolveTypeAliases( - parsed.vendor, - parsed.app, - )) { - for (const blockTypeMap of Object.values(allBlockTypes)) { - if (blockTypeMap[alias]) { - return blockTypeMap[alias] as RawSchema; + if (parsed) { + for (const alias of appManifestResolveTypeAliases( + parsed.vendor, + parsed.app, + )) { + for (const blockTypeMap of Object.values(allBlockTypes)) { + if (blockTypeMap[alias]) { + return blockTypeMap[alias] as RawSchema; + } } } } + // Tanstack sites generate app schemas with base64-encoded resolveType keys + // (same convention as sections). Fall back when manifest.blocks.apps is empty. + const encodedResolveType = toBase64(resolveType); + for (const blockTypeMap of Object.values(allBlockTypes)) { + if (blockTypeMap[encodedResolveType]) { + return blockTypeMap[encodedResolveType] as RawSchema; + } + } + + const globalSchema = meta.schema ?? {}; + const defs = (globalSchema.$defs ?? globalSchema.definitions ?? {}) as Record< + string, + unknown + >; + if (defs[encodedResolveType]) { + return { $ref: `#/definitions/${encodedResolveType}` }; + } + return {}; } @@ -139,8 +163,31 @@ export function isResolvableManifestApp( parseLegacyAppResolveType(resolveType); if (!parsed) return false; const apps = meta.manifest?.blocks?.apps ?? {}; - return appManifestResolveTypeAliases(parsed.vendor, parsed.app).some( - (alias) => alias in apps, + if ( + appManifestResolveTypeAliases(parsed.vendor, parsed.app).some( + (alias) => alias in apps, + ) + ) { + return true; + } + const encodedResolveType = toBase64(resolveType); + const defs = (meta.schema?.$defs ?? meta.schema?.definitions ?? {}) as Record< + string, + unknown + >; + return encodedResolveType in defs; +} + +/** + * Whether resolveType is a deco app module path (site/apps or legacy vendor/apps), + * excluding the site app itself. Used to detect installed custom/local apps even + * when they are missing from manifest.blocks.apps. + */ +export function isDecoAppResolveType(resolveType: string): boolean { + if (resolveType === "site/apps/site.ts") return false; + return ( + parseSiteAppResolveType(resolveType) !== null || + parseLegacyAppResolveType(resolveType) !== null ); } From 79b5a8c984f872271eb705962ecaeaa5e44e122d Mon Sep 17 00:00:00 2001 From: guitavano Date: Thu, 18 Jun 2026 18:17:41 -0300 Subject: [PATCH 2/8] fix(cms): secret fields, array reorder, and app item labels Render website/loaders/secret blocks in app forms, enable drag-to-reorder on schema array fields, and map {{{name}}} array item titles to titleBy. Co-authored-by: Cursor --- .../sections-editor/fields/array-field.tsx | 213 +++++++++++++----- .../sections-editor/fields/secret-field.tsx | 81 +++++++ .../sections-editor/resolve-schema.ts | 6 + .../sections-editor/schema-form.tsx | 21 +- 4 files changed, 267 insertions(+), 54 deletions(-) create mode 100644 apps/mesh/src/web/components/sections-editor/fields/secret-field.tsx diff --git a/apps/mesh/src/web/components/sections-editor/fields/array-field.tsx b/apps/mesh/src/web/components/sections-editor/fields/array-field.tsx index 7cbc77167b..01e7de7749 100644 --- a/apps/mesh/src/web/components/sections-editor/fields/array-field.tsx +++ b/apps/mesh/src/web/components/sections-editor/fields/array-field.tsx @@ -1,3 +1,21 @@ +import { useState } from "react"; +import { + DndContext, + type DragEndEvent, + KeyboardSensor, + PointerSensor, + closestCenter, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, + arrayMove, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; import { DotsGrid, DotsHorizontal, Plus, Trash01 } from "@untitledui/icons"; import { Button } from "@deco/ui/components/button.tsx"; import { @@ -6,12 +24,104 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@deco/ui/components/dropdown-menu.tsx"; +import { cn } from "@deco/ui/lib/utils.ts"; import { getArrayItemImageSrc, getArrayItemLabel } from "../array-item-display"; import { isEmbeddedUnionResolveType } from "../block-type-utils"; import { resolveArrayItemSelection } from "../schema-form-breadcrumb"; import type { FieldProps } from "./field-props"; import { SchemaForm, renderField } from "../schema-form"; +function sortableIdFor(path: string, index: number): string { + return `${path}::${index}`; +} + +function SortableArrayRow({ + sortableId, + labelText, + imageSrc, + onOpen, + onRemove, +}: { + sortableId: string; + labelText: string; + imageSrc?: string; + onOpen: () => void; + onRemove: () => void; +}) { + const { attributes, listeners, setNodeRef, transform, isDragging } = + useSortable({ + id: sortableId, + animateLayoutChanges: () => false, + }); + + const style = { + transform: CSS.Transform.toString( + transform ? { ...transform, x: 0 } : null, + ), + opacity: isDragging ? 0.4 : undefined, + }; + + return ( +
+ + + + + + + + + + Delete + + + +
+ ); +} + export function ArrayField({ schema, value, @@ -33,11 +143,13 @@ export function ArrayField({ itemSchema, ); const selectedIndex = selection?.index ?? null; + const [isDragging, setIsDragging] = useState(false); const itemLabel = (item: unknown, index: number) => getArrayItemLabel(item, index, itemSchema); const openItem = (index: number) => { + if (isDragging) return; const labelText = itemLabel(items[index], index); onBreadcrumbChange?.([...breadcrumbPath, labelText]); }; @@ -100,6 +212,25 @@ export function ArrayField({ } }; + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + const sortableIds = items.map((_, i) => sortableIdFor(path, i)); + + const handleDragEnd = (event: DragEndEvent) => { + setIsDragging(false); + const { active, over } = event; + if (!over || active.id === over.id) return; + const oldIndex = sortableIds.indexOf(String(active.id)); + const newIndex = sortableIds.indexOf(String(over.id)); + if (oldIndex === -1 || newIndex === -1) return; + onChange(arrayMove([...items], oldIndex, newIndex)); + }; + if (selectedIndex !== null && selectedIndex < items.length) { const item = items[selectedIndex]; const arrayItemPrefix = () => { @@ -166,59 +297,35 @@ export function ArrayField({ {items.length > 0 && ( -
- {items.map((item, i) => { - const labelText = itemLabel(item, i); - const imageSrc = getArrayItemImageSrc(item, itemSchema); - return ( -
- - - - - - - - removeItem(i)} - > - - Delete - - - -
- ); - })} -
+ setIsDragging(true)} + onDragEnd={handleDragEnd} + onDragCancel={() => setIsDragging(false)} + > + +
+ {items.map((item, i) => { + const labelText = itemLabel(item, i); + const imageSrc = getArrayItemImageSrc(item, itemSchema); + return ( + openItem(i)} + onRemove={() => removeItem(i)} + /> + ); + })} +
+
+
)} - + + {imageSrc && ( + + )} + {labelText} @@ -322,8 +323,7 @@ export function ArrayField({ const activeEntry = activeEntryId ? entries.find((entry) => entry.id === activeEntryId) : null; - const activeItem = - activeEntry != null ? items[activeEntry.index] : undefined; + const activeItem = activeEntry != null ? items[activeEntry.index] : undefined; const activeLabel = activeEntry != null && activeItem !== undefined ? itemLabel(activeItem, activeEntry.index) @@ -433,9 +433,7 @@ export function ArrayField({ {activeLabel ? ( -
+
Date: Thu, 18 Jun 2026 18:31:42 -0300 Subject: [PATCH 5/8] fix(cms): use pointer cursor on array rows until drag starts Match section list: cursor-pointer on hover, grabbing only while dragging. Co-authored-by: Cursor --- .../src/web/components/sections-editor/fields/array-field.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mesh/src/web/components/sections-editor/fields/array-field.tsx b/apps/mesh/src/web/components/sections-editor/fields/array-field.tsx index aba10613a0..f150dae60b 100644 --- a/apps/mesh/src/web/components/sections-editor/fields/array-field.tsx +++ b/apps/mesh/src/web/components/sections-editor/fields/array-field.tsx @@ -136,7 +136,7 @@ function SortableArrayRow({ }} className={cn( "group flex min-w-0 items-center gap-2.5 rounded-lg px-2 py-2.5 hover:bg-accent hover:text-accent-foreground touch-none", - isDragging ? "cursor-grabbing" : "cursor-grab", + isDragging ? "cursor-grabbing" : "cursor-pointer", )} title={labelText} > From 44ffdbf88606c2b1f17ee72bf5615cd1df679d33 Mon Sep 17 00:00:00 2001 From: guitavano Date: Fri, 19 Jun 2026 09:46:59 -0300 Subject: [PATCH 6/8] fix(cms): array drill-down, site schema depth, and global section picker Enable breadcrumb navigation into array items, resolve flag-list anyOf schemas as arrays, and traverse deeper allOf chains so the Site app shows global/caching fields. Custom app icons align with logo rows, and Global Sections uses the same Add section gallery as the Sections tab. Co-authored-by: Cursor --- .../components/sandbox/content/app-editor.tsx | 64 ++++++- .../sandbox/content/content-browser.tsx | 40 ++-- .../sections-editor/fields/array-field.tsx | 176 ++++++++++++++++-- .../sections-editor/fields/field-props.ts | 7 + .../sections-editor/resolve-schema.test.ts | 113 +++++++++++ .../sections-editor/resolve-schema.ts | 97 ++++++++-- .../schema-form-breadcrumb.test.ts | 74 ++++++++ .../sections-editor/schema-form-breadcrumb.ts | 86 +++++++-- .../sections-editor/schema-form.tsx | 19 +- .../section-array-field.test.ts | 63 +++++++ .../sections-editor/section-array-field.ts | 22 +++ 11 files changed, 686 insertions(+), 75 deletions(-) create mode 100644 apps/mesh/src/web/components/sections-editor/section-array-field.test.ts create mode 100644 apps/mesh/src/web/components/sections-editor/section-array-field.ts diff --git a/apps/mesh/src/web/components/sandbox/content/app-editor.tsx b/apps/mesh/src/web/components/sandbox/content/app-editor.tsx index 5fe1388587..545f694baf 100644 --- a/apps/mesh/src/web/components/sandbox/content/app-editor.tsx +++ b/apps/mesh/src/web/components/sandbox/content/app-editor.tsx @@ -1,13 +1,19 @@ import { ChevronRight, Loading01 } from "@untitledui/icons"; import { useState } from "react"; +import { toast } from "sonner"; import { cn } from "@deco/ui/lib/utils.js"; import { ScrollArea } from "@deco/ui/components/scroll-area.tsx"; import { appLabel } from "@/web/components/sections-editor/page-list"; import type { LiveMeta } from "@/web/components/sections-editor/resolve-schema"; +import type { SectionCatalogEntry } from "@/web/components/sections-editor/section-catalog"; import { createReferencedBlockSaver } from "@/web/components/sections-editor/save-referenced-block"; +import { + useDebouncedSaveBlock, + useSaveBlock, +} from "@/web/components/sections-editor/use-save-block"; import { resolveAppEditorSchema } from "./app-editor-schema"; +import { nextUniqueBlockKey } from "./content-mutations"; import { SchemaForm } from "@/web/components/sections-editor/schema-form"; -import { useDebouncedSaveBlock } from "@/web/components/sections-editor/use-save-block"; import { SaveStatus } from "./blog/save-status"; export function AppEditor({ @@ -21,6 +27,7 @@ export function AppEditor({ title: titleOverride, excludeFields, schemaPending = false, + previewBaseUrl = null, }: { orgSlug: string; virtualMcpId: string; @@ -33,6 +40,7 @@ export function AppEditor({ /** Top-level schema fields to omit (e.g. site `seo` is edited in the SEO tab). */ excludeFields?: readonly string[]; schemaPending?: boolean; + previewBaseUrl?: string | null; }) { const resolveType = typeof block?.__resolveType === "string" ? block.__resolveType : ""; @@ -47,6 +55,7 @@ export function AppEditor({ virtualMcpId, branch, }); + const saveBlock = useSaveBlock({ orgSlug, virtualMcpId, branch }); const saveReferencedBlock = createReferencedBlockSaver((refKey, data) => save(refKey, data), ); @@ -65,8 +74,11 @@ export function AppEditor({ setBreadcrumbs([]); } - const savedValue = block ?? {}; - const effectiveValue = (formValue ?? savedValue) as Record; + const savedValue = (block ?? {}) as Record; + const effectiveValue = { + ...savedValue, + ...(formValue ?? {}), + } as Record; const handleChange = (next: unknown) => { const nextRecord = next as Record; @@ -77,6 +89,33 @@ export function AppEditor({ }); }; + const handleBreadcrumbChange = (next: string[]) => { + setBreadcrumbs(next); + }; + + const handleAddSectionItem = async ( + entry: SectionCatalogEntry, + append: (item: unknown) => void, + ) => { + const baseLabel = (entry.title || entry.resolveType.split("/").pop() || "") + .replace(/\.(tsx?|jsx?)$/, "") + .replace(/[^A-Za-z0-9_-]/g, ""); + const safeBase = + /^[A-Za-z]/.test(baseLabel) && baseLabel.length > 0 + ? baseLabel + : "Section"; + const newKey = nextUniqueBlockKey(decofile, safeBase); + const data: Record = { + __resolveType: entry.resolveType, + name: newKey, + }; + await saveBlock.mutateAsync({ blockKey: newKey, data }); + toast.success(`Created section "${newKey}"`); + append({ __resolveType: newKey }); + }; + + const breadcrumbKey = breadcrumbs.join("\0"); + const headerCrumbs = breadcrumbs.length > 0 ? [title, ...breadcrumbs] : []; return ( @@ -99,11 +138,14 @@ export function AppEditor({ )} + + {usesSectionPicker && previewBaseUrl && ( + { + void handleSelectSection(entry); + }} + /> + )}
); } diff --git a/apps/mesh/src/web/components/sections-editor/fields/field-props.ts b/apps/mesh/src/web/components/sections-editor/fields/field-props.ts index b28a844df6..4bdbbeed5d 100644 --- a/apps/mesh/src/web/components/sections-editor/fields/field-props.ts +++ b/apps/mesh/src/web/components/sections-editor/fields/field-props.ts @@ -1,4 +1,5 @@ import type { LiveMeta, SchemaProperty } from "../resolve-schema"; +import type { SectionCatalogEntry } from "../section-catalog"; export interface FieldProps { schema: SchemaProperty; @@ -10,6 +11,12 @@ export interface FieldProps { onBreadcrumbChange?: (path: string[]) => void; meta?: LiveMeta; decofile?: Record; + containerResolveType?: string; + previewBaseUrl?: string | null; + onAddSectionItem?: ( + entry: SectionCatalogEntry, + append: (item: unknown) => void, + ) => void | Promise; onSaveReferencedBlock?: ( blockKey: string, data: Record, diff --git a/apps/mesh/src/web/components/sections-editor/resolve-schema.test.ts b/apps/mesh/src/web/components/sections-editor/resolve-schema.test.ts index 1fe8a6f66a..ad73890aa7 100644 --- a/apps/mesh/src/web/components/sections-editor/resolve-schema.test.ts +++ b/apps/mesh/src/web/components/sections-editor/resolve-schema.test.ts @@ -199,6 +199,66 @@ describe("resolveSchema – app resolveType aliases", () => { expect(resolved?.properties?.account?.title).toBe("Account Name"); }); + test("merges properties from deeply nested allOf chains", () => { + const resolveType = "site/apps/site.ts"; + const encoded = Buffer.from(resolveType).toString("base64"); + const meta: LiveMeta = { + manifest: { + blocks: { + apps: { + [resolveType]: { $ref: `#/definitions/${encoded}` }, + }, + }, + }, + schema: { + definitions: { + BaseSite: { + type: "object", + properties: { + global: { + title: "Global Sections", + type: "array", + items: { type: "object" }, + }, + caching: { type: "object", title: "Caching configuration" }, + }, + }, + ExtensionSite: { + type: "object", + properties: { + badIPS: { + type: "array", + title: "Bad IPS", + items: { type: "string" }, + }, + }, + }, + Layer4: { $ref: "#/definitions/Layer3" }, + Layer3: { $ref: "#/definitions/Layer2" }, + Layer2: { $ref: "#/definitions/Layer1" }, + Layer1: { $ref: "#/definitions/Layer0" }, + Layer0: { + allOf: [ + { $ref: "#/definitions/ExtensionSite" }, + { $ref: "#/definitions/BaseSite" }, + ], + }, + [encoded]: { + allOf: [{ $ref: "#/definitions/Layer4" }], + properties: { + __resolveType: { type: "string", enum: [resolveType] }, + }, + }, + }, + }, + }; + + const resolved = resolveSchema(resolveType, meta); + expect(resolved?.properties?.global?.title).toBe("Global Sections"); + expect(resolved?.properties?.caching?.title).toBe("Caching configuration"); + expect(resolved?.properties?.badIPS?.title).toBe("Bad IPS"); + }); + test("prefers section array over page multivariate flag for site global", () => { const meta: LiveMeta = { manifest: { @@ -266,6 +326,59 @@ describe("resolveSchema – app resolveType aliases", () => { expect(global?.anyOfRefs).toBeUndefined(); expect(global?.items).toBeDefined(); }); + + test("prefers config array over product-list loaders in app flag anyOf", () => { + const loaderRef = "#/definitions/ProductList"; + const meta: LiveMeta = { + manifest: { blocks: {} }, + schema: { + definitions: { + ProductList: { + type: "object", + properties: { + __resolveType: { + enum: ["vtex/loaders/ProductList.ts"], + }, + query: { type: "string", title: "Query" }, + }, + }, + AppProps: { + type: "object", + properties: { + flags: { + title: "Flags Personalizada", + anyOf: [ + { $ref: "#/definitions/Resolvable" }, + { + type: "array", + items: { + type: "object", + title: "{{{name}}}", + properties: { + name: { type: "string", title: "Name" }, + text: { type: "string", title: "Text" }, + }, + }, + }, + { $ref: loaderRef }, + ], + }, + }, + }, + [Buffer.from("site/apps/local/app-tags.ts").toString("base64")]: { + allOf: [{ $ref: "#/definitions/AppProps" }], + }, + }, + }, + }; + + const flags = resolveSchema("site/apps/local/app-tags.ts", meta)?.properties + ?.flags; + expect(flags?.type).toBe("array"); + expect(flags?.items?.properties?.name?.title).toBe("Name"); + expect(flags?.items?.titleBy).toBe("{{{name}}}"); + expect(flags?.anyOfRefs).toBeUndefined(); + }); }); describe("resolveSchema – type-discriminated unions", () => { diff --git a/apps/mesh/src/web/components/sections-editor/resolve-schema.ts b/apps/mesh/src/web/components/sections-editor/resolve-schema.ts index 43b647252a..2f2f619135 100644 --- a/apps/mesh/src/web/components/sections-editor/resolve-schema.ts +++ b/apps/mesh/src/web/components/sections-editor/resolve-schema.ts @@ -60,11 +60,36 @@ export interface LiveMeta { type RawSchema = Record; +/** Max `$ref` / `allOf` hops while flattening top-level properties. */ +const MAX_COLLECT_PROPS_DEPTH = 12; + +/** Max recursion while building nested field schemas. */ +const MAX_BUILD_PROPERTY_DEPTH = 8; + function isArraySchemaBranch(schema: RawSchema): boolean { const t = schema.type; return t === "array" || (Array.isArray(t) && t.includes("array")); } +/** + * Section/loader arrays (items carry `__resolveType` or `anyOf`) vs config + * arrays (plain object items, e.g. app flag lists). + */ +function isSectionLoaderArrayBranch( + branch: RawSchema, + resolveRef: (ref: string) => RawSchema, +): boolean { + let items = branch.items as RawSchema | undefined; + if (!items) return false; + if (typeof items.$ref === "string") { + items = resolveRef(items.$ref); + } + if (Array.isArray(items.anyOf) || Array.isArray(items.oneOf)) return true; + const props = items.properties as RawSchema | undefined; + const rtEnum = (props?.__resolveType as RawSchema | undefined)?.enum; + return Array.isArray(rtEnum) && typeof rtEnum[0] === "string"; +} + // deco.cx convention: VideoWidget schemas don't carry `format` in their // JSON Schema definition, so we inject it here so the UI can render a // VideoField instead of a generic FileField. If the schema ever gains the @@ -281,7 +306,7 @@ export function resolveSchema( seenRefs: Set = new Set(), depth = 0, ): RawSchema => { - if (depth > 5) return {}; + if (depth > MAX_COLLECT_PROPS_DEPTH) return {}; if (typeof s.$ref === "string") { const key = s.$ref.split("/").pop() ?? ""; @@ -421,6 +446,8 @@ export function resolveSchema( // Site `global` / page `sections`: plain section arrays with an optional // page multivariate flag branch. Prefer the array (admin hides the flag UI). + // App config arrays (e.g. flag lists) share anyOf with product-list loaders; + // prefer the array branch when items are plain objects, not section refs. const arrayBranch = nonNull.find(isArraySchemaBranch); const hasPageMultivariateLoader = loaderBranches.some((branch) => { const rtEnum = ( @@ -433,18 +460,41 @@ export function resolveSchema( rtEnum[0] === PAGE_MULTIVARIATE_FLAG_RESOLVE_TYPE ); }); - if (arrayBranch && hasPageMultivariateLoader) { - const built = buildProperty(arrayBranch, depth + 1); - return { - ...built, - type: "array", - title: - typeof resolved.title === "string" ? resolved.title : built.title, - description: - typeof resolved.description === "string" - ? resolved.description - : built.description, - }; + if (arrayBranch) { + const isConfigArray = !isSectionLoaderArrayBranch( + arrayBranch, + resolveRef, + ); + if (isConfigArray && nonNull.length > 1) { + const built = buildProperty(arrayBranch, depth + 1); + return { + ...built, + type: "array", + title: + typeof resolved.title === "string" + ? resolved.title + : built.title, + description: + typeof resolved.description === "string" + ? resolved.description + : built.description, + }; + } + if (hasPageMultivariateLoader) { + const built = buildProperty(arrayBranch, depth + 1); + return { + ...built, + type: "array", + title: + typeof resolved.title === "string" + ? resolved.title + : built.title, + description: + typeof resolved.description === "string" + ? resolved.description + : built.description, + }; + } } if (loaderBranches.length > 0) { @@ -464,7 +514,9 @@ export function resolveSchema( ? branch.description : undefined, schema: - depth + 1 < 6 ? buildProperty(branch, depth + 1) : undefined, + depth + 1 < MAX_BUILD_PROPERTY_DEPTH + ? buildProperty(branch, depth + 1) + : undefined, }; }); return { @@ -499,7 +551,10 @@ export function resolveSchema( ? def.description : undefined, discriminatorValue, - schema: depth + 1 < 6 ? buildProperty(def, depth + 1) : undefined, + schema: + depth + 1 < MAX_BUILD_PROPERTY_DEPTH + ? buildProperty(def, depth + 1) + : undefined, }; }); return { @@ -565,7 +620,10 @@ export function resolveSchema( ? def.description : undefined, discriminatorValue, - schema: depth + 1 < 6 ? buildProperty(def, depth + 1) : undefined, + schema: + depth + 1 < MAX_BUILD_PROPERTY_DEPTH + ? buildProperty(def, depth + 1) + : undefined, }); } return { @@ -592,7 +650,7 @@ export function resolveSchema( // deco sections nest images at depth 4+ (`images[].desktop.src`); the // old cap left those leaves un-resolved and stripped their `format`. let nestedProperties: Record | undefined; - if (depth < 6) { + if (depth < MAX_BUILD_PROPERTY_DEPTH) { const nestedRaw = collectProps(resolved); const nestedEntries = Object.entries(nestedRaw).filter( ([k]) => !k.startsWith("__") && k !== "@type", @@ -607,7 +665,10 @@ export function resolveSchema( // Array items let itemsSchema: SchemaProperty | undefined; - if ((type === "array" || resolved.type === "array") && depth < 6) { + if ( + (type === "array" || resolved.type === "array") && + depth < MAX_BUILD_PROPERTY_DEPTH + ) { let rawItems = resolved.items as RawSchema | undefined; if (rawItems) { if (typeof rawItems.$ref === "string") { diff --git a/apps/mesh/src/web/components/sections-editor/schema-form-breadcrumb.test.ts b/apps/mesh/src/web/components/sections-editor/schema-form-breadcrumb.test.ts index 6fa2da4184..2bda028e7c 100644 --- a/apps/mesh/src/web/components/sections-editor/schema-form-breadcrumb.test.ts +++ b/apps/mesh/src/web/components/sections-editor/schema-form-breadcrumb.test.ts @@ -1,6 +1,8 @@ import { describe, expect, test } from "bun:test"; import type { SchemaProperty } from "./resolve-schema"; import { + breadcrumbPathForActiveField, + buildArrayDrillDownBreadcrumb, fieldDisplayLabel, isArrayDrillDownField, resolveActiveFieldKey, @@ -60,6 +62,16 @@ describe("resolveActiveFieldKey", () => { expect(resolveActiveFieldKey(["layout"], properties, {}, [])).toBeNull(); }); + test("matches array field label anywhere in breadcrumb trail", () => { + expect( + resolveActiveFieldKey(["layout", "cards"], properties, {}, [ + "Options", + "Cards", + "Men's", + ]), + ).toBe("cards"); + }); + test("finds nested array field inside object ancestor", () => { const globalHeader = { logos: { title: "Logos", type: "object", properties: {} }, @@ -96,6 +108,27 @@ describe("resolveActiveFieldKey", () => { ), ).toBe("alert"); }); + + test("matches array field when runtime value is an array", () => { + const properties = { + appKey: { type: "string", title: "App Key" } as SchemaProperty, + flags: { + title: "Flags Personalizada", + type: "object", + } as SchemaProperty, + }; + + expect( + resolveActiveFieldKey( + ["appKey", "flags"], + properties, + { + flags: [{ name: "Sale" }, { name: "Holiday" }], + }, + ["Flags Personalizada", "Sale"], + ), + ).toBe("flags"); + }); }); describe("isArrayDrillDownField", () => { @@ -137,6 +170,47 @@ describe("isArrayDrillDownField", () => { }); }); +describe("buildArrayDrillDownBreadcrumb", () => { + test("includes array label before item label", () => { + expect( + buildArrayDrillDownBreadcrumb([], "Flag Desconto", "Partiu ferias"), + ).toEqual(["Flag Desconto", "Partiu ferias"]); + }); + + test("does not duplicate crumbs already in trail", () => { + expect( + buildArrayDrillDownBreadcrumb( + ["Flag Desconto", "Partiu ferias"], + "Flag Desconto", + "Partiu ferias", + ), + ).toEqual(["Flag Desconto", "Partiu ferias"]); + }); +}); + +describe("breadcrumbPathForActiveField", () => { + const schema = { + title: "Flag Desconto", + type: "array", + items: { type: "object" }, + } as SchemaProperty; + + test("strips array field label from head", () => { + expect( + breadcrumbPathForActiveField("flags", schema, [ + "Flag Desconto", + "Partiu ferias", + ]), + ).toEqual(["Partiu ferias"]); + }); + + test("keeps trail when head is item label", () => { + expect( + breadcrumbPathForActiveField("flags", schema, ["Partiu ferias"]), + ).toEqual(["Partiu ferias"]); + }); +}); + describe("resolveArrayItemSelection", () => { const itemSchema = { type: "object", diff --git a/apps/mesh/src/web/components/sections-editor/schema-form-breadcrumb.ts b/apps/mesh/src/web/components/sections-editor/schema-form-breadcrumb.ts index 2d801729d6..084d6d9bb1 100644 --- a/apps/mesh/src/web/components/sections-editor/schema-form-breadcrumb.ts +++ b/apps/mesh/src/web/components/sections-editor/schema-form-breadcrumb.ts @@ -9,14 +9,64 @@ function humanize(key: string): string { .replace(/^\w/, (c) => c.toUpperCase()); } +/** Normalize labels so breadcrumb matching survives NFC/NFD differences (e.g. `ª`). */ +export function normalizeBreadcrumbLabel(label: string): string { + return label.normalize("NFC").trim(); +} + +function labelsMatch(a: string, b: string): boolean { + return normalizeBreadcrumbLabel(a) === normalizeBreadcrumbLabel(b); +} + +/** Breadcrumb trail when opening an array item (includes the array field label). */ +export function buildArrayDrillDownBreadcrumb( + breadcrumbPath: string[], + arrayLabel: string, + itemLabel: string, +): string[] { + const normalizedItem = normalizeBreadcrumbLabel(itemLabel); + if ( + breadcrumbPath.some( + (crumb) => + labelsMatch(crumb, normalizedItem) || labelsMatch(crumb, itemLabel), + ) + ) { + return breadcrumbPath; + } + const trail = [...breadcrumbPath]; + const hasArrayLabel = trail.some((crumb) => labelsMatch(crumb, arrayLabel)); + if (!hasArrayLabel) trail.push(arrayLabel); + trail.push(itemLabel); + return trail; +} + +/** Drop crumbs consumed by the active field so children see a relative trail. */ +export function breadcrumbPathForActiveField( + activeKey: string, + schema: SchemaProperty, + breadcrumbPath: string[], +): string[] { + if (breadcrumbPath.length === 0) return breadcrumbPath; + const label = fieldDisplayLabel(activeKey, schema); + const head = breadcrumbPath[0]!; + if (labelsMatch(head, label) || labelsMatch(head, activeKey)) { + return breadcrumbPath.slice(1); + } + return breadcrumbPath; +} + export function fieldDisplayLabel(key: string, schema: SchemaProperty): string { return schema.title ?? humanize(key); } /** Breadcrumb drill-down applies to array fields only (not nested objects). */ -export function isArrayDrillDownField(schema: SchemaProperty): boolean { +export function isArrayDrillDownField( + schema: SchemaProperty, + value?: unknown, +): boolean { if (schema.type === "array" && schema.items) return true; - return isPageMultivariateSectionArrayField(schema); + if (isPageMultivariateSectionArrayField(schema)) return true; + return Array.isArray(value); } function asObjectRecord(value: unknown): Record { @@ -37,19 +87,27 @@ function resolveActiveFieldKeyInScope( for (const key of keys) { const schema = properties[key]; - if (!schema || !isArrayDrillDownField(schema)) continue; + if (!schema || !isArrayDrillDownField(schema, objValue[key])) continue; const label = fieldDisplayLabel(key, schema); - if (head === label || head === key) return key; + if (labelsMatch(head, label) || labelsMatch(head, key)) return key; + if ( + breadcrumbPath.some( + (crumb) => labelsMatch(crumb, label) || labelsMatch(crumb, key), + ) + ) { + return key; + } } for (const key of keys) { const schema = properties[key]; - if (!schema || !isArrayDrillDownField(schema)) continue; + if (!schema || !isArrayDrillDownField(schema, objValue[key])) continue; const val = objValue[key]; if (!Array.isArray(val)) continue; const itemSchema = schema.items; for (let i = 0; i < val.length; i++) { - if (getArrayItemLabel(val[i], i, itemSchema) === head) return key; + const itemLabel = getArrayItemLabel(val[i], i, itemSchema); + if (labelsMatch(itemLabel, head)) return key; } } @@ -68,7 +126,7 @@ function resolveActiveFieldKeyInScope( ); if (direct) return key; - if (head === label || head === key) { + if (labelsMatch(head, label) || labelsMatch(head, key)) { const viaLabel = resolveActiveFieldKeyInScope( childKeys, schema.properties, @@ -120,7 +178,7 @@ export function isBreadcrumbInsideObject( } const head = breadcrumbPath[0]!; - if (head !== label && head !== fieldKey) return false; + if (!labelsMatch(head, label) && !labelsMatch(head, fieldKey)) return false; return ( breadcrumbPath.length > 1 || @@ -143,19 +201,21 @@ export function resolveArrayItemSelection( for (let pi = 0; pi < breadcrumbPath.length; pi++) { const crumb = breadcrumbPath[pi]!; - const index = items.findIndex( - (item, i) => getArrayItemLabel(item, i, itemSchema) === crumb, + const index = items.findIndex((item, i) => + labelsMatch(getArrayItemLabel(item, i, itemSchema), crumb), ); if (index >= 0) { return { index, innerPath: breadcrumbPath.slice(pi + 1) }; } } - const labelIndex = breadcrumbPath.indexOf(label); + const labelIndex = breadcrumbPath.findIndex((crumb) => + labelsMatch(crumb, label), + ); if (labelIndex >= 0 && breadcrumbPath.length > labelIndex + 1) { const itemCrumb = breadcrumbPath[labelIndex + 1]!; - const index = items.findIndex( - (item, i) => getArrayItemLabel(item, i, itemSchema) === itemCrumb, + const index = items.findIndex((item, i) => + labelsMatch(getArrayItemLabel(item, i, itemSchema), itemCrumb), ); if (index >= 0) { return { index, innerPath: breadcrumbPath.slice(labelIndex + 2) }; diff --git a/apps/mesh/src/web/components/sections-editor/schema-form.tsx b/apps/mesh/src/web/components/sections-editor/schema-form.tsx index d681d864da..89e0ffcd1e 100644 --- a/apps/mesh/src/web/components/sections-editor/schema-form.tsx +++ b/apps/mesh/src/web/components/sections-editor/schema-form.tsx @@ -17,6 +17,7 @@ import { wrapMultivariateArrayValue, } from "./page-variants"; import { + breadcrumbPathForActiveField, fieldDisplayLabel, resolveActiveFieldKey, } from "./schema-form-breadcrumb"; @@ -248,6 +249,8 @@ export function SchemaForm({ meta, decofile, onSaveReferencedBlock, + previewBaseUrl, + onAddSectionItem, }: { schema: SchemaProperty; value: unknown; @@ -261,6 +264,8 @@ export function SchemaForm({ blockKey: string, data: Record, ) => void; + previewBaseUrl?: string | null; + onAddSectionItem?: FieldProps["onAddSectionItem"]; }) { const properties = schema.properties; if (!properties) return null; @@ -286,12 +291,21 @@ export function SchemaForm({ onChange({ ...objValue, [key]: fieldValue }); }; + const containerResolveType = + typeof objValue.__resolveType === "string" + ? objValue.__resolveType + : undefined; + const activeKey = breadcrumbPath.length > 0 ? resolveActiveFieldKey(keys, properties, objValue, breadcrumbPath) : null; const activeSchema = activeKey ? properties[activeKey] : null; const visibleKeys = activeKey && activeSchema ? [activeKey] : keys; + const fieldBreadcrumbPath = + activeKey && activeSchema + ? breadcrumbPathForActiveField(activeKey, activeSchema, breadcrumbPath) + : breadcrumbPath; return (
{visibleKeys.map((key) => { @@ -306,11 +320,14 @@ export function SchemaForm({ onChange: (val) => updateField(key, val), path: fieldPath, label, - breadcrumbPath, + breadcrumbPath: fieldBreadcrumbPath, onBreadcrumbChange, meta, decofile, onSaveReferencedBlock, + containerResolveType, + previewBaseUrl, + onAddSectionItem, }); })}
diff --git a/apps/mesh/src/web/components/sections-editor/section-array-field.test.ts b/apps/mesh/src/web/components/sections-editor/section-array-field.test.ts new file mode 100644 index 0000000000..6888157deb --- /dev/null +++ b/apps/mesh/src/web/components/sections-editor/section-array-field.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "bun:test"; +import type { SchemaProperty } from "./resolve-schema"; +import { isSectionArrayField } from "./section-array-field"; +import { PAGE_MULTIVARIATE_FLAG_RESOLVE_TYPE } from "./section-types"; + +describe("isSectionArrayField", () => { + test("detects global/page section arrays", () => { + expect( + isSectionArrayField({ + type: "array", + items: { + type: "block-ref", + anyOfRefs: [ + { resolveType: "site/sections/Analytics.tsx", title: "Analytics" }, + ], + }, + } as SchemaProperty), + ).toBe(true); + }); + + test("detects page multivariate section array fields", () => { + expect( + isSectionArrayField({ + type: "block-ref", + anyOfRefs: [ + { + resolveType: PAGE_MULTIVARIATE_FLAG_RESOLVE_TYPE, + title: "Page Variants", + schema: { + type: "object", + properties: { + variants: { + type: "array", + items: { + type: "object", + properties: { + value: { type: "array", items: { type: "object" } }, + }, + }, + }, + }, + }, + }, + ], + } as SchemaProperty), + ).toBe(true); + }); + + test("ignores config flag arrays", () => { + expect( + isSectionArrayField({ + type: "array", + items: { + type: "object", + properties: { + name: { type: "string", title: "Name" }, + text: { type: "string", title: "Text" }, + }, + }, + } as SchemaProperty), + ).toBe(false); + }); +}); diff --git a/apps/mesh/src/web/components/sections-editor/section-array-field.ts b/apps/mesh/src/web/components/sections-editor/section-array-field.ts new file mode 100644 index 0000000000..d4fc1c8921 --- /dev/null +++ b/apps/mesh/src/web/components/sections-editor/section-array-field.ts @@ -0,0 +1,22 @@ +import type { SchemaProperty } from "./resolve-schema"; +import { isPageMultivariateSectionArrayField } from "./page-variants"; +import { SECTION_MULTIVARIATE_RESOLVE_TYPE } from "./section-types"; + +/** True when an array field holds page/global section entries (block-ref items). */ +export function isSectionArrayField(schema: SchemaProperty): boolean { + if (isPageMultivariateSectionArrayField(schema)) return true; + + const items = schema.type === "array" ? schema.items : undefined; + if (!items || items.type !== "block-ref" || !items.anyOfRefs?.length) { + return false; + } + + return items.anyOfRefs.some((ref) => { + const rt = ref.resolveType; + return ( + rt.includes("/sections/") || + rt.includes("multivariate/section") || + rt === SECTION_MULTIVARIATE_RESOLVE_TYPE + ); + }); +} From b032a0968e5e34d9bfa2a21e40bd3edb7d89d8e9 Mon Sep 17 00:00:00 2001 From: guitavano Date: Fri, 19 Jun 2026 10:01:28 -0300 Subject: [PATCH 7/8] fix(cms): address app editor review blockers and important issues Correct breadcrumb navigation, avoid SchemaForm remounts on drill-down, hoist the section picker modal, share section creation logic, and flush site saves after adding global sections. Co-authored-by: Cursor --- .../components/sandbox/content/app-editor.tsx | 84 ++++++++++++++----- .../sandbox/content/content-browser.tsx | 17 ++-- .../sandbox/content/section-create.ts | 22 +++++ .../sections-editor/fields/array-field.tsx | 76 ++++------------- .../sections-editor/fields/field-props.ts | 1 + .../sections-editor/fields/object-field.tsx | 6 ++ .../sections-editor/fields/secret-field.tsx | 40 ++++++++- .../schema-form-breadcrumb.test.ts | 33 ++++++++ .../sections-editor/schema-form-breadcrumb.ts | 19 +++++ .../sections-editor/schema-form.tsx | 3 + .../sections-editor/secret-field.test.ts | 22 +++++ .../section-array-field.test.ts | 24 ++++++ .../sections-editor/section-array-field.ts | 52 +++++++++--- 13 files changed, 291 insertions(+), 108 deletions(-) create mode 100644 apps/mesh/src/web/components/sandbox/content/section-create.ts create mode 100644 apps/mesh/src/web/components/sections-editor/secret-field.test.ts diff --git a/apps/mesh/src/web/components/sandbox/content/app-editor.tsx b/apps/mesh/src/web/components/sandbox/content/app-editor.tsx index 545f694baf..687f4e92ee 100644 --- a/apps/mesh/src/web/components/sandbox/content/app-editor.tsx +++ b/apps/mesh/src/web/components/sandbox/content/app-editor.tsx @@ -1,8 +1,9 @@ import { ChevronRight, Loading01 } from "@untitledui/icons"; -import { useState } from "react"; +import { useRef, useState } from "react"; import { toast } from "sonner"; import { cn } from "@deco/ui/lib/utils.js"; import { ScrollArea } from "@deco/ui/components/scroll-area.tsx"; +import { AddSectionModal } from "@/web/components/sections-editor/add-section-modal"; import { appLabel } from "@/web/components/sections-editor/page-list"; import type { LiveMeta } from "@/web/components/sections-editor/resolve-schema"; import type { SectionCatalogEntry } from "@/web/components/sections-editor/section-catalog"; @@ -12,8 +13,9 @@ import { useSaveBlock, } from "@/web/components/sections-editor/use-save-block"; import { resolveAppEditorSchema } from "./app-editor-schema"; -import { nextUniqueBlockKey } from "./content-mutations"; +import { buildSectionBlockFromCatalogEntry } from "./section-create"; import { SchemaForm } from "@/web/components/sections-editor/schema-form"; +import { breadcrumbsForHeaderClick } from "@/web/components/sections-editor/schema-form-breadcrumb"; import { SaveStatus } from "./blog/save-status"; export function AppEditor({ @@ -50,7 +52,7 @@ export function AppEditor({ const title = titleOverride ?? (block ? appLabel(blockKey, block, meta) : blockKey); - const { save, isPending } = useDebouncedSaveBlock({ + const { save, flush, isPending } = useDebouncedSaveBlock({ orgSlug, virtualMcpId, branch, @@ -66,6 +68,8 @@ export function AppEditor({ ); const [formResetKey, setFormResetKey] = useState(0); const [breadcrumbs, setBreadcrumbs] = useState([]); + const [addSectionOpen, setAddSectionOpen] = useState(false); + const pendingAppendRef = useRef<((item: unknown) => void) | null>(null); if (prevBlockKey !== blockKey) { setPrevBlockKey(blockKey); @@ -97,24 +101,43 @@ export function AppEditor({ entry: SectionCatalogEntry, append: (item: unknown) => void, ) => { - const baseLabel = (entry.title || entry.resolveType.split("/").pop() || "") - .replace(/\.(tsx?|jsx?)$/, "") - .replace(/[^A-Za-z0-9_-]/g, ""); - const safeBase = - /^[A-Za-z]/.test(baseLabel) && baseLabel.length > 0 - ? baseLabel - : "Section"; - const newKey = nextUniqueBlockKey(decofile, safeBase); - const data: Record = { - __resolveType: entry.resolveType, - name: newKey, - }; - await saveBlock.mutateAsync({ blockKey: newKey, data }); - toast.success(`Created section "${newKey}"`); - append({ __resolveType: newKey }); + try { + const { blockKey: newKey, data } = buildSectionBlockFromCatalogEntry( + entry, + decofile, + ); + await saveBlock.mutateAsync({ blockKey: newKey, data }); + toast.success(`Created section "${newKey}"`); + append({ __resolveType: newKey }); + flush(); + } catch (err) { + const message = + err instanceof Error ? err.message : "Could not add section"; + toast.error(message); + throw err; + } }; - const breadcrumbKey = breadcrumbs.join("\0"); + const handleRequestAddSection = (context: { + append: (item: unknown) => void; + }) => { + pendingAppendRef.current = context.append; + setAddSectionOpen(true); + }; + + const handleSelectSection = async (entry: SectionCatalogEntry) => { + const append = pendingAppendRef.current; + if (!append) return; + try { + await handleAddSectionItem(entry, (item) => { + append(item); + setAddSectionOpen(false); + pendingAppendRef.current = null; + }); + } catch { + // Toast shown in handleAddSectionItem. + } + }; const headerCrumbs = breadcrumbs.length > 0 ? [title, ...breadcrumbs] : []; @@ -142,7 +165,9 @@ export function AppEditor({ if (index === 0) { handleBreadcrumbChange([]); } else { - handleBreadcrumbChange(breadcrumbs.slice(0, index - 1)); + handleBreadcrumbChange( + breadcrumbsForHeaderClick(breadcrumbs, index), + ); } setFormResetKey((key) => key + 1); }} @@ -170,7 +195,7 @@ export function AppEditor({
{hasEditableFields ? ( ) : schemaPending ? (
@@ -196,6 +222,22 @@ export function AppEditor({
+ + {previewBaseUrl && ( + { + setAddSectionOpen(open); + if (!open) pendingAppendRef.current = null; + }} + meta={meta} + decofile={decofile} + previewBaseUrl={previewBaseUrl} + onSelect={(entry) => { + void handleSelectSection(entry); + }} + /> + )}
); } diff --git a/apps/mesh/src/web/components/sandbox/content/content-browser.tsx b/apps/mesh/src/web/components/sandbox/content/content-browser.tsx index 0c88d19f55..d62a2a124d 100644 --- a/apps/mesh/src/web/components/sandbox/content/content-browser.tsx +++ b/apps/mesh/src/web/components/sandbox/content/content-browser.tsx @@ -96,6 +96,7 @@ import { nextUniqueName, nextUniquePagePath, } from "./content-mutations"; +import { buildSectionBlockFromCatalogEntry } from "./section-create"; import { PageFormDialog, type PageFormMode } from "./page-form-dialog"; import { SectionRenameDialog } from "./section-rename-dialog"; import { @@ -534,18 +535,10 @@ function ContentBrowserReady({ // ------------------ Section CRUD ------------------ const handleCreateSection = async (entry: SectionCatalogEntry) => { - const baseLabel = (entry.title || entry.resolveType.split("/").pop() || "") - .replace(/\.(tsx?|jsx?)$/, "") - .replace(/[^A-Za-z0-9_-]/g, ""); - const safeBase = - /^[A-Za-z]/.test(baseLabel) && baseLabel.length > 0 - ? baseLabel - : "Section"; - const newKey = nextUniqueBlockKey(decofile, safeBase); - const data: Record = { - __resolveType: entry.resolveType, - name: newKey, - }; + const { blockKey: newKey, data } = buildSectionBlockFromCatalogEntry( + entry, + decofile, + ); try { await saveBlock.mutateAsync({ blockKey: newKey, data }); toast.success(`Created section "${newKey}"`); diff --git a/apps/mesh/src/web/components/sandbox/content/section-create.ts b/apps/mesh/src/web/components/sandbox/content/section-create.ts new file mode 100644 index 0000000000..d826df275b --- /dev/null +++ b/apps/mesh/src/web/components/sandbox/content/section-create.ts @@ -0,0 +1,22 @@ +import type { SectionCatalogEntry } from "@/web/components/sections-editor/section-catalog"; +import { nextUniqueBlockKey } from "./content-mutations"; + +/** Build a new saved global section block from a catalog entry. */ +export function buildSectionBlockFromCatalogEntry( + entry: SectionCatalogEntry, + decofile: Record, +): { blockKey: string; data: Record } { + const baseLabel = (entry.title || entry.resolveType.split("/").pop() || "") + .replace(/\.(tsx?|jsx?)$/, "") + .replace(/[^A-Za-z0-9_-]/g, ""); + const safeBase = + /^[A-Za-z]/.test(baseLabel) && baseLabel.length > 0 ? baseLabel : "Section"; + const blockKey = nextUniqueBlockKey(decofile, safeBase); + return { + blockKey, + data: { + __resolveType: entry.resolveType, + name: blockKey, + }, + }; +} diff --git a/apps/mesh/src/web/components/sections-editor/fields/array-field.tsx b/apps/mesh/src/web/components/sections-editor/fields/array-field.tsx index dbbb6b3bc3..2ca47dbacf 100644 --- a/apps/mesh/src/web/components/sections-editor/fields/array-field.tsx +++ b/apps/mesh/src/web/components/sections-editor/fields/array-field.tsx @@ -28,16 +28,14 @@ import { DropdownMenuTrigger, } from "@deco/ui/components/dropdown-menu.tsx"; import { cn } from "@deco/ui/lib/utils.ts"; -import { AddSectionModal } from "../add-section-modal"; import { getArrayItemImageSrc, getArrayItemLabel } from "../array-item-display"; import { isEmbeddedUnionResolveType } from "../block-type-utils"; import { buildArrayDrillDownBreadcrumb, - normalizeBreadcrumbLabel, + findBreadcrumbLabelIndex, resolveArrayItemSelection, } from "../schema-form-breadcrumb"; import { isSectionArrayField } from "../section-array-field"; -import type { SectionCatalogEntry } from "../section-catalog"; import type { FieldProps } from "./field-props"; import { SchemaForm, renderField } from "../schema-form"; import { @@ -101,6 +99,11 @@ function itemEditorSchema( return itemSchema; } +function arrayFieldKeyFromPath(path: string): string { + const segments = path.split(".").filter((segment) => !/^\d+$/.test(segment)); + return segments[segments.length - 1] ?? path; +} + function remapEntryIndices(entries: ArrayEntry[]): ArrayEntry[] { return entries.map((entry, index) => ({ ...entry, index })); } @@ -123,13 +126,6 @@ function resizeArrayEntries( return [...current, ...extra]; } -function findBreadcrumbLabelIndex(path: string[], targetLabel: string): number { - const normalized = normalizeBreadcrumbLabel(targetLabel); - return path.findIndex( - (crumb) => normalizeBreadcrumbLabel(crumb) === normalized, - ); -} - function ArrayRowContent({ labelText, imageSrc, @@ -254,13 +250,12 @@ export function ArrayField({ containerResolveType, previewBaseUrl, onAddSectionItem, + onRequestAddSection, }: FieldProps) { const items = Array.isArray(value) ? value : []; const itemSchema = schema.items; - const usesSectionPicker = isSectionArrayField(schema); - const arrayFieldKey = path.includes(".") - ? path.slice(0, path.indexOf(".")) - : path; + const arrayFieldKey = arrayFieldKeyFromPath(path); + const usesSectionPicker = isSectionArrayField(schema, arrayFieldKey); const selection = resolveArrayItemSelection( label, breadcrumbPath, @@ -273,7 +268,6 @@ export function ArrayField({ createArrayEntries(items.length), ); const [activeEntryId, setActiveEntryId] = useState(null); - const [addSectionOpen, setAddSectionOpen] = useState(false); const [prevListKey, setPrevListKey] = useState(path); const [prevItemCount, setPrevItemCount] = useState(items.length); const suppressClickRef = useRef(false); @@ -345,27 +339,14 @@ export function ArrayField({ toast.error("Start the preview dev server to add sections."); return; } - setAddSectionOpen(true); - return; - } - addItem(); - }; - - const handleSelectSection = async (entry: SectionCatalogEntry) => { - const append = (item: unknown) => { - appendItem(item); - setAddSectionOpen(false); - }; - - try { - if (onAddSectionItem) { - await onAddSectionItem(entry, append); + if (onRequestAddSection) { + onRequestAddSection({ append: appendItem }); return; } - append({ __resolveType: entry.resolveType }); - } catch (err) { - toast.error(err instanceof Error ? err.message : "Could not add section"); + toast.error("Section picker is not available in this editor."); + return; } + addItem(); }; const removeItem = (index: number) => { @@ -383,20 +364,6 @@ export function ArrayField({ const next = [...items]; next[index] = val; onChange(next); - if (selectedIndex === index) { - const previousName = itemLabel(items[index], index); - const labelText = itemLabel(val, index); - const itemIndex = findBreadcrumbLabelIndex(breadcrumbPath, previousName); - if (itemIndex >= 0) { - const nextPath = [...breadcrumbPath]; - nextPath[itemIndex] = labelText; - onBreadcrumbChange?.(nextPath); - return; - } - onBreadcrumbChange?.( - buildArrayDrillDownBreadcrumb(breadcrumbPath, label, labelText), - ); - } }; const sensors = useSensors( @@ -488,6 +455,7 @@ export function ArrayField({ onSaveReferencedBlock={onSaveReferencedBlock} previewBaseUrl={previewBaseUrl} onAddSectionItem={onAddSectionItem} + onRequestAddSection={onRequestAddSection} /> ) : editorSchema ? ( renderField({ @@ -505,6 +473,7 @@ export function ArrayField({ onSaveReferencedBlock, previewBaseUrl, onAddSectionItem, + onRequestAddSection, }) ) : null} @@ -584,19 +553,6 @@ export function ArrayField({ {usesSectionPicker ? "Add section" : "Add item"} - - {usesSectionPicker && previewBaseUrl && ( - { - void handleSelectSection(entry); - }} - /> - )} ); } diff --git a/apps/mesh/src/web/components/sections-editor/fields/field-props.ts b/apps/mesh/src/web/components/sections-editor/fields/field-props.ts index 4bdbbeed5d..e5b73ba6e3 100644 --- a/apps/mesh/src/web/components/sections-editor/fields/field-props.ts +++ b/apps/mesh/src/web/components/sections-editor/fields/field-props.ts @@ -17,6 +17,7 @@ export interface FieldProps { entry: SectionCatalogEntry, append: (item: unknown) => void, ) => void | Promise; + onRequestAddSection?: (context: { append: (item: unknown) => void }) => void; onSaveReferencedBlock?: ( blockKey: string, data: Record, diff --git a/apps/mesh/src/web/components/sections-editor/fields/object-field.tsx b/apps/mesh/src/web/components/sections-editor/fields/object-field.tsx index ad2354bb0a..e317a9ed74 100644 --- a/apps/mesh/src/web/components/sections-editor/fields/object-field.tsx +++ b/apps/mesh/src/web/components/sections-editor/fields/object-field.tsx @@ -15,6 +15,9 @@ export function ObjectField({ meta, decofile, onSaveReferencedBlock, + previewBaseUrl, + onAddSectionItem, + onRequestAddSection, }: FieldProps) { const [open, setOpen] = useState(false); const objValue = @@ -71,6 +74,9 @@ export function ObjectField({ meta={meta} decofile={decofile} onSaveReferencedBlock={onSaveReferencedBlock} + previewBaseUrl={previewBaseUrl} + onAddSectionItem={onAddSectionItem} + onRequestAddSection={onRequestAddSection} /> )} diff --git a/apps/mesh/src/web/components/sections-editor/fields/secret-field.tsx b/apps/mesh/src/web/components/sections-editor/fields/secret-field.tsx index 2b7170c596..45b6df1f3e 100644 --- a/apps/mesh/src/web/components/sections-editor/fields/secret-field.tsx +++ b/apps/mesh/src/web/components/sections-editor/fields/secret-field.tsx @@ -22,9 +22,17 @@ function emptySecretBlock(): Record { }; } +function omitPendingSecretValue( + block: Record, +): Record { + const { value: _omit, ...rest } = block; + return rest; +} + /** * Deco stores API secrets as `website/loaders/secret.ts` blocks (`name` + `encrypted`). - * JSON Schema often marks these as `format: password` or `format: string` without `type`. + * The editor may emit a transient `value` when the user enters a new secret; the deco + * runtime encrypts it on save. JSON Schema often marks these as `format: password`. */ export function SecretField({ schema, @@ -33,6 +41,31 @@ export function SecretField({ label, path, }: FieldProps) { + if (typeof value === "string" || (value == null && !isSecretBlock(value))) { + const stringValue = typeof value === "string" ? value : ""; + return ( +
+ + {schema.description && ( +

{schema.description}

+ )} + onChange(e.target.value)} + className="h-10" + /> +
+ ); + } + const block = isSecretBlock(value) ? (value as Record) : emptySecretBlock(); @@ -58,13 +91,16 @@ export function SecretField({ { const next = e.target.value; if (!next) { - onChange(block); + onChange(hasStoredSecret ? omitPendingSecretValue(block) : block); return; } onChange({ ...block, value: next }); diff --git a/apps/mesh/src/web/components/sections-editor/schema-form-breadcrumb.test.ts b/apps/mesh/src/web/components/sections-editor/schema-form-breadcrumb.test.ts index 2bda028e7c..8095f6471d 100644 --- a/apps/mesh/src/web/components/sections-editor/schema-form-breadcrumb.test.ts +++ b/apps/mesh/src/web/components/sections-editor/schema-form-breadcrumb.test.ts @@ -2,15 +2,48 @@ import { describe, expect, test } from "bun:test"; import type { SchemaProperty } from "./resolve-schema"; import { breadcrumbPathForActiveField, + breadcrumbsForHeaderClick, buildArrayDrillDownBreadcrumb, fieldDisplayLabel, + findBreadcrumbLabelIndex, isArrayDrillDownField, + normalizeBreadcrumbLabel, resolveActiveFieldKey, resolveArrayItemSelection, isBreadcrumbInsideObject, } from "./schema-form-breadcrumb"; import { PAGE_MULTIVARIATE_FLAG_RESOLVE_TYPE } from "./section-types"; +describe("normalizeBreadcrumbLabel", () => { + test("normalizes composed characters to NFC", () => { + const nfd = "cafe\u0301"; + const nfc = "caf\u00e9"; + expect(normalizeBreadcrumbLabel(nfd)).toBe(normalizeBreadcrumbLabel(nfc)); + }); +}); + +describe("breadcrumbsForHeaderClick", () => { + test("maps header index to breadcrumb trail", () => { + const breadcrumbs = ["Global Sections", "Analytics"]; + expect(breadcrumbsForHeaderClick(breadcrumbs, 0)).toEqual([]); + expect(breadcrumbsForHeaderClick(breadcrumbs, 1)).toEqual([ + "Global Sections", + ]); + expect(breadcrumbsForHeaderClick(breadcrumbs, 2)).toEqual([ + "Global Sections", + "Analytics", + ]); + }); +}); + +describe("findBreadcrumbLabelIndex", () => { + test("matches labels with NFC normalization", () => { + const nfd = "cafe\u0301"; + const nfc = "caf\u00e9"; + expect(findBreadcrumbLabelIndex(["Flag", nfd], nfc)).toBe(1); + }); +}); + describe("fieldDisplayLabel", () => { test("prefers schema title", () => { expect( diff --git a/apps/mesh/src/web/components/sections-editor/schema-form-breadcrumb.ts b/apps/mesh/src/web/components/sections-editor/schema-form-breadcrumb.ts index 084d6d9bb1..79c5b7d08b 100644 --- a/apps/mesh/src/web/components/sections-editor/schema-form-breadcrumb.ts +++ b/apps/mesh/src/web/components/sections-editor/schema-form-breadcrumb.ts @@ -59,6 +59,25 @@ export function fieldDisplayLabel(key: string, schema: SchemaProperty): string { return schema.title ?? humanize(key); } +/** Map a header crumb index to the breadcrumb trail (`headerCrumbs = [title, ...breadcrumbs]`). */ +export function breadcrumbsForHeaderClick( + breadcrumbs: string[], + headerIndex: number, +): string[] { + if (headerIndex <= 0) return []; + return breadcrumbs.slice(0, headerIndex); +} + +export function findBreadcrumbLabelIndex( + path: string[], + targetLabel: string, +): number { + const normalized = normalizeBreadcrumbLabel(targetLabel); + return path.findIndex( + (crumb) => normalizeBreadcrumbLabel(crumb) === normalized, + ); +} + /** Breadcrumb drill-down applies to array fields only (not nested objects). */ export function isArrayDrillDownField( schema: SchemaProperty, diff --git a/apps/mesh/src/web/components/sections-editor/schema-form.tsx b/apps/mesh/src/web/components/sections-editor/schema-form.tsx index 89e0ffcd1e..998916fd65 100644 --- a/apps/mesh/src/web/components/sections-editor/schema-form.tsx +++ b/apps/mesh/src/web/components/sections-editor/schema-form.tsx @@ -251,6 +251,7 @@ export function SchemaForm({ onSaveReferencedBlock, previewBaseUrl, onAddSectionItem, + onRequestAddSection, }: { schema: SchemaProperty; value: unknown; @@ -266,6 +267,7 @@ export function SchemaForm({ ) => void; previewBaseUrl?: string | null; onAddSectionItem?: FieldProps["onAddSectionItem"]; + onRequestAddSection?: FieldProps["onRequestAddSection"]; }) { const properties = schema.properties; if (!properties) return null; @@ -328,6 +330,7 @@ export function SchemaForm({ containerResolveType, previewBaseUrl, onAddSectionItem, + onRequestAddSection, }); })} diff --git a/apps/mesh/src/web/components/sections-editor/secret-field.test.ts b/apps/mesh/src/web/components/sections-editor/secret-field.test.ts new file mode 100644 index 0000000000..8c0a384cd1 --- /dev/null +++ b/apps/mesh/src/web/components/sections-editor/secret-field.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test"; +import { isSecretBlock } from "./fields/secret-field"; + +describe("isSecretBlock", () => { + test("detects website secret loader blocks", () => { + expect( + isSecretBlock({ + __resolveType: "website/loaders/secret.ts", + name: "apiKey", + encrypted: "abc", + }), + ).toBe(true); + }); + + test("rejects plain strings and unrelated objects", () => { + expect(isSecretBlock("secret")).toBe(false); + expect(isSecretBlock({ __resolveType: "site/sections/Hero.tsx" })).toBe( + false, + ); + expect(isSecretBlock(null)).toBe(false); + }); +}); diff --git a/apps/mesh/src/web/components/sections-editor/section-array-field.test.ts b/apps/mesh/src/web/components/sections-editor/section-array-field.test.ts index 6888157deb..d2d778d205 100644 --- a/apps/mesh/src/web/components/sections-editor/section-array-field.test.ts +++ b/apps/mesh/src/web/components/sections-editor/section-array-field.test.ts @@ -60,4 +60,28 @@ describe("isSectionArrayField", () => { } as SchemaProperty), ).toBe(false); }); + + test("detects global field by key even when items are plain objects", () => { + expect( + isSectionArrayField( + { + type: "array", + items: { type: "object", properties: {} }, + } as SchemaProperty, + "global", + ), + ).toBe(true); + }); + + test("detects sections field by key", () => { + expect( + isSectionArrayField( + { + type: "array", + items: { type: "object" }, + } as SchemaProperty, + "sections", + ), + ).toBe(true); + }); }); diff --git a/apps/mesh/src/web/components/sections-editor/section-array-field.ts b/apps/mesh/src/web/components/sections-editor/section-array-field.ts index d4fc1c8921..2111f45441 100644 --- a/apps/mesh/src/web/components/sections-editor/section-array-field.ts +++ b/apps/mesh/src/web/components/sections-editor/section-array-field.ts @@ -2,21 +2,47 @@ import type { SchemaProperty } from "./resolve-schema"; import { isPageMultivariateSectionArrayField } from "./page-variants"; import { SECTION_MULTIVARIATE_RESOLVE_TYPE } from "./section-types"; -/** True when an array field holds page/global section entries (block-ref items). */ -export function isSectionArrayField(schema: SchemaProperty): boolean { - if (isPageMultivariateSectionArrayField(schema)) return true; +function sectionRefResolveType(resolveType: string): boolean { + return ( + resolveType.includes("/sections/") || + resolveType.includes("multivariate/section") || + resolveType === SECTION_MULTIVARIATE_RESOLVE_TYPE + ); +} - const items = schema.type === "array" ? schema.items : undefined; - if (!items || items.type !== "block-ref" || !items.anyOfRefs?.length) { - return false; +function itemsLookLikeSections(items: SchemaProperty): boolean { + if (items.type === "block-ref" && items.anyOfRefs?.length) { + return items.anyOfRefs.some((ref) => + sectionRefResolveType(ref.resolveType), + ); } - return items.anyOfRefs.some((ref) => { - const rt = ref.resolveType; - return ( - rt.includes("/sections/") || - rt.includes("multivariate/section") || - rt === SECTION_MULTIVARIATE_RESOLVE_TYPE + if (items.anyOfRefs?.length) { + return items.anyOfRefs.some((ref) => + sectionRefResolveType(ref.resolveType), ); - }); + } + + const rtProp = items.properties?.__resolveType; + if (rtProp?.type === "block-ref" && rtProp.anyOfRefs?.length) { + return rtProp.anyOfRefs.some((ref) => + sectionRefResolveType(ref.resolveType), + ); + } + + return false; +} + +/** True when an array field holds page/global section entries. */ +export function isSectionArrayField( + schema: SchemaProperty, + fieldKey?: string, +): boolean { + if (fieldKey === "global" || fieldKey === "sections") return true; + if (isPageMultivariateSectionArrayField(schema)) return true; + + const items = schema.type === "array" ? schema.items : undefined; + if (!items) return false; + + return itemsLookLikeSections(items); } From d0719220ce2f1af374ec4b2cf04903ccd49c1793 Mon Sep 17 00:00:00 2001 From: guitavano Date: Fri, 19 Jun 2026 10:08:27 -0300 Subject: [PATCH 8/8] fix(cms): use typed empty apps manifest in app-catalog test Replace invalid `{ anyOf: [] }` fixture with `{}` so LiveMeta satisfies the manifest blocks type in decocms check. Co-authored-by: Cursor --- .../mesh/src/web/components/sandbox/content/app-catalog.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mesh/src/web/components/sandbox/content/app-catalog.test.ts b/apps/mesh/src/web/components/sandbox/content/app-catalog.test.ts index 92ac5f6138..d66e010b68 100644 --- a/apps/mesh/src/web/components/sandbox/content/app-catalog.test.ts +++ b/apps/mesh/src/web/components/sandbox/content/app-catalog.test.ts @@ -141,7 +141,7 @@ describe("app-catalog", () => { it("lists installed custom/local apps without manifest or store entries", () => { const emptyMeta: LiveMeta = { - manifest: { blocks: { apps: { anyOf: [] } } }, + manifest: { blocks: { apps: {} } }, schema: {}, }; const decofile = {