From 142922914311e2decf0f485ef0c78e9f836fb27f Mon Sep 17 00:00:00 2001 From: guitavano Date: Thu, 18 Jun 2026 17:37:32 -0300 Subject: [PATCH 1/2] 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 14703892997e2c57e36c68e1f07f8825a4abafab Mon Sep 17 00:00:00 2001 From: guitavano Date: Thu, 18 Jun 2026 18:17:41 -0300 Subject: [PATCH 2/2] 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)} + /> + ); + })} +
+
+
)}