Skip to content
29 changes: 29 additions & 0 deletions apps/mesh/src/web/components/sandbox/content/app-catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
]);
});
});
124 changes: 57 additions & 67 deletions apps/mesh/src/web/components/sandbox/content/app-catalog.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -126,6 +139,37 @@ function catalogEntryFromManifestApp(
};
}

function catalogEntryFromInstalledBlock(
blockKey: string,
block: Record<string, unknown>,
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.
Expand All @@ -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<string, unknown>,
meta,
);
if (!entry || byId.has(entry.id)) continue;
byId.set(entry.id, entry);
}

return [...byId.values()].sort(compareAppCatalogEntries);
Expand All @@ -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<string, unknown>,
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<string, unknown>;
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;
}
108 changes: 97 additions & 11 deletions apps/mesh/src/web/components/sandbox/content/app-editor.tsx
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -21,6 +29,7 @@ export function AppEditor({
title: titleOverride,
excludeFields,
schemaPending = false,
previewBaseUrl = null,
}: {
orgSlug: string;
virtualMcpId: string;
Expand All @@ -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 : "";
Expand All @@ -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),
);
Expand All @@ -57,6 +68,8 @@ export function AppEditor({
);
const [formResetKey, setFormResetKey] = useState(0);
const [breadcrumbs, setBreadcrumbs] = useState<string[]>([]);
const [addSectionOpen, setAddSectionOpen] = useState(false);
const pendingAppendRef = useRef<((item: unknown) => void) | null>(null);

if (prevBlockKey !== blockKey) {
setPrevBlockKey(blockKey);
Expand All @@ -65,8 +78,11 @@ export function AppEditor({
setBreadcrumbs([]);
}

const savedValue = block ?? {};
const effectiveValue = (formValue ?? savedValue) as Record<string, unknown>;
const savedValue = (block ?? {}) as Record<string, unknown>;
const effectiveValue = {
...savedValue,
...(formValue ?? {}),
} as Record<string, unknown>;

const handleChange = (next: unknown) => {
const nextRecord = next as Record<string, unknown>;
Expand All @@ -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 (
Expand All @@ -99,11 +161,16 @@ export function AppEditor({
)}
<button
type="button"
onClick={() =>
setBreadcrumbs(
index === 0 ? [] : breadcrumbs.slice(0, index),
)
}
onClick={() => {
if (index === 0) {
handleBreadcrumbChange([]);
} else {
handleBreadcrumbChange(
breadcrumbsForHeaderClick(breadcrumbs, index),
);
}
setFormResetKey((key) => key + 1);
}}
title={crumb}
className={cn(
"min-w-0 truncate rounded-md px-1 py-0.5 text-left transition-colors",
Expand Down Expand Up @@ -134,10 +201,13 @@ export function AppEditor({
onChange={handleChange}
basePath=""
breadcrumbPath={breadcrumbs}
onBreadcrumbChange={setBreadcrumbs}
onBreadcrumbChange={handleBreadcrumbChange}
decofile={decofile}
meta={meta}
onSaveReferencedBlock={saveReferencedBlock}
previewBaseUrl={previewBaseUrl}
onAddSectionItem={handleAddSectionItem}
onRequestAddSection={handleRequestAddSection}
/>
) : schemaPending ? (
<div className="flex flex-col items-center gap-2 py-6 text-center text-xs text-muted-foreground">
Expand All @@ -152,6 +222,22 @@ export function AppEditor({
</div>
</div>
</ScrollArea>

{previewBaseUrl && (
<AddSectionModal
open={addSectionOpen}
onOpenChange={(open) => {
setAddSectionOpen(open);
if (!open) pendingAppendRef.current = null;
}}
meta={meta}
decofile={decofile}
previewBaseUrl={previewBaseUrl}
onSelect={(entry) => {
void handleSelectSection(entry);
}}
/>
)}
</div>
);
}
Loading
Loading