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/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)} + /> + ); + })} +
+
+
)}