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..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 @@ -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: {} } }, + 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/sandbox/content/app-editor.tsx b/apps/mesh/src/web/components/sandbox/content/app-editor.tsx index 5fe1388587..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,13 +1,21 @@ 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"; 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 { buildSectionBlockFromCatalogEntry } from "./section-create"; import { SchemaForm } from "@/web/components/sections-editor/schema-form"; -import { useDebouncedSaveBlock } from "@/web/components/sections-editor/use-save-block"; +import { breadcrumbsForHeaderClick } from "@/web/components/sections-editor/schema-form-breadcrumb"; import { SaveStatus } from "./blog/save-status"; export function AppEditor({ @@ -21,6 +29,7 @@ export function AppEditor({ title: titleOverride, excludeFields, schemaPending = false, + previewBaseUrl = null, }: { orgSlug: string; virtualMcpId: string; @@ -33,6 +42,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 : ""; @@ -42,11 +52,12 @@ export function AppEditor({ const title = titleOverride ?? (block ? appLabel(blockKey, block, meta) : blockKey); - const { save, isPending } = useDebouncedSaveBlock({ + const { save, flush, isPending } = useDebouncedSaveBlock({ orgSlug, virtualMcpId, branch, }); + const saveBlock = useSaveBlock({ orgSlug, virtualMcpId, branch }); const saveReferencedBlock = createReferencedBlockSaver((refKey, data) => save(refKey, data), ); @@ -57,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); @@ -65,8 +78,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 +93,52 @@ export function AppEditor({ }); }; + const handleBreadcrumbChange = (next: string[]) => { + setBreadcrumbs(next); + }; + + const handleAddSectionItem = async ( + entry: SectionCatalogEntry, + append: (item: unknown) => void, + ) => { + 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 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] : []; return ( @@ -99,11 +161,16 @@ export function AppEditor({ )} + + + + + Delete + + + + + ); +} export function ArrayField({ schema, @@ -23,9 +247,15 @@ export function ArrayField({ meta, decofile, onSaveReferencedBlock, + containerResolveType, + previewBaseUrl, + onAddSectionItem, + onRequestAddSection, }: FieldProps) { const items = Array.isArray(value) ? value : []; const itemSchema = schema.items; + const arrayFieldKey = arrayFieldKeyFromPath(path); + const usesSectionPicker = isSectionArrayField(schema, arrayFieldKey); const selection = resolveArrayItemSelection( label, breadcrumbPath, @@ -34,12 +264,42 @@ export function ArrayField({ ); const selectedIndex = selection?.index ?? null; + const [entries, setEntries] = useState(() => + createArrayEntries(items.length), + ); + const [activeEntryId, setActiveEntryId] = useState(null); + const [prevListKey, setPrevListKey] = useState(path); + const [prevItemCount, setPrevItemCount] = useState(items.length); + const suppressClickRef = useRef(false); + + if (prevListKey !== path) { + setPrevListKey(path); + setPrevItemCount(items.length); + setEntries(createArrayEntries(items.length)); + } else if (prevItemCount !== items.length) { + setPrevItemCount(items.length); + setEntries((current) => resizeArrayEntries(current, items.length)); + } + const itemLabel = (item: unknown, index: number) => getArrayItemLabel(item, index, itemSchema); const openItem = (index: number) => { + if (suppressClickRef.current) return; const labelText = itemLabel(items[index], index); - onBreadcrumbChange?.([...breadcrumbPath, labelText]); + onBreadcrumbChange?.( + buildArrayDrillDownBreadcrumb(breadcrumbPath, label, labelText), + ); + }; + + const appendItem = (item: unknown) => { + const next = [...items, item]; + onChange(next); + const nextIndex = next.length - 1; + const labelText = getArrayItemLabel(item, nextIndex, itemSchema); + onBreadcrumbChange?.( + buildArrayDrillDownBreadcrumb(breadcrumbPath, label, labelText), + ); }; const addItem = () => { @@ -68,14 +328,32 @@ export function ArrayField({ onChange(next); const nextIndex = next.length - 1; const labelText = getArrayItemLabel(defaultVal, nextIndex, itemSchema); - onBreadcrumbChange?.([...breadcrumbPath, labelText]); + onBreadcrumbChange?.( + buildArrayDrillDownBreadcrumb(breadcrumbPath, label, labelText), + ); + }; + + const handleAddClick = () => { + if (usesSectionPicker) { + if (!previewBaseUrl) { + toast.error("Start the preview dev server to add sections."); + return; + } + if (onRequestAddSection) { + onRequestAddSection({ append: appendItem }); + return; + } + toast.error("Section picker is not available in this editor."); + return; + } + addItem(); }; const removeItem = (index: number) => { onChange(items.filter((_, i) => i !== index)); if (selectedIndex === index) { const itemName = itemLabel(items[index], index); - const itemIndex = breadcrumbPath.indexOf(itemName); + const itemIndex = findBreadcrumbLabelIndex(breadcrumbPath, itemName); onBreadcrumbChange?.( itemIndex >= 0 ? breadcrumbPath.slice(0, itemIndex) : [], ); @@ -86,36 +364,85 @@ 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 = breadcrumbPath.indexOf(previousName); - if (itemIndex >= 0) { - const nextPath = [...breadcrumbPath]; - nextPath[itemIndex] = labelText; - onBreadcrumbChange?.(nextPath); - return; - } - onBreadcrumbChange?.([...breadcrumbPath, labelText]); - } }; + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + const entryIds = entries.map((entry) => entry.id); + + const handleDragStart = (event: DragStartEvent) => { + setActiveEntryId(String(event.active.id)); + }; + + const handleDragEnd = (event: DragEndEvent) => { + setActiveEntryId(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)), + ); + onChange(arrayMove([...items], oldIndex, newIndex)); + suppressClickRef.current = true; + requestAnimationFrame(() => { + suppressClickRef.current = false; + }); + }; + + const handleDragCancel = () => { + setActiveEntryId(null); + }; + + const activeEntry = activeEntryId + ? entries.find((entry) => entry.id === activeEntryId) + : null; + const activeItem = activeEntry != null ? items[activeEntry.index] : undefined; + const activeLabel = + activeEntry != null && activeItem !== undefined + ? itemLabel(activeItem, activeEntry.index) + : null; + const activeImage = + activeEntry != null && activeItem !== undefined + ? getArrayItemImageSrc(activeItem, itemSchema) + : undefined; + if (selectedIndex !== null && selectedIndex < items.length) { const item = items[selectedIndex]; + const editorSchema = itemEditorSchema( + item, + itemSchema, + meta, + containerResolveType, + arrayFieldKey, + ); const arrayItemPrefix = () => { const itemName = itemLabel(item, selectedIndex); - const itemIndex = breadcrumbPath.indexOf(itemName); + const trail = buildArrayDrillDownBreadcrumb( + breadcrumbPath, + label, + itemName, + ); + const itemIndex = findBreadcrumbLabelIndex(trail, itemName); if (itemIndex >= 0) { - return breadcrumbPath.slice(0, itemIndex + 1); + return trail.slice(0, itemIndex + 1); } - return [...breadcrumbPath, itemName]; + return trail; }; return (
- {itemSchema?.type === "object" && itemSchema.properties ? ( + {editorSchema?.type === "object" && editorSchema.properties ? ( updateItem(selectedIndex, val)} basePath={`${path}.${selectedIndex}`} @@ -126,14 +453,17 @@ export function ArrayField({ meta={meta} decofile={decofile} onSaveReferencedBlock={onSaveReferencedBlock} + previewBaseUrl={previewBaseUrl} + onAddSectionItem={onAddSectionItem} + onRequestAddSection={onRequestAddSection} /> - ) : itemSchema ? ( + ) : editorSchema ? ( renderField({ - schema: itemSchema, + schema: editorSchema, value: item, onChange: (val) => updateItem(selectedIndex, val), path: `${path}.${selectedIndex}`, - label: itemSchema.title ?? `Item ${selectedIndex + 1}`, + label: editorSchema.title ?? `Item ${selectedIndex + 1}`, breadcrumbPath: selection?.innerPath ?? [], onBreadcrumbChange: (nextPath) => { onBreadcrumbChange?.([...arrayItemPrefix(), ...nextPath]); @@ -141,6 +471,9 @@ export function ArrayField({ meta, decofile, onSaveReferencedBlock, + previewBaseUrl, + onAddSectionItem, + onRequestAddSection, }) ) : null}
@@ -166,68 +499,59 @@ export function ArrayField({ {items.length > 0 && ( -
- {items.map((item, i) => { - const labelText = itemLabel(item, i); - const imageSrc = getArrayItemImageSrc(item, itemSchema); - return ( -
- - - - - - - - removeItem(i)} - > - - Delete - - - + ); + })}
- ); - })} + + + + {activeLabel ? ( +
+ +
+ ) : null} +
+
)} ); 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..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 @@ -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,13 @@ 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; + 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 new file mode 100644 index 0000000000..45b6df1f3e --- /dev/null +++ b/apps/mesh/src/web/components/sections-editor/fields/secret-field.tsx @@ -0,0 +1,117 @@ +import { Input } from "@deco/ui/components/input.tsx"; +import { Label } from "@deco/ui/components/label.tsx"; +import type { FieldProps } from "./field-props"; + +const DEFAULT_SECRET_RESOLVE_TYPE = "website/loaders/secret.ts"; + +export function isSecretBlock(value: unknown): boolean { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const resolveType = (value as Record).__resolveType; + return ( + typeof resolveType === "string" && + (resolveType.endsWith("/secret.ts") || + resolveType.includes("loaders/secret")) + ); +} + +function emptySecretBlock(): Record { + return { + __resolveType: DEFAULT_SECRET_RESOLVE_TYPE, + name: "", + encrypted: "", + }; +} + +function omitPendingSecretValue( + block: Record, +): Record { + const { value: _omit, ...rest } = block; + return rest; +} + +/** + * Deco stores API secrets as `website/loaders/secret.ts` blocks (`name` + `encrypted`). + * 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, + value, + onChange, + 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(); + const name = typeof block.name === "string" ? block.name : ""; + const hasStoredSecret = + typeof block.encrypted === "string" && block.encrypted; + + return ( +
+ + {schema.description && ( +

{schema.description}

+ )} + onChange({ ...block, name: e.target.value })} + className="h-10" + /> + { + const next = e.target.value; + if (!next) { + onChange(hasStoredSecret ? omitPendingSecretValue(block) : block); + return; + } + onChange({ ...block, value: next }); + }} + className="h-10" + /> + {hasStoredSecret && ( +

+ A secret value is stored. +

+ )} +
+ ); +} 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..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 @@ -163,6 +163,102 @@ 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("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: { @@ -230,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 d6cdb4fdda..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,17 +60,48 @@ 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 // `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 +143,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 +188,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 ); } @@ -234,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() ?? ""; @@ -374,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 = ( @@ -386,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) { @@ -417,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 { @@ -452,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 { @@ -518,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 { @@ -545,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", @@ -560,13 +665,22 @@ 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") { rawItems = resolveRef(rawItems.$ref); } itemsSchema = buildProperty(rawItems, depth + 1); + if ( + typeof rawItems.title === "string" && + rawItems.title.includes("{{") + ) { + itemsSchema.titleBy = rawItems.title; + } } } 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..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 @@ -1,14 +1,49 @@ 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( @@ -60,6 +95,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 +141,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 +203,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..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 @@ -9,14 +9,83 @@ 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); } +/** 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): 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 +106,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 +145,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 +197,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 +220,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 15ae03d072..998916fd65 100644 --- a/apps/mesh/src/web/components/sections-editor/schema-form.tsx +++ b/apps/mesh/src/web/components/sections-editor/schema-form.tsx @@ -9,6 +9,7 @@ import { ObjectField } from "./fields/object-field"; import { AnyOfField } from "./fields/any-of-field"; import { FileField } from "./fields/file-field"; import { ImageField } from "./fields/image-field"; +import { isSecretBlock, SecretField } from "./fields/secret-field"; import { isMultivariateArrayWrapper, isPageMultivariateSectionArrayField, @@ -16,6 +17,7 @@ import { wrapMultivariateArrayValue, } from "./page-variants"; import { + breadcrumbPathForActiveField, fieldDisplayLabel, resolveActiveFieldKey, } from "./schema-form-breadcrumb"; @@ -166,13 +168,31 @@ export function renderField(props: FieldProps) { return ; } + // Deco API secrets are stored as loader blocks, not plain strings. + if ( + isSecretBlock(value) || + schema.format === "password" || + (value == null && schema.format === "password") + ) { + return ; + } + // If value is null/undefined, try to produce a typed default from schema const effectiveValue = value === null || value === undefined ? defaultForType(schema.type, schema.default) : value; - if (effectiveValue === null || effectiveValue === undefined) return null; + if (effectiveValue === null || effectiveValue === undefined) { + if (isSecretBlock(value)) { + return ; + } + return null; + } + + if (isSecretBlock(effectiveValue)) { + return ; + } const effectiveProps = { ...props, value: effectiveValue }; @@ -229,6 +249,9 @@ export function SchemaForm({ meta, decofile, onSaveReferencedBlock, + previewBaseUrl, + onAddSectionItem, + onRequestAddSection, }: { schema: SchemaProperty; value: unknown; @@ -242,6 +265,9 @@ export function SchemaForm({ blockKey: string, data: Record, ) => void; + previewBaseUrl?: string | null; + onAddSectionItem?: FieldProps["onAddSectionItem"]; + onRequestAddSection?: FieldProps["onRequestAddSection"]; }) { const properties = schema.properties; if (!properties) return null; @@ -267,12 +293,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) => { @@ -287,11 +322,15 @@ export function SchemaForm({ onChange: (val) => updateField(key, val), path: fieldPath, label, - breadcrumbPath, + breadcrumbPath: fieldBreadcrumbPath, onBreadcrumbChange, meta, decofile, onSaveReferencedBlock, + 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 new file mode 100644 index 0000000000..d2d778d205 --- /dev/null +++ b/apps/mesh/src/web/components/sections-editor/section-array-field.test.ts @@ -0,0 +1,87 @@ +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); + }); + + 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 new file mode 100644 index 0000000000..2111f45441 --- /dev/null +++ b/apps/mesh/src/web/components/sections-editor/section-array-field.ts @@ -0,0 +1,48 @@ +import type { SchemaProperty } from "./resolve-schema"; +import { isPageMultivariateSectionArrayField } from "./page-variants"; +import { SECTION_MULTIVARIATE_RESOLVE_TYPE } from "./section-types"; + +function sectionRefResolveType(resolveType: string): boolean { + return ( + resolveType.includes("/sections/") || + resolveType.includes("multivariate/section") || + resolveType === SECTION_MULTIVARIATE_RESOLVE_TYPE + ); +} + +function itemsLookLikeSections(items: SchemaProperty): boolean { + if (items.type === "block-ref" && items.anyOfRefs?.length) { + return items.anyOfRefs.some((ref) => + sectionRefResolveType(ref.resolveType), + ); + } + + 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); +}