From 7c663c07f546d2ed16cb0ea0ae284e0963df3528 Mon Sep 17 00:00:00 2001 From: olliethedev <5933733+olliethedev@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:42:14 +0000 Subject: [PATCH 1/2] feat(ui-builder): complete v3 plugin sweep --- docs/content/docs/plugins/ui-builder.mdx | 42 +- packages/stack/build.config.ts | 1 + packages/stack/package.json | 13 + packages/stack/registry/btst-ui-builder.json | 90 +-- packages/stack/scripts/build-registry.ts | 34 + .../client-plugin-ssr-loaders.test.ts | 13 +- .../__tests__/client-sweep.test.tsx | 335 ++++++++++ .../client/components/page-renderer.tsx | 29 +- .../pages/page-builder-page.internal.tsx | 197 ++++-- .../components/pages/page-builder-page.tsx | 35 +- .../pages/page-list-page.internal.tsx | 253 +++++-- .../components/pages/page-list-page.tsx | 28 +- .../components/shared/default-error.tsx | 27 +- .../client/components/shared/pagination.tsx | 4 +- .../plugins/ui-builder/client/hooks/index.tsx | 3 + .../client/hooks/ui-builder-hooks.tsx | 619 +++--------------- .../client/hooks/ui-builder-resource.ts | 10 + .../src/plugins/ui-builder/client/index.ts | 4 + .../ui-builder/client/localization/index.ts | 99 ++- .../plugins/ui-builder/client/overrides.ts | 4 + .../src/plugins/ui-builder/client/plugin.tsx | 58 +- .../src/plugins/ui-builder/query-keys.ts | 216 ++++++ 22 files changed, 1293 insertions(+), 821 deletions(-) create mode 100644 packages/stack/src/plugins/ui-builder/__tests__/client-sweep.test.tsx create mode 100644 packages/stack/src/plugins/ui-builder/client/hooks/ui-builder-resource.ts create mode 100644 packages/stack/src/plugins/ui-builder/query-keys.ts diff --git a/docs/content/docs/plugins/ui-builder.mdx b/docs/content/docs/plugins/ui-builder.mdx index 108fd42b..6181e6e8 100644 --- a/docs/content/docs/plugins/ui-builder.mdx +++ b/docs/content/docs/plugins/ui-builder.mdx @@ -207,6 +207,20 @@ The UI Builder plugin provides these admin routes: | `/ui-builder/new` | Create a new page with the visual builder | | `/ui-builder/:id/edit` | Edit an existing page | +When `StackProvider` has an `auth` provider, the built-in routes and actions use +these permission checks: + +| UI | Permission | +|----|------------| +| Page list route | `ui-builder:page` / `read` | +| New-page route and create buttons | `ui-builder:page` / `create` | +| Edit route and action | `ui-builder:page` / `update` with `{ id }` | +| Delete action | `ui-builder:page` / `delete` with `{ id }` | + +Without an auth provider, permission gates remain disabled for backward +compatibility. These client checks control visibility and navigation only; +continue enforcing authorization in the CMS backend. + Admin routes are automatically set to `noindex` for SEO. Don't include them in your public sitemap. @@ -680,6 +694,17 @@ Access UI Builder data with React Query hooks: | `useCreateUIBuilderPage()` | Create mutation | React Query mutation | | `useUpdateUIBuilderPage()` | Update mutation | React Query mutation | | `useDeleteUIBuilderPage()` | Delete mutation | React Query mutation | +| `useUIBuilderPageForm(config)` | Create/edit lifecycle | `{ submit, isSubmitting, fieldErrors, error }` | + +`useUIBuilderPageForm` uses the shared resource-form lifecycle: it selects the +create or update mutation, awaits cache invalidation, sends messages through the +`notify` provider, redirects through the router adapter, and exposes structured +server validation errors through `fieldErrors`. + +For SSR loaders or custom cache prefetching, import +`createUIBuilderQueryKeys` from +`@btst/stack/plugins/ui-builder/query-keys`. Its keys intentionally match the +underlying CMS content-item keys. ### Usage Examples @@ -886,6 +911,8 @@ Creates the client plugin with routes and SSR loaders. | `Link` | `ComponentType` | No | Link component | | `refresh` | `() => void` | No | Refresh function | | `componentRegistry` | `ComponentRegistry` | No | Custom component registry | +| `functionRegistry` | `FunctionRegistry` | No | Functions available to UI Builder event bindings | +| `localization` | `UIBuilderLocalizationOverrides` | No | Nested overrides for built-in page, editor, renderer, and notification copy | | `showAttribution` | `boolean` | No | Show BTST attribution | #### defaultComponentRegistry @@ -1105,6 +1132,13 @@ The UI Builder plugin UI layer is distributed as a [shadcn registry](https://ui. The registry installs only the view layer. Hooks and data-fetching continue to come from `@btst/stack/plugins/ui-builder/client/hooks`. + +The visual editor itself is installed from the upstream UI Builder shadcn +registry. BTST's registry references that external item and does not embed or +fork `components/ui/ui-builder` or `lib/ui-builder`, so the editor source can be +synced from upstream independently of the BTST adapter pages. + + ```bash @@ -1132,17 +1166,17 @@ After installing, wire your custom components into the plugin via the `pageCompo ```tsx title="lib/stack-client.tsx" import { uiBuilderClientPlugin } from "@btst/stack/plugins/ui-builder/client" // Import your ejected (and customized) page components -import { PageListPageComponent } from "@/components/btst/ui-builder/client/components/pages/page-list-page" -import { EditPagePageComponent } from "@/components/btst/ui-builder/client/components/pages/edit-page-page" +import { PageListPage } from "@/components/btst/ui-builder/client/components/pages/page-list-page" +import { PageBuilderPage } from "@/components/btst/ui-builder/client/components/pages/page-builder-page" uiBuilderClientPlugin({ apiBaseURL: "...", apiBasePath: "/api/data", queryClient, pageComponents: { - pageList: PageListPageComponent, // replaces the page list page + pageList: PageListPage, // replaces the page list page // Param routes receive the route context ({ params }) as props - editPage: ({ params }) => , + editPage: ({ params }) => , // newPage — omit to keep built-in default }, }) diff --git a/packages/stack/build.config.ts b/packages/stack/build.config.ts index ccb10bce..d54b5d24 100644 --- a/packages/stack/build.config.ts +++ b/packages/stack/build.config.ts @@ -115,6 +115,7 @@ export default defineBuildConfig({ "./src/plugins/ui-builder/client/index.ts", "./src/plugins/ui-builder/client/components/index.ts", "./src/plugins/ui-builder/client/hooks/index.tsx", + "./src/plugins/ui-builder/query-keys.ts", // kanban plugin entries "./src/plugins/kanban/api/index.ts", "./src/plugins/kanban/client/index.ts", diff --git a/packages/stack/package.json b/packages/stack/package.json index 7ac43e56..fc1259a8 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -361,6 +361,16 @@ "default": "./dist/plugins/ui-builder/client/hooks/index.cjs" } }, + "./plugins/ui-builder/query-keys": { + "import": { + "types": "./dist/plugins/ui-builder/query-keys.d.ts", + "default": "./dist/plugins/ui-builder/query-keys.mjs" + }, + "require": { + "types": "./dist/plugins/ui-builder/query-keys.d.cts", + "default": "./dist/plugins/ui-builder/query-keys.cjs" + } + }, "./plugins/ui-builder/css": "./dist/plugins/ui-builder/style.css", "./plugins/open-api/api": { "import": { @@ -726,6 +736,9 @@ "plugins/ui-builder/client/hooks": [ "./dist/plugins/ui-builder/client/hooks/index.d.ts" ], + "plugins/ui-builder/query-keys": [ + "./dist/plugins/ui-builder/query-keys.d.ts" + ], "plugins/open-api/api": [ "./dist/plugins/open-api/api/index.d.ts" ], diff --git a/packages/stack/registry/btst-ui-builder.json b/packages/stack/registry/btst-ui-builder.json index 03bd7c5f..43883db4 100644 --- a/packages/stack/registry/btst-ui-builder.json +++ b/packages/stack/registry/btst-ui-builder.json @@ -52,37 +52,37 @@ { "path": "btst/ui-builder/client/components/page-renderer.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport type { ComponentType, ReactNode } from \"react\";\nimport { Suspense } from \"react\";\nimport { ErrorBoundary } from \"react-error-boundary\";\nimport LayerRenderer from \"@/components/ui/ui-builder/layer-renderer\";\nimport type {\n\tComponentRegistry,\n\tFunctionRegistry,\n\tPropValue,\n} from \"@/components/ui/ui-builder/types\";\nimport { useSuspenseUIBuilderPageBySlug } from \"@btst/stack/plugins/ui-builder/client/hooks\";\nimport { defaultComponentRegistry } from \"../registry\";\nimport { uiBuilderLocalization } from \"../localization\";\n\n/**\n * Default loading component for PageRenderer\n */\nfunction DefaultLoadingComponent(): ReactNode {\n\treturn (\n\t\t
\n\t\t\t
\n\t\t\t\t{uiBuilderLocalization.pageRenderer.loading}\n\t\t\t
\n\t\t
\n\t);\n}\n\n/**\n * Default error component for PageRenderer\n */\nfunction DefaultErrorComponent({ error }: { error: unknown }): ReactNode {\n\treturn (\n\t\t
\n\t\t\t
\n\t\t\t\t{uiBuilderLocalization.pageRenderer.error}\n\t\t\t
\n\t\t\t
\n\t\t\t\t{error instanceof Error ? error.message : String(error)}\n\t\t\t
\n\t\t
\n\t);\n}\n\n/**\n * Default not found component for PageRenderer\n */\nfunction DefaultNotFoundComponent(): ReactNode {\n\treturn (\n\t\t
\n\t\t\t
\n\t\t\t\t{uiBuilderLocalization.pageRenderer.notFound}\n\t\t\t
\n\t\t
\n\t);\n}\n\nexport interface PageRendererProps {\n\t/** URL slug of the UI Builder page to render */\n\tslug: string;\n\t/** Component registry to use for rendering (defaults to defaultComponentRegistry) */\n\tcomponentRegistry?: ComponentRegistry;\n\t/** Runtime variable values to override defaults */\n\tvariableValues?: Record;\n\t/** Function registry for resolving function-type variable bindings */\n\tfunctionRegistry?: FunctionRegistry;\n\t/** Custom loading component */\n\tLoadingComponent?: ComponentType;\n\t/** Custom error component */\n\tErrorComponent?: ComponentType<{ error: unknown }>;\n\t/** Custom not found component */\n\tNotFoundComponent?: ComponentType;\n\t/** Additional className for the container */\n\tclassName?: string;\n}\n\n/**\n * PageRenderer - Renders a UI Builder page by slug\n *\n * A convenient component for rendering UI Builder pages on public-facing routes.\n * Handles loading states, error boundaries, and 404 cases automatically.\n *\n * @example\n * ```tsx\n * // Basic usage with default registry\n * import { PageRenderer } from \"@btst/stack/plugins/ui-builder/client\"\n *\n * export default function Page({ params }: { params: { slug: string } }) {\n * return \n * }\n * ```\n *\n * @example\n * ```tsx\n * // With custom component registry\n * import { PageRenderer, createComponentRegistry } from \"@btst/stack/plugins/ui-builder/client\"\n * import { defaultComponentRegistry } from \"@btst/stack/plugins/ui-builder/client\"\n *\n * const customRegistry = createComponentRegistry({\n * ...defaultComponentRegistry,\n * MyCustomComponent: { component: MyComponent, schema: mySchema },\n * })\n *\n * export default function Page({ params }: { params: { slug: string } }) {\n * return (\n * \n * )\n * }\n * ```\n *\n * @example\n * ```tsx\n * // With variable values for dynamic content\n * import { PageRenderer } from \"@btst/stack/plugins/ui-builder/client\"\n *\n * export default function Page({ params }: { params: { slug: string } }) {\n * const user = useCurrentUser()\n *\n * return (\n * \n * )\n * }\n * ```\n */\nexport function PageRenderer({\n\tslug,\n\tcomponentRegistry = defaultComponentRegistry,\n\tvariableValues,\n\tfunctionRegistry,\n\tLoadingComponent = DefaultLoadingComponent,\n\tErrorComponent = DefaultErrorComponent,\n\tNotFoundComponent = DefaultNotFoundComponent,\n\tclassName,\n}: PageRendererProps): ReactNode {\n\treturn (\n\t\t }\n\t\t>\n\t\t\t}>\n\t\t\t\t\n\t\t\t\n\t\t\n\t);\n}\n\n/**\n * Internal component that fetches and renders a UI Builder page using Suspense\n * Uses useSuspenseQuery which throws promises for React Suspense to catch\n */\nfunction SuspensePageRendererContent({\n\tslug,\n\tcomponentRegistry = defaultComponentRegistry,\n\tvariableValues,\n\tfunctionRegistry,\n\tNotFoundComponent = DefaultNotFoundComponent,\n\tclassName,\n}: Omit) {\n\tconst { page, layers, variables } = useSuspenseUIBuilderPageBySlug(slug);\n\n\tif (!page || layers.length === 0) {\n\t\treturn ;\n\t}\n\n\t// Get the first page layer (root)\n\tconst rootLayer = layers[0];\n\n\tif (!rootLayer) {\n\t\treturn ;\n\t}\n\n\treturn (\n\t\t\n\t);\n}\n\n/**\n * SuspensePageRenderer - Suspense-based PageRenderer for SSR\n *\n * Similar to PageRenderer but designed for use with React Suspense streaming.\n * Use this when you want the server to wait for the page data before sending HTML.\n *\n * This component uses `useSuspenseQuery` internally, which throws a promise\n * while data is loading. This allows React Suspense boundaries to properly\n * catch the loading state and enables SSR streaming.\n *\n * @example\n * ```tsx\n * import { Suspense } from \"react\"\n * import { SuspensePageRenderer } from \"@btst/stack/plugins/ui-builder/client\"\n *\n * export default function Page({ params }: { params: { slug: string } }) {\n * return (\n * }>\n * \n * \n * )\n * }\n * ```\n */\nexport function SuspensePageRenderer({\n\tslug,\n\tcomponentRegistry = defaultComponentRegistry,\n\tvariableValues,\n\tfunctionRegistry,\n\tNotFoundComponent = DefaultNotFoundComponent,\n\tclassName,\n}: Omit): ReactNode {\n\treturn (\n\t\t\n\t);\n}\n", + "content": "\"use client\";\n\nimport type { ComponentType, ReactNode } from \"react\";\nimport { Suspense } from \"react\";\nimport { ErrorBoundary } from \"react-error-boundary\";\nimport { usePluginOverrides, useTranslate } from \"@btst/stack/context\";\nimport LayerRenderer from \"@/components/ui/ui-builder/layer-renderer\";\nimport type {\n\tComponentRegistry,\n\tFunctionRegistry,\n\tPropValue,\n} from \"@/components/ui/ui-builder/types\";\nimport { useSuspenseUIBuilderPageBySlug } from \"@btst/stack/plugins/ui-builder/client/hooks\";\nimport { defaultComponentRegistry } from \"../registry\";\nimport { uiBuilderLocalization } from \"../localization\";\nimport type { UIBuilderPluginOverrides } from \"../overrides\";\n\n/**\n * Default loading component for PageRenderer\n */\nfunction DefaultLoadingComponent(): ReactNode {\n\tconst t = useTranslate();\n\tconst { localization } =\n\t\tusePluginOverrides(\"ui-builder\");\n\treturn (\n\t\t
\n\t\t\t
\n\t\t\t\t{localization?.pageRenderer?.loading ??\n\t\t\t\t\tt(\n\t\t\t\t\t\t\"uiBuilder.pageRenderer.loading\",\n\t\t\t\t\t\tuiBuilderLocalization.pageRenderer.loading,\n\t\t\t\t\t)}\n\t\t\t
\n\t\t
\n\t);\n}\n\n/**\n * Default error component for PageRenderer\n */\nfunction DefaultErrorComponent({ error }: { error: unknown }): ReactNode {\n\tconst t = useTranslate();\n\tconst { localization } =\n\t\tusePluginOverrides(\"ui-builder\");\n\treturn (\n\t\t
\n\t\t\t
\n\t\t\t\t{localization?.pageRenderer?.error ??\n\t\t\t\t\tt(\n\t\t\t\t\t\t\"uiBuilder.pageRenderer.error\",\n\t\t\t\t\t\tuiBuilderLocalization.pageRenderer.error,\n\t\t\t\t\t)}\n\t\t\t
\n\t\t\t
\n\t\t\t\t{error instanceof Error ? error.message : String(error)}\n\t\t\t
\n\t\t
\n\t);\n}\n\n/**\n * Default not found component for PageRenderer\n */\nfunction DefaultNotFoundComponent(): ReactNode {\n\tconst t = useTranslate();\n\tconst { localization } =\n\t\tusePluginOverrides(\"ui-builder\");\n\treturn (\n\t\t
\n\t\t\t
\n\t\t\t\t{localization?.pageRenderer?.notFound ??\n\t\t\t\t\tt(\n\t\t\t\t\t\t\"uiBuilder.pageRenderer.notFound\",\n\t\t\t\t\t\tuiBuilderLocalization.pageRenderer.notFound,\n\t\t\t\t\t)}\n\t\t\t
\n\t\t
\n\t);\n}\n\nexport interface PageRendererProps {\n\t/** URL slug of the UI Builder page to render */\n\tslug: string;\n\t/** Component registry to use for rendering (defaults to defaultComponentRegistry) */\n\tcomponentRegistry?: ComponentRegistry;\n\t/** Runtime variable values to override defaults */\n\tvariableValues?: Record;\n\t/** Function registry for resolving function-type variable bindings */\n\tfunctionRegistry?: FunctionRegistry;\n\t/** Custom loading component */\n\tLoadingComponent?: ComponentType;\n\t/** Custom error component */\n\tErrorComponent?: ComponentType<{ error: unknown }>;\n\t/** Custom not found component */\n\tNotFoundComponent?: ComponentType;\n\t/** Additional className for the container */\n\tclassName?: string;\n}\n\n/**\n * PageRenderer - Renders a UI Builder page by slug\n *\n * A convenient component for rendering UI Builder pages on public-facing routes.\n * Handles loading states, error boundaries, and 404 cases automatically.\n *\n * @example\n * ```tsx\n * // Basic usage with default registry\n * import { PageRenderer } from \"@btst/stack/plugins/ui-builder/client\"\n *\n * export default function Page({ params }: { params: { slug: string } }) {\n * return \n * }\n * ```\n *\n * @example\n * ```tsx\n * // With custom component registry\n * import { PageRenderer, createComponentRegistry } from \"@btst/stack/plugins/ui-builder/client\"\n * import { defaultComponentRegistry } from \"@btst/stack/plugins/ui-builder/client\"\n *\n * const customRegistry = createComponentRegistry({\n * ...defaultComponentRegistry,\n * MyCustomComponent: { component: MyComponent, schema: mySchema },\n * })\n *\n * export default function Page({ params }: { params: { slug: string } }) {\n * return (\n * \n * )\n * }\n * ```\n *\n * @example\n * ```tsx\n * // With variable values for dynamic content\n * import { PageRenderer } from \"@btst/stack/plugins/ui-builder/client\"\n *\n * export default function Page({ params }: { params: { slug: string } }) {\n * const user = useCurrentUser()\n *\n * return (\n * \n * )\n * }\n * ```\n */\nexport function PageRenderer({\n\tslug,\n\tcomponentRegistry = defaultComponentRegistry,\n\tvariableValues,\n\tfunctionRegistry,\n\tLoadingComponent = DefaultLoadingComponent,\n\tErrorComponent = DefaultErrorComponent,\n\tNotFoundComponent = DefaultNotFoundComponent,\n\tclassName,\n}: PageRendererProps): ReactNode {\n\treturn (\n\t\t }\n\t\t>\n\t\t\t}>\n\t\t\t\t\n\t\t\t\n\t\t\n\t);\n}\n\n/**\n * Internal component that fetches and renders a UI Builder page using Suspense\n * Uses useSuspenseQuery which throws promises for React Suspense to catch\n */\nfunction SuspensePageRendererContent({\n\tslug,\n\tcomponentRegistry = defaultComponentRegistry,\n\tvariableValues,\n\tfunctionRegistry,\n\tNotFoundComponent = DefaultNotFoundComponent,\n\tclassName,\n}: Omit) {\n\tconst { page, layers, variables } = useSuspenseUIBuilderPageBySlug(slug);\n\n\tif (!page || layers.length === 0) {\n\t\treturn ;\n\t}\n\n\t// Get the first page layer (root)\n\tconst rootLayer = layers[0];\n\n\tif (!rootLayer) {\n\t\treturn ;\n\t}\n\n\treturn (\n\t\t\n\t);\n}\n\n/**\n * SuspensePageRenderer - Suspense-based PageRenderer for SSR\n *\n * Similar to PageRenderer but designed for use with React Suspense streaming.\n * Use this when you want the server to wait for the page data before sending HTML.\n *\n * This component uses `useSuspenseQuery` internally, which throws a promise\n * while data is loading. This allows React Suspense boundaries to properly\n * catch the loading state and enables SSR streaming.\n *\n * @example\n * ```tsx\n * import { Suspense } from \"react\"\n * import { SuspensePageRenderer } from \"@btst/stack/plugins/ui-builder/client\"\n *\n * export default function Page({ params }: { params: { slug: string } }) {\n * return (\n * }>\n * \n * \n * )\n * }\n * ```\n */\nexport function SuspensePageRenderer({\n\tslug,\n\tcomponentRegistry = defaultComponentRegistry,\n\tvariableValues,\n\tfunctionRegistry,\n\tNotFoundComponent = DefaultNotFoundComponent,\n\tclassName,\n}: Omit): ReactNode {\n\treturn (\n\t\t\n\t);\n}\n", "target": "src/components/btst/ui-builder/client/components/page-renderer.tsx" }, { "path": "btst/ui-builder/client/components/pages/page-builder-page.internal.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { useState, useCallback } from \"react\";\nimport { usePluginOverrides, useBasePath } from \"@btst/stack/context\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport {\n\tSelect,\n\tSelectContent,\n\tSelectItem,\n\tSelectTrigger,\n\tSelectValue,\n} from \"@/components/ui/select\";\nimport {\n\tPopover,\n\tPopoverContent,\n\tPopoverTrigger,\n} from \"@/components/ui/popover\";\nimport { Label } from \"@/components/ui/label\";\nimport { ArrowLeft, Save, Settings2 } from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport UIBuilder from \"@/components/ui/ui-builder\";\nimport type {\n\tComponentLayer,\n\tComponentRegistry,\n\tVariable,\n} from \"@/components/ui/ui-builder/types\";\n\nimport { useLayerStore } from \"@/lib/ui-builder/store/layer-store\";\nimport { useRegisterPageAIContext } from \"@btst/stack/plugins/ai-chat/client/context\";\nimport {\n\tuseSuspenseUIBuilderPage,\n\tuseCreateUIBuilderPage,\n\tuseUpdateUIBuilderPage,\n} from \"@btst/stack/plugins/ui-builder/client/hooks\";\nimport type { UIBuilderPluginOverrides } from \"../../overrides\";\nimport { uiBuilderLocalization } from \"../../localization\";\nimport { defaultComponentRegistry } from \"../../registry\";\nimport type { SerializedUIBuilderPage } from \"../../../types\";\n\nexport interface PageBuilderPageProps {\n\tid?: string;\n}\n\n/**\n * Generate a concise AI-readable description of the available components\n * in the component registry, including their prop names.\n */\nfunction buildRegistryDescription(registry: ComponentRegistry): string {\n\tconst lines: string[] = [];\n\tfor (const [name, entry] of Object.entries(registry) as [\n\t\tstring,\n\t\t{ schema?: unknown },\n\t][]) {\n\t\tlet propsLine = \"\";\n\t\ttry {\n\t\t\tconst shape = (entry.schema as any)?.shape as\n\t\t\t\t| Record\n\t\t\t\t| undefined;\n\t\t\tif (shape) {\n\t\t\t\tconst fields = Object.keys(shape).join(\", \");\n\t\t\t\tpropsLine = ` — props: ${fields}`;\n\t\t\t}\n\t\t} catch {\n\t\t\t// ignore schema introspection errors\n\t\t}\n\t\tlines.push(`- ${name}${propsLine}`);\n\t}\n\treturn lines.join(\"\\n\");\n}\n\n/**\n * Build the full page description string for the AI context.\n * Stays within the 8,000-character pageContext limit.\n */\nfunction buildPageDescription(\n\tid: string | undefined,\n\tslug: string,\n\tlayers: ComponentLayer[],\n\tregistry: ComponentRegistry,\n): string {\n\tconst header = id\n\t\t? `UI Builder — editing page (slug: \"${slug}\")`\n\t\t: \"UI Builder — creating new page\";\n\n\tconst layersJson = JSON.stringify(layers, null, 2);\n\n\tconst registryDesc = buildRegistryDescription(registry);\n\n\tconst layerFormat = `Each layer: { id: string, type: string, name: string, props: Record, children?: ComponentLayer[] | string }`;\n\n\tconst full = [\n\t\theader,\n\t\t\"\",\n\t\t`## Current Layers (${layers.length})`,\n\t\tlayersJson,\n\t\t\"\",\n\t\t`## Available Component Types`,\n\t\tregistryDesc,\n\t\t\"\",\n\t\t`## ComponentLayer format`,\n\t\tlayerFormat,\n\t].join(\"\\n\");\n\n\t// Trim to fit the 16,000-char server-side limit, cutting the layers JSON if needed\n\tif (full.length <= 16000) return full;\n\n\t// Re-build with truncated layers JSON\n\tconst overhead =\n\t\t[\n\t\t\theader,\n\t\t\t\"\",\n\t\t\t`## Current Layers (${layers.length})`,\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\t`## Available Component Types`,\n\t\t\tregistryDesc,\n\t\t\t\"\",\n\t\t\t`## ComponentLayer format`,\n\t\t\tlayerFormat,\n\t\t].join(\"\\n\").length + 30; // 30-char buffer for \"...(truncated)\"\n\n\tconst budget = Math.max(0, 16000 - overhead);\n\tconst truncatedLayers =\n\t\tlayersJson.length > budget\n\t\t\t? layersJson.slice(0, budget) + \"\\n...(truncated)\"\n\t\t\t: layersJson;\n\n\treturn [\n\t\theader,\n\t\t\"\",\n\t\t`## Current Layers (${layers.length})`,\n\t\ttruncatedLayers,\n\t\t\"\",\n\t\t`## Available Component Types`,\n\t\tregistryDesc,\n\t\t\"\",\n\t\t`## ComponentLayer format`,\n\t\tlayerFormat,\n\t].join(\"\\n\");\n}\n\n/**\n * Slugify a string for URL-friendly slugs\n */\nfunction slugify(str: string): string {\n\treturn str\n\t\t.toLowerCase()\n\t\t.trim()\n\t\t.replace(/[^\\w\\s-]/g, \"\")\n\t\t.replace(/[\\s_-]+/g, \"-\")\n\t\t.replace(/^-+|-+$/g, \"\");\n}\n\n/**\n * Entry point component that conditionally renders the appropriate\n * sub-component based on whether we're creating or editing a page.\n * This avoids conditional hook calls which violate React's Rules of Hooks.\n */\nexport function PageBuilderPage({ id }: PageBuilderPageProps) {\n\tif (id) {\n\t\treturn ;\n\t}\n\treturn ;\n}\n\n/**\n * Component for editing an existing page.\n * Uses useSuspenseUIBuilderPage unconditionally since id is always defined.\n */\nfunction EditPageBuilderPage({ id }: { id: string }) {\n\tconst { page: existingPage } = useSuspenseUIBuilderPage(id);\n\treturn ;\n}\n\n/**\n * Component for creating a new page.\n * No data fetching needed.\n */\nfunction CreatePageBuilderPage() {\n\treturn ;\n}\n\ninterface PageBuilderPageContentProps {\n\tid?: string;\n\texistingPage?: SerializedUIBuilderPage | null;\n}\n\n/**\n * Parse JSON strings safely\n */\nfunction parseLayers(layersJson?: string): ComponentLayer[] {\n\tif (!layersJson) return [];\n\ttry {\n\t\treturn JSON.parse(layersJson) as ComponentLayer[];\n\t} catch {\n\t\treturn [];\n\t}\n}\n\nfunction parseVariables(variablesJson?: string): Variable[] {\n\tif (!variablesJson) return [];\n\ttry {\n\t\treturn JSON.parse(variablesJson) as Variable[];\n\t} catch {\n\t\treturn [];\n\t}\n}\n\nfunction PageBuilderPageContent({\n\tid,\n\texistingPage,\n}: PageBuilderPageContentProps) {\n\tconst {\n\t\tnavigate,\n\t\tLink,\n\t\tcomponentRegistry: customRegistry,\n\t\tfunctionRegistry,\n\t} = usePluginOverrides(\"ui-builder\");\n\tconst basePath = useBasePath();\n\n\tconst createMutation = useCreateUIBuilderPage();\n\tconst updateMutation = useUpdateUIBuilderPage();\n\n\tconst loc = uiBuilderLocalization;\n\tconst LinkComponent = Link || \"a\";\n\tconst componentRegistry = customRegistry || defaultComponentRegistry;\n\n\t// Parse existing page data\n\tconst existingLayers = parseLayers(existingPage?.parsedData?.layers);\n\tconst existingVariables = parseVariables(existingPage?.parsedData?.variables);\n\n\t// Form state\n\tconst [slug, setSlug] = useState(existingPage?.slug || \"\");\n\tconst [status, setStatus] = useState<\"published\" | \"draft\" | \"archived\">(\n\t\texistingPage?.parsedData?.status || \"draft\",\n\t);\n\tconst [layers, setLayers] = useState(existingLayers);\n\tconst [variables, setVariables] = useState(existingVariables);\n\n\t// Auto-generate slug from first page name\n\tconst [autoSlug, setAutoSlug] = useState(!id);\n\n\t// Register AI context so the chat can update the page layout\n\tuseRegisterPageAIContext({\n\t\trouteName: id ? \"ui-builder-edit-page\" : \"ui-builder-new-page\",\n\t\tpageDescription: buildPageDescription(id, slug, layers, componentRegistry),\n\t\tsuggestions: [\n\t\t\t\"Add a hero section\",\n\t\t\t\"Add a 3-column feature grid\",\n\t\t\t\"Make the layout full-width\",\n\t\t\t\"Add a card with a title, description, and button\",\n\t\t\t\"Replace the layout with a centered single-column design\",\n\t\t],\n\t\tclientTools: {\n\t\t\tupdatePageLayers: async ({ layers: newLayers }) => {\n\t\t\t\t// Drive the UIBuilder's Zustand store directly so the editor\n\t\t\t\t// and layers panel update immediately. The store's onChange\n\t\t\t\t// callback will propagate back to the parent's `layers` state.\n\t\t\t\tconst store = useLayerStore.getState();\n\t\t\t\tstore.initialize(\n\t\t\t\t\tnewLayers,\n\t\t\t\t\tstore.selectedPageId || newLayers[0]?.id,\n\t\t\t\t\tundefined,\n\t\t\t\t\tstore.variables,\n\t\t\t\t);\n\t\t\t\treturn {\n\t\t\t\t\tsuccess: true,\n\t\t\t\t\tmessage: `Applied ${newLayers.length} layer(s) to the page`,\n\t\t\t\t};\n\t\t\t},\n\t\t},\n\t});\n\n\t// Handle layers change from UIBuilder\n\tconst handleLayersChange = useCallback(\n\t\t(newLayers: ComponentLayer[]) => {\n\t\t\tsetLayers(newLayers);\n\n\t\t\t// Auto-generate slug from first page name if creating new page\n\t\t\tif (autoSlug && newLayers.length > 0 && newLayers[0]?.name) {\n\t\t\t\tsetSlug(slugify(newLayers[0].name));\n\t\t\t}\n\t\t},\n\t\t[autoSlug],\n\t);\n\n\t// Handle variables change from UIBuilder\n\tconst handleVariablesChange = useCallback((newVariables: Variable[]) => {\n\t\tsetVariables(newVariables);\n\t}, []);\n\n\tconst handleSave = async () => {\n\t\tif (!slug.trim()) {\n\t\t\ttoast.error(loc.pageBuilder.validation.slugRequired);\n\t\t\treturn;\n\t\t}\n\n\t\tif (!/^[a-z0-9-]+$/.test(slug)) {\n\t\t\ttoast.error(loc.pageBuilder.validation.slugFormat);\n\t\t\treturn;\n\t\t}\n\n\t\tif (layers.length === 0) {\n\t\t\ttoast.error(loc.pageBuilder.validation.layersRequired);\n\t\t\treturn;\n\t\t}\n\n\t\ttry {\n\t\t\tif (id) {\n\t\t\t\tawait updateMutation.mutateAsync({\n\t\t\t\t\tid,\n\t\t\t\t\tdata: {\n\t\t\t\t\t\tlayers,\n\t\t\t\t\t\tvariables,\n\t\t\t\t\t\tstatus,\n\t\t\t\t\t},\n\t\t\t\t});\n\t\t\t\ttoast.success(loc.pageBuilder.saved);\n\t\t\t} else {\n\t\t\t\tconst newPage = await createMutation.mutateAsync({\n\t\t\t\t\tslug,\n\t\t\t\t\tlayers,\n\t\t\t\t\tvariables,\n\t\t\t\t\tstatus,\n\t\t\t\t});\n\t\t\t\ttoast.success(loc.pageBuilder.saved);\n\t\t\t\tnavigate?.(`${basePath}/ui-builder/${newPage.id}/edit`);\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tconst message = error instanceof Error ? error.message : \"Unknown error\";\n\t\t\tif (message.includes(\"slug already exists\")) {\n\t\t\t\ttoast.error(\"A page with this slug already exists\");\n\t\t\t} else {\n\t\t\t\ttoast.error(loc.pageBuilder.saveError);\n\t\t\t}\n\t\t}\n\t};\n\n\tconst isSaving = createMutation.isPending || updateMutation.isPending;\n\n\t// Shared form fields - used in both mobile popover and desktop inline\n\tconst pageSettingsFields = (isMobile: boolean) => (\n\t\t\n\t\t\t
\n\t\t\t\t{isMobile && (\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t\t {\n\t\t\t\t\t\tsetSlug(e.target.value);\n\t\t\t\t\t\tsetAutoSlug(false);\n\t\t\t\t\t}}\n\t\t\t\t\tplaceholder={loc.pageBuilder.slugPlaceholder}\n\t\t\t\t\tclassName={\n\t\t\t\t\t\tisMobile ? \"h-9 font-mono text-sm\" : \"h-8 w-48 font-mono text-sm\"\n\t\t\t\t\t}\n\t\t\t\t\tdisabled={!!id}\n\t\t\t\t/>\n\t\t\t
\n\n\t\t\t
\n\t\t\t\t{isMobile && (\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t\t setStatus(v as typeof status)}\n\t\t\t\t>\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{loc.pageBuilder.statusOptions.draft}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{loc.pageBuilder.statusOptions.published}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{loc.pageBuilder.statusOptions.archived}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t
\n\t\t\n\t);\n\n\t// NavBar left children - back button, mobile popover, desktop inline fields\n\tconst navLeftChildren = (\n\t\t
\n\t\t\t\n\n\t\t\t{/* Mobile: Popover with settings */}\n\t\t\t
\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t

Page Settings

\n\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\tConfigure page slug and status\n\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t{pageSettingsFields(true)}\n\t\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t
\n\t\t\t
\n\n\t\t\t{/* Desktop: Inline fields */}\n\t\t\t
\n\t\t\t\t{pageSettingsFields(false)}\n\t\t\t
\n\t\t
\n\t);\n\n\t// NavBar right children - save button (icon only on mobile, with text on desktop)\n\tconst navRightChildren = (\n\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t{isSaving\n\t\t\t\t\t? loc.pageBuilder.saving\n\t\t\t\t\t: id\n\t\t\t\t\t\t? loc.pageBuilder.save\n\t\t\t\t\t\t: loc.pageBuilder.save}\n\t\t\t\n\t\t\n\t);\n\n\treturn (\n\t\t
\n\t\t\t 0 ? existingLayers : undefined}\n\t\t\t\tonChange={handleLayersChange}\n\t\t\t\tinitialVariables={\n\t\t\t\t\texistingVariables.length > 0 ? existingVariables : undefined\n\t\t\t\t}\n\t\t\t\tonVariablesChange={handleVariablesChange}\n\t\t\t\tcomponentRegistry={componentRegistry}\n\t\t\t\tfunctionRegistry={functionRegistry}\n\t\t\t\tpersistLayerStore={false}\n\t\t\t\tallowVariableEditing={true}\n\t\t\t\tallowPagesCreation={false}\n\t\t\t\tallowPagesDeletion={false}\n\t\t\t\tshowExport={false}\n\t\t\t\tnavLeftChildren={navLeftChildren}\n\t\t\t\tnavRightChildren={navRightChildren}\n\t\t\t/>\n\t\t
\n\t);\n}\n", + "content": "\"use client\";\n\nimport { useState, useCallback } from \"react\";\nimport {\n\tuseBasePath,\n\tuseNotify,\n\tusePluginOverrides,\n\tuseTranslate,\n} from \"@btst/stack/context\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport {\n\tSelect,\n\tSelectContent,\n\tSelectItem,\n\tSelectTrigger,\n\tSelectValue,\n} from \"@/components/ui/select\";\nimport {\n\tPopover,\n\tPopoverContent,\n\tPopoverTrigger,\n} from \"@/components/ui/popover\";\nimport { Label } from \"@/components/ui/label\";\nimport { ArrowLeft, Save, Settings2 } from \"lucide-react\";\nimport UIBuilder from \"@/components/ui/ui-builder\";\nimport type {\n\tComponentLayer,\n\tComponentRegistry,\n\tVariable,\n} from \"@/components/ui/ui-builder/types\";\n\nimport { useLayerStore } from \"@/lib/ui-builder/store/layer-store\";\nimport { useRegisterPageAIContext } from \"@btst/stack/plugins/ai-chat/client/context\";\nimport {\n\tuseSuspenseUIBuilderPage,\n\tuseUIBuilderPageForm,\n} from \"@btst/stack/plugins/ui-builder/client/hooks\";\nimport type { UIBuilderPluginOverrides } from \"../../overrides\";\nimport { uiBuilderLocalization } from \"../../localization\";\nimport { defaultComponentRegistry } from \"../../registry\";\nimport type { SerializedUIBuilderPage } from \"../../../types\";\n\nexport interface PageBuilderPageProps {\n\tid?: string;\n}\n\n/**\n * Generate a concise AI-readable description of the available components\n * in the component registry, including their prop names.\n */\nfunction buildRegistryDescription(registry: ComponentRegistry): string {\n\tconst lines: string[] = [];\n\tfor (const [name, entry] of Object.entries(registry) as [\n\t\tstring,\n\t\t{ schema?: unknown },\n\t][]) {\n\t\tlet propsLine = \"\";\n\t\ttry {\n\t\t\tconst shape = (entry.schema as any)?.shape as\n\t\t\t\t| Record\n\t\t\t\t| undefined;\n\t\t\tif (shape) {\n\t\t\t\tconst fields = Object.keys(shape).join(\", \");\n\t\t\t\tpropsLine = ` — props: ${fields}`;\n\t\t\t}\n\t\t} catch {\n\t\t\t// ignore schema introspection errors\n\t\t}\n\t\tlines.push(`- ${name}${propsLine}`);\n\t}\n\treturn lines.join(\"\\n\");\n}\n\n/**\n * Build the full page description string for the AI context.\n * Stays within the 8,000-character pageContext limit.\n */\nfunction buildPageDescription(\n\tid: string | undefined,\n\tslug: string,\n\tlayers: ComponentLayer[],\n\tregistry: ComponentRegistry,\n): string {\n\tconst header = id\n\t\t? `UI Builder — editing page (slug: \"${slug}\")`\n\t\t: \"UI Builder — creating new page\";\n\n\tconst layersJson = JSON.stringify(layers, null, 2);\n\n\tconst registryDesc = buildRegistryDescription(registry);\n\n\tconst layerFormat = `Each layer: { id: string, type: string, name: string, props: Record, children?: ComponentLayer[] | string }`;\n\n\tconst full = [\n\t\theader,\n\t\t\"\",\n\t\t`## Current Layers (${layers.length})`,\n\t\tlayersJson,\n\t\t\"\",\n\t\t`## Available Component Types`,\n\t\tregistryDesc,\n\t\t\"\",\n\t\t`## ComponentLayer format`,\n\t\tlayerFormat,\n\t].join(\"\\n\");\n\n\t// Trim to fit the 16,000-char server-side limit, cutting the layers JSON if needed\n\tif (full.length <= 16000) return full;\n\n\t// Re-build with truncated layers JSON\n\tconst overhead =\n\t\t[\n\t\t\theader,\n\t\t\t\"\",\n\t\t\t`## Current Layers (${layers.length})`,\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\t`## Available Component Types`,\n\t\t\tregistryDesc,\n\t\t\t\"\",\n\t\t\t`## ComponentLayer format`,\n\t\t\tlayerFormat,\n\t\t].join(\"\\n\").length + 30; // 30-char buffer for \"...(truncated)\"\n\n\tconst budget = Math.max(0, 16000 - overhead);\n\tconst truncatedLayers =\n\t\tlayersJson.length > budget\n\t\t\t? layersJson.slice(0, budget) + \"\\n...(truncated)\"\n\t\t\t: layersJson;\n\n\treturn [\n\t\theader,\n\t\t\"\",\n\t\t`## Current Layers (${layers.length})`,\n\t\ttruncatedLayers,\n\t\t\"\",\n\t\t`## Available Component Types`,\n\t\tregistryDesc,\n\t\t\"\",\n\t\t`## ComponentLayer format`,\n\t\tlayerFormat,\n\t].join(\"\\n\");\n}\n\n/**\n * Slugify a string for URL-friendly slugs\n */\nfunction slugify(str: string): string {\n\treturn str\n\t\t.toLowerCase()\n\t\t.trim()\n\t\t.replace(/[^\\w\\s-]/g, \"\")\n\t\t.replace(/[\\s_-]+/g, \"-\")\n\t\t.replace(/^-+|-+$/g, \"\");\n}\n\n/**\n * Entry point component that conditionally renders the appropriate\n * sub-component based on whether we're creating or editing a page.\n * This avoids conditional hook calls which violate React's Rules of Hooks.\n */\nexport function PageBuilderPage({ id }: PageBuilderPageProps) {\n\tif (id) {\n\t\treturn ;\n\t}\n\treturn ;\n}\n\n/**\n * Component for editing an existing page.\n * Uses useSuspenseUIBuilderPage unconditionally since id is always defined.\n */\nfunction EditPageBuilderPage({ id }: { id: string }) {\n\tconst { page: existingPage } = useSuspenseUIBuilderPage(id);\n\treturn ;\n}\n\n/**\n * Component for creating a new page.\n * No data fetching needed.\n */\nfunction CreatePageBuilderPage() {\n\treturn ;\n}\n\ninterface PageBuilderPageContentProps {\n\tid?: string;\n\texistingPage?: SerializedUIBuilderPage | null;\n}\n\ninterface PageBuilderFormValues {\n\tslug: string;\n\tlayers: ComponentLayer[];\n\tvariables: Variable[];\n\tstatus: \"published\" | \"draft\" | \"archived\";\n}\n\n/**\n * Parse JSON strings safely\n */\nfunction parseLayers(layersJson?: string): ComponentLayer[] {\n\tif (!layersJson) return [];\n\ttry {\n\t\treturn JSON.parse(layersJson) as ComponentLayer[];\n\t} catch {\n\t\treturn [];\n\t}\n}\n\nfunction parseVariables(variablesJson?: string): Variable[] {\n\tif (!variablesJson) return [];\n\ttry {\n\t\treturn JSON.parse(variablesJson) as Variable[];\n\t} catch {\n\t\treturn [];\n\t}\n}\n\nfunction PageBuilderPageContent({\n\tid,\n\texistingPage,\n}: PageBuilderPageContentProps) {\n\tconst t = useTranslate();\n\tconst notify = useNotify();\n\tconst {\n\t\tLink,\n\t\tcomponentRegistry: customRegistry,\n\t\tfunctionRegistry,\n\t\tlocalization,\n\t} = usePluginOverrides(\"ui-builder\");\n\tconst basePath = useBasePath();\n\tconst LinkComponent = Link || \"a\";\n\tconst componentRegistry = customRegistry || defaultComponentRegistry;\n\tconst localized = (\n\t\toverride: string | undefined,\n\t\tkey: string,\n\t\tfallback: string,\n\t) => override ?? t(key, fallback);\n\tconst savedMessage = localized(\n\t\tlocalization?.pageBuilder?.saved,\n\t\t\"uiBuilder.pageBuilder.saved\",\n\t\tuiBuilderLocalization.pageBuilder.saved,\n\t);\n\tconst saveErrorMessage = localized(\n\t\tlocalization?.pageBuilder?.saveError,\n\t\t\"uiBuilder.pageBuilder.saveError\",\n\t\tuiBuilderLocalization.pageBuilder.saveError,\n\t);\n\tconst duplicateSlugMessage = localized(\n\t\tlocalization?.pageBuilder?.duplicateSlug,\n\t\t\"uiBuilder.pageBuilder.duplicateSlug\",\n\t\tuiBuilderLocalization.pageBuilder.duplicateSlug,\n\t);\n\tconst loc = {\n\t\tpageBuilder: {\n\t\t\tslugLabel: localized(\n\t\t\t\tlocalization?.pageBuilder?.slugLabel,\n\t\t\t\t\"uiBuilder.pageBuilder.slugLabel\",\n\t\t\t\tuiBuilderLocalization.pageBuilder.slugLabel,\n\t\t\t),\n\t\t\tslugPlaceholder: localized(\n\t\t\t\tlocalization?.pageBuilder?.slugPlaceholder,\n\t\t\t\t\"uiBuilder.pageBuilder.slugPlaceholder\",\n\t\t\t\tuiBuilderLocalization.pageBuilder.slugPlaceholder,\n\t\t\t),\n\t\t\tstatusLabel: localized(\n\t\t\t\tlocalization?.pageBuilder?.statusLabel,\n\t\t\t\t\"uiBuilder.pageBuilder.statusLabel\",\n\t\t\t\tuiBuilderLocalization.pageBuilder.statusLabel,\n\t\t\t),\n\t\t\tsettingsTitle: localized(\n\t\t\t\tlocalization?.pageBuilder?.settingsTitle,\n\t\t\t\t\"uiBuilder.pageBuilder.settingsTitle\",\n\t\t\t\tuiBuilderLocalization.pageBuilder.settingsTitle,\n\t\t\t),\n\t\t\tsettingsDescription: localized(\n\t\t\t\tlocalization?.pageBuilder?.settingsDescription,\n\t\t\t\t\"uiBuilder.pageBuilder.settingsDescription\",\n\t\t\t\tuiBuilderLocalization.pageBuilder.settingsDescription,\n\t\t\t),\n\t\t\tsave: localized(\n\t\t\t\tlocalization?.pageBuilder?.save,\n\t\t\t\t\"uiBuilder.pageBuilder.save\",\n\t\t\t\tuiBuilderLocalization.pageBuilder.save,\n\t\t\t),\n\t\t\tsaving: localized(\n\t\t\t\tlocalization?.pageBuilder?.saving,\n\t\t\t\t\"uiBuilder.pageBuilder.saving\",\n\t\t\t\tuiBuilderLocalization.pageBuilder.saving,\n\t\t\t),\n\t\t\tstatusOptions: {\n\t\t\t\tdraft: localized(\n\t\t\t\t\tlocalization?.pageBuilder?.statusOptions?.draft,\n\t\t\t\t\t\"uiBuilder.pageBuilder.statusOptions.draft\",\n\t\t\t\t\tuiBuilderLocalization.pageBuilder.statusOptions.draft,\n\t\t\t\t),\n\t\t\t\tpublished: localized(\n\t\t\t\t\tlocalization?.pageBuilder?.statusOptions?.published,\n\t\t\t\t\t\"uiBuilder.pageBuilder.statusOptions.published\",\n\t\t\t\t\tuiBuilderLocalization.pageBuilder.statusOptions.published,\n\t\t\t\t),\n\t\t\t\tarchived: localized(\n\t\t\t\t\tlocalization?.pageBuilder?.statusOptions?.archived,\n\t\t\t\t\t\"uiBuilder.pageBuilder.statusOptions.archived\",\n\t\t\t\t\tuiBuilderLocalization.pageBuilder.statusOptions.archived,\n\t\t\t\t),\n\t\t\t},\n\t\t},\n\t};\n\n\t// Parse existing page data\n\tconst existingLayers = parseLayers(existingPage?.parsedData?.layers);\n\tconst existingVariables = parseVariables(existingPage?.parsedData?.variables);\n\n\t// Form state\n\tconst [slug, setSlug] = useState(existingPage?.slug || \"\");\n\tconst [status, setStatus] = useState<\"published\" | \"draft\" | \"archived\">(\n\t\texistingPage?.parsedData?.status || \"draft\",\n\t);\n\tconst [layers, setLayers] = useState(existingLayers);\n\tconst [variables, setVariables] = useState(existingVariables);\n\tconst pageForm = useUIBuilderPageForm({\n\t\taction: id ? \"edit\" : \"create\",\n\t\tid,\n\t\trecord: existingPage,\n\t\ttoCreateVars: (values) => values,\n\t\ttoUpdateVars: (values) => ({\n\t\t\tid: id!,\n\t\t\tdata: {\n\t\t\t\tlayers: values.layers,\n\t\t\t\tvariables: values.variables,\n\t\t\t\tstatus: values.status,\n\t\t\t},\n\t\t}),\n\t\tsuccessMessage: savedMessage,\n\t\terrorMessage: (error) =>\n\t\t\terror.message.includes(\"slug already exists\")\n\t\t\t\t? duplicateSlugMessage\n\t\t\t\t: saveErrorMessage,\n\t\tredirect: (page, action) =>\n\t\t\taction === \"create\" ? `${basePath}/ui-builder/${page.id}/edit` : false,\n\t});\n\n\t// Auto-generate slug from first page name\n\tconst [autoSlug, setAutoSlug] = useState(!id);\n\n\t// Register AI context so the chat can update the page layout\n\tuseRegisterPageAIContext({\n\t\trouteName: id ? \"ui-builder-edit-page\" : \"ui-builder-new-page\",\n\t\tpageDescription: buildPageDescription(id, slug, layers, componentRegistry),\n\t\tsuggestions: [\n\t\t\t\"Add a hero section\",\n\t\t\t\"Add a 3-column feature grid\",\n\t\t\t\"Make the layout full-width\",\n\t\t\t\"Add a card with a title, description, and button\",\n\t\t\t\"Replace the layout with a centered single-column design\",\n\t\t],\n\t\tclientTools: {\n\t\t\tupdatePageLayers: async ({ layers: newLayers }) => {\n\t\t\t\t// Drive the UIBuilder's Zustand store directly so the editor\n\t\t\t\t// and layers panel update immediately. The store's onChange\n\t\t\t\t// callback will propagate back to the parent's `layers` state.\n\t\t\t\tconst store = useLayerStore.getState();\n\t\t\t\tstore.initialize(\n\t\t\t\t\tnewLayers,\n\t\t\t\t\tstore.selectedPageId || newLayers[0]?.id,\n\t\t\t\t\tundefined,\n\t\t\t\t\tstore.variables,\n\t\t\t\t);\n\t\t\t\treturn {\n\t\t\t\t\tsuccess: true,\n\t\t\t\t\tmessage: `Applied ${newLayers.length} layer(s) to the page`,\n\t\t\t\t};\n\t\t\t},\n\t\t},\n\t});\n\n\t// Handle layers change from UIBuilder\n\tconst handleLayersChange = useCallback(\n\t\t(newLayers: ComponentLayer[]) => {\n\t\t\tsetLayers(newLayers);\n\n\t\t\t// Auto-generate slug from first page name if creating new page\n\t\t\tif (autoSlug && newLayers.length > 0 && newLayers[0]?.name) {\n\t\t\t\tsetSlug(slugify(newLayers[0].name));\n\t\t\t}\n\t\t},\n\t\t[autoSlug],\n\t);\n\n\t// Handle variables change from UIBuilder\n\tconst handleVariablesChange = useCallback((newVariables: Variable[]) => {\n\t\tsetVariables(newVariables);\n\t}, []);\n\n\tconst handleSave = async () => {\n\t\tif (!slug.trim()) {\n\t\t\tnotify.error(\n\t\t\t\tlocalized(\n\t\t\t\t\tlocalization?.pageBuilder?.validation?.slugRequired,\n\t\t\t\t\t\"uiBuilder.pageBuilder.validation.slugRequired\",\n\t\t\t\t\tuiBuilderLocalization.pageBuilder.validation.slugRequired,\n\t\t\t\t),\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\n\t\tif (!/^[a-z0-9-]+$/.test(slug)) {\n\t\t\tnotify.error(\n\t\t\t\tlocalized(\n\t\t\t\t\tlocalization?.pageBuilder?.validation?.slugFormat,\n\t\t\t\t\t\"uiBuilder.pageBuilder.validation.slugFormat\",\n\t\t\t\t\tuiBuilderLocalization.pageBuilder.validation.slugFormat,\n\t\t\t\t),\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\n\t\tif (layers.length === 0) {\n\t\t\tnotify.error(\n\t\t\t\tlocalized(\n\t\t\t\t\tlocalization?.pageBuilder?.validation?.layersRequired,\n\t\t\t\t\t\"uiBuilder.pageBuilder.validation.layersRequired\",\n\t\t\t\t\tuiBuilderLocalization.pageBuilder.validation.layersRequired,\n\t\t\t\t),\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\n\t\tawait pageForm.submit({ slug, layers, variables, status });\n\t};\n\n\tconst isSaving = pageForm.isSubmitting;\n\tconst slugFieldError = pageForm.fieldErrors.slug;\n\tconst slugErrorMessage = Array.isArray(slugFieldError)\n\t\t? slugFieldError[0]\n\t\t: slugFieldError;\n\n\t// Shared form fields - used in both mobile popover and desktop inline\n\tconst pageSettingsFields = (isMobile: boolean) => (\n\t\t\n\t\t\t
\n\t\t\t\t{isMobile && (\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t\t {\n\t\t\t\t\t\tsetSlug(e.target.value);\n\t\t\t\t\t\tsetAutoSlug(false);\n\t\t\t\t\t\tpageForm.clearErrors();\n\t\t\t\t\t}}\n\t\t\t\t\tplaceholder={loc.pageBuilder.slugPlaceholder}\n\t\t\t\t\tclassName={\n\t\t\t\t\t\tisMobile ? \"h-9 font-mono text-sm\" : \"h-8 w-48 font-mono text-sm\"\n\t\t\t\t\t}\n\t\t\t\t\tdisabled={!!id}\n\t\t\t\t\taria-invalid={!!slugErrorMessage}\n\t\t\t\t/>\n\t\t\t\t{slugErrorMessage && (\n\t\t\t\t\t

\n\t\t\t\t\t\t{slugErrorMessage}\n\t\t\t\t\t

\n\t\t\t\t)}\n\t\t\t
\n\n\t\t\t
\n\t\t\t\t{isMobile && (\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t\t setStatus(v as typeof status)}\n\t\t\t\t>\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{loc.pageBuilder.statusOptions.draft}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{loc.pageBuilder.statusOptions.published}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{loc.pageBuilder.statusOptions.archived}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t
\n\t\t\n\t);\n\n\t// NavBar left children - back button, mobile popover, desktop inline fields\n\tconst navLeftChildren = (\n\t\t
\n\t\t\t\n\n\t\t\t{/* Mobile: Popover with settings */}\n\t\t\t
\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t{loc.pageBuilder.settingsTitle}\n\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t{loc.pageBuilder.settingsDescription}\n\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t{pageSettingsFields(true)}\n\t\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t
\n\t\t\t
\n\n\t\t\t{/* Desktop: Inline fields */}\n\t\t\t
\n\t\t\t\t{pageSettingsFields(false)}\n\t\t\t
\n\t\t
\n\t);\n\n\t// NavBar right children - save button (icon only on mobile, with text on desktop)\n\tconst navRightChildren = (\n\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t{isSaving\n\t\t\t\t\t? loc.pageBuilder.saving\n\t\t\t\t\t: id\n\t\t\t\t\t\t? loc.pageBuilder.save\n\t\t\t\t\t\t: loc.pageBuilder.save}\n\t\t\t\n\t\t\n\t);\n\n\treturn (\n\t\t
\n\t\t\t 0 ? existingLayers : undefined}\n\t\t\t\tonChange={handleLayersChange}\n\t\t\t\tinitialVariables={\n\t\t\t\t\texistingVariables.length > 0 ? existingVariables : undefined\n\t\t\t\t}\n\t\t\t\tonVariablesChange={handleVariablesChange}\n\t\t\t\tcomponentRegistry={componentRegistry}\n\t\t\t\tfunctionRegistry={functionRegistry}\n\t\t\t\tpersistLayerStore={false}\n\t\t\t\tallowVariableEditing={true}\n\t\t\t\tallowPagesCreation={false}\n\t\t\t\tallowPagesDeletion={false}\n\t\t\t\tshowExport={false}\n\t\t\t\tnavLeftChildren={navLeftChildren}\n\t\t\t\tnavRightChildren={navRightChildren}\n\t\t\t/>\n\t\t
\n\t);\n}\n", "target": "src/components/btst/ui-builder/client/components/pages/page-builder-page.internal.tsx" }, { "path": "btst/ui-builder/client/components/pages/page-builder-page.tsx", "type": "registry:page", - "content": "\"use client\";\n\nimport { lazy, Suspense } from \"react\";\nimport { PageBuilderSkeleton } from \"../loading/page-builder-skeleton\";\nimport { ErrorBoundary } from \"react-error-boundary\";\nimport { DefaultError } from \"../shared/default-error\";\n\nconst PageBuilderPageInternal = lazy(() =>\n\timport(\"./page-builder-page.internal\").then((m) => ({\n\t\tdefault: m.PageBuilderPage,\n\t})),\n);\n\nexport interface PageBuilderPageProps {\n\tid?: string;\n}\n\nexport function PageBuilderPage({ id }: PageBuilderPageProps) {\n\treturn (\n\t\t\n\t\t\t}>\n\t\t\t\t\n\t\t\t\n\t\t\n\t);\n}\n", + "content": "\"use client\";\n\nimport { lazy } from \"react\";\nimport { ComposedRoute } from \"@btst/stack/client/components\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport { PageBuilderSkeleton } from \"../loading/page-builder-skeleton\";\nimport { DefaultError } from \"../shared/default-error\";\nimport type { UIBuilderPluginOverrides } from \"../../overrides\";\n\nconst PageBuilderPageInternal = lazy(() =>\n\timport(\"./page-builder-page.internal\").then((m) => ({\n\t\tdefault: m.PageBuilderPage,\n\t})),\n);\n\nexport interface PageBuilderPageProps {\n\tid?: string;\n}\n\nexport function PageBuilderPage({ id }: PageBuilderPageProps) {\n\tconst { onRouteError } =\n\t\tusePluginOverrides(\"ui-builder\");\n\tconst path = id ? `/ui-builder/${id}/edit` : \"/ui-builder/new\";\n\n\treturn (\n\t\t null}\n\t\t\tprops={{ id }}\n\t\t\tonError={(error) => {\n\t\t\t\tonRouteError?.(\"pageBuilder\", error, {\n\t\t\t\t\tpath,\n\t\t\t\t\tparams: id ? { id } : {},\n\t\t\t\t\tisSSR: typeof window === \"undefined\",\n\t\t\t\t});\n\t\t\t}}\n\t\t/>\n\t);\n}\n", "target": "src/components/btst/ui-builder/client/components/pages/page-builder-page.tsx" }, { "path": "btst/ui-builder/client/components/pages/page-list-page.internal.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { usePluginOverrides, useBasePath } from \"@btst/stack/context\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n\tTable,\n\tTableBody,\n\tTableCell,\n\tTableHead,\n\tTableHeader,\n\tTableRow,\n} from \"@/components/ui/table\";\nimport {\n\tDropdownMenu,\n\tDropdownMenuContent,\n\tDropdownMenuItem,\n\tDropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport {\n\tAlertDialog,\n\tAlertDialogAction,\n\tAlertDialogCancel,\n\tAlertDialogContent,\n\tAlertDialogDescription,\n\tAlertDialogFooter,\n\tAlertDialogHeader,\n\tAlertDialogTitle,\n} from \"@/components/ui/alert-dialog\";\nimport { MoreHorizontal, Plus, Pencil, Trash2 } from \"lucide-react\";\nimport { toast } from \"sonner\";\n\nimport {\n\tuseSuspenseUIBuilderPages,\n\tuseDeleteUIBuilderPage,\n} from \"@btst/stack/plugins/ui-builder/client/hooks\";\nimport type { UIBuilderPluginOverrides } from \"../../overrides\";\nimport { uiBuilderLocalization } from \"../../localization\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\nimport { EmptyState } from \"../shared/empty-state\";\nimport { Pagination } from \"../shared/pagination\";\n\nexport function PageListPage() {\n\tconst { navigate, Link } =\n\t\tusePluginOverrides(\"ui-builder\");\n\tconst basePath = useBasePath();\n\tconst { pages, total, hasMore, isLoadingMore, loadMore, refetch } =\n\t\tuseSuspenseUIBuilderPages();\n\tconst deleteMutation = useDeleteUIBuilderPage();\n\n\tconst [deleteId, setDeleteId] = useState(null);\n\n\tconst loc = uiBuilderLocalization;\n\tconst LinkComponent = Link || \"a\";\n\n\tconst handleDelete = async () => {\n\t\tif (!deleteId) return;\n\n\t\ttry {\n\t\t\tawait deleteMutation.mutateAsync(deleteId);\n\t\t\ttoast.success(\"Page deleted successfully\");\n\t\t\tsetDeleteId(null);\n\t\t\tawait refetch();\n\t\t} catch {\n\t\t\ttoast.error(\"Failed to delete page\");\n\t\t}\n\t};\n\n\tconst getStatusBadge = (status: string) => {\n\t\tconst colors: Record = {\n\t\t\tpublished:\n\t\t\t\t\"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200\",\n\t\t\tdraft:\n\t\t\t\t\"bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200\",\n\t\t\tarchived: \"bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200\",\n\t\t};\n\t\treturn (\n\t\t\t\n\t\t\t\t{loc.pageBuilder.statusOptions[\n\t\t\t\t\tstatus as keyof typeof loc.pageBuilder.statusOptions\n\t\t\t\t] || status}\n\t\t\t\n\t\t);\n\t};\n\n\treturn (\n\t\t\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t

{loc.pageList.title}

\n\t\t\t\t\t\t

{loc.pageList.description}

\n\t\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t
\n\n\t\t\t\t{pages.length === 0 ? (\n\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{loc.pageList.createButton}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t}\n\t\t\t\t\t/>\n\t\t\t\t) : (\n\t\t\t\t\t<>\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t{loc.pageList.columns.slug}\n\t\t\t\t\t\t\t\t\t\t{loc.pageList.columns.status}\n\t\t\t\t\t\t\t\t\t\t{loc.pageList.columns.updatedAt}\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t{loc.pageList.columns.actions}\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{pages.map((page) => (\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t{page.slug}\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t{getStatusBadge(page.parsedData.status)}\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t{new Date(page.updatedAt).toLocaleDateString()}\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnavigate?.(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t`${basePath}/ui-builder/${page.id}/edit`,\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{loc.pageList.actions.edit}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t setDeleteId(page.id)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{loc.pageList.actions.delete}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t
\n\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t
\n\n\t\t\t{/* Delete confirmation dialog */}\n\t\t\t setDeleteId(null)}>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{loc.pageList.deleteDialog.title}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{loc.pageList.deleteDialog.description}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{loc.pageList.deleteDialog.cancel}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{deleteMutation.isPending\n\t\t\t\t\t\t\t\t? \"Deleting...\"\n\t\t\t\t\t\t\t\t: loc.pageList.deleteDialog.confirm}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t
\n\t);\n}\n", + "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport {\n\tCanAccess,\n\tuseBasePath,\n\tuseNotify,\n\tusePluginOverrides,\n\tuseTranslate,\n} from \"@btst/stack/context\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n\tTable,\n\tTableBody,\n\tTableCell,\n\tTableHead,\n\tTableHeader,\n\tTableRow,\n} from \"@/components/ui/table\";\nimport {\n\tDropdownMenu,\n\tDropdownMenuContent,\n\tDropdownMenuItem,\n\tDropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport {\n\tAlertDialog,\n\tAlertDialogAction,\n\tAlertDialogCancel,\n\tAlertDialogContent,\n\tAlertDialogDescription,\n\tAlertDialogFooter,\n\tAlertDialogHeader,\n\tAlertDialogTitle,\n} from \"@/components/ui/alert-dialog\";\nimport { MoreHorizontal, Plus, Pencil, Trash2 } from \"lucide-react\";\n\nimport {\n\tuseSuspenseUIBuilderPages,\n\tuseDeleteUIBuilderPage,\n} from \"@btst/stack/plugins/ui-builder/client/hooks\";\nimport type { UIBuilderPluginOverrides } from \"../../overrides\";\nimport { uiBuilderLocalization } from \"../../localization\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\nimport { EmptyState } from \"../shared/empty-state\";\nimport { Pagination } from \"../shared/pagination\";\n\nexport function PageListPage() {\n\tconst t = useTranslate();\n\tconst notify = useNotify();\n\tconst { navigate, Link, localization } =\n\t\tusePluginOverrides(\"ui-builder\");\n\tconst basePath = useBasePath();\n\tconst { pages, total, hasMore, isLoadingMore, loadMore } =\n\t\tuseSuspenseUIBuilderPages();\n\tconst deleteMutation = useDeleteUIBuilderPage();\n\n\tconst [deleteId, setDeleteId] = useState(null);\n\n\tconst LinkComponent = Link || \"a\";\n\n\tconst handleDelete = async () => {\n\t\tif (!deleteId) return;\n\n\t\ttry {\n\t\t\tawait deleteMutation.mutateAsync(deleteId);\n\t\t} catch {\n\t\t\tnotify.error(\n\t\t\t\tlocalization?.pageList?.deleteError ??\n\t\t\t\t\tt(\n\t\t\t\t\t\t\"uiBuilder.pageList.deleteError\",\n\t\t\t\t\t\tuiBuilderLocalization.pageList.deleteError,\n\t\t\t\t\t),\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\t\tnotify.success(\n\t\t\tlocalization?.pageList?.deleteSuccess ??\n\t\t\t\tt(\n\t\t\t\t\t\"uiBuilder.pageList.deleteSuccess\",\n\t\t\t\t\tuiBuilderLocalization.pageList.deleteSuccess,\n\t\t\t\t),\n\t\t);\n\t\tsetDeleteId(null);\n\t};\n\n\tconst getStatusBadge = (status: string) => {\n\t\tconst colors: Record = {\n\t\t\tpublished:\n\t\t\t\t\"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200\",\n\t\t\tdraft:\n\t\t\t\t\"bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200\",\n\t\t\tarchived: \"bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200\",\n\t\t};\n\t\treturn (\n\t\t\t\n\t\t\t\t{localization?.pageBuilder?.statusOptions?.[\n\t\t\t\t\tstatus as keyof typeof uiBuilderLocalization.pageBuilder.statusOptions\n\t\t\t\t] ??\n\t\t\t\t\tt(\n\t\t\t\t\t\t`uiBuilder.pageBuilder.statusOptions.${status}`,\n\t\t\t\t\t\tuiBuilderLocalization.pageBuilder.statusOptions[\n\t\t\t\t\t\t\tstatus as keyof typeof uiBuilderLocalization.pageBuilder.statusOptions\n\t\t\t\t\t\t] ?? status,\n\t\t\t\t\t)}\n\t\t\t\n\t\t);\n\t};\n\n\tconst createButton = (\n\t\t\n\t\t\t\n\t\t\n\t);\n\n\treturn (\n\t\t\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t

\n\t\t\t\t\t\t\t{localization?.pageList?.title ??\n\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\"uiBuilder.pageList.title\",\n\t\t\t\t\t\t\t\t\tuiBuilderLocalization.pageList.title,\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t

\n\t\t\t\t\t\t

\n\t\t\t\t\t\t\t{localization?.pageList?.description ??\n\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\"uiBuilder.pageList.description\",\n\t\t\t\t\t\t\t\t\tuiBuilderLocalization.pageList.description,\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t

\n\t\t\t\t\t
\n\t\t\t\t\t{createButton}\n\t\t\t\t
\n\n\t\t\t\t{pages.length === 0 ? (\n\t\t\t\t\t\n\t\t\t\t) : (\n\t\t\t\t\t<>\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t{localization?.pageList?.columns?.slug ??\n\t\t\t\t\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\"uiBuilder.pageList.columns.slug\",\n\t\t\t\t\t\t\t\t\t\t\t\t\tuiBuilderLocalization.pageList.columns.slug,\n\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t{localization?.pageList?.columns?.status ??\n\t\t\t\t\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\"uiBuilder.pageList.columns.status\",\n\t\t\t\t\t\t\t\t\t\t\t\t\tuiBuilderLocalization.pageList.columns.status,\n\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t{localization?.pageList?.columns?.updatedAt ??\n\t\t\t\t\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\"uiBuilder.pageList.columns.updatedAt\",\n\t\t\t\t\t\t\t\t\t\t\t\t\tuiBuilderLocalization.pageList.columns.updatedAt,\n\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t{localization?.pageList?.columns?.actions ??\n\t\t\t\t\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\"uiBuilder.pageList.columns.actions\",\n\t\t\t\t\t\t\t\t\t\t\t\t\tuiBuilderLocalization.pageList.columns.actions,\n\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{pages.map((page) => (\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t{page.slug}\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t{getStatusBadge(page.parsedData.status)}\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t{new Date(page.updatedAt).toLocaleDateString()}\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnavigate?.(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t`${basePath}/ui-builder/${page.id}/edit`,\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{localization?.pageList?.actions?.edit ??\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"uiBuilder.pageList.actions.edit\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tuiBuilderLocalization.pageList.actions.edit,\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t setDeleteId(page.id)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{localization?.pageList?.actions?.delete ??\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"uiBuilder.pageList.actions.delete\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tuiBuilderLocalization.pageList.actions\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t.delete,\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t
\n\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t
\n\n\t\t\t{/* Delete confirmation dialog */}\n\t\t\t setDeleteId(null)}>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.pageList?.deleteDialog?.title ??\n\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\"uiBuilder.pageList.deleteDialog.title\",\n\t\t\t\t\t\t\t\t\tuiBuilderLocalization.pageList.deleteDialog.title,\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.pageList?.deleteDialog?.description ??\n\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\"uiBuilder.pageList.deleteDialog.description\",\n\t\t\t\t\t\t\t\t\tuiBuilderLocalization.pageList.deleteDialog.description,\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.pageList?.deleteDialog?.cancel ??\n\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\"uiBuilder.pageList.deleteDialog.cancel\",\n\t\t\t\t\t\t\t\t\tuiBuilderLocalization.pageList.deleteDialog.cancel,\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{deleteMutation.isPending\n\t\t\t\t\t\t\t\t? (localization?.pageList?.deleteDialog?.deleting ??\n\t\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\t\"uiBuilder.pageList.deleteDialog.deleting\",\n\t\t\t\t\t\t\t\t\t\tuiBuilderLocalization.pageList.deleteDialog.deleting,\n\t\t\t\t\t\t\t\t\t))\n\t\t\t\t\t\t\t\t: (localization?.pageList?.deleteDialog?.confirm ??\n\t\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\t\"uiBuilder.pageList.deleteDialog.confirm\",\n\t\t\t\t\t\t\t\t\t\tuiBuilderLocalization.pageList.deleteDialog.confirm,\n\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t
\n\t);\n}\n", "target": "src/components/btst/ui-builder/client/components/pages/page-list-page.internal.tsx" }, { "path": "btst/ui-builder/client/components/pages/page-list-page.tsx", "type": "registry:page", - "content": "\"use client\";\n\nimport { lazy, Suspense } from \"react\";\nimport { PageListSkeleton } from \"../loading/page-list-skeleton\";\nimport { ErrorBoundary } from \"react-error-boundary\";\nimport { DefaultError } from \"../shared/default-error\";\n\nconst PageListPageInternal = lazy(() =>\n\timport(\"./page-list-page.internal\").then((m) => ({\n\t\tdefault: m.PageListPage,\n\t})),\n);\n\nexport function PageListPage() {\n\treturn (\n\t\t\n\t\t\t}>\n\t\t\t\t\n\t\t\t\n\t\t\n\t);\n}\n", + "content": "\"use client\";\n\nimport { lazy } from \"react\";\nimport { ComposedRoute } from \"@btst/stack/client/components\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport { PageListSkeleton } from \"../loading/page-list-skeleton\";\nimport { DefaultError } from \"../shared/default-error\";\nimport type { UIBuilderPluginOverrides } from \"../../overrides\";\n\nconst PageListPageInternal = lazy(() =>\n\timport(\"./page-list-page.internal\").then((m) => ({\n\t\tdefault: m.PageListPage,\n\t})),\n);\n\nexport function PageListPage() {\n\tconst { onRouteError } =\n\t\tusePluginOverrides(\"ui-builder\");\n\n\treturn (\n\t\t null}\n\t\t\tonError={(error) => {\n\t\t\t\tonRouteError?.(\"pageList\", error, {\n\t\t\t\t\tpath: \"/ui-builder\",\n\t\t\t\t\tisSSR: typeof window === \"undefined\",\n\t\t\t\t});\n\t\t\t}}\n\t\t/>\n\t);\n}\n", "target": "src/components/btst/ui-builder/client/components/pages/page-list-page.tsx" }, { "path": "btst/ui-builder/client/components/shared/default-error.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { AlertCircle } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\n\ninterface DefaultErrorProps {\n\terror: unknown;\n\tresetErrorBoundary?: () => void;\n}\n\nexport function DefaultError({ error, resetErrorBoundary }: DefaultErrorProps) {\n\treturn (\n\t\t
\n\t\t\t
\n\t\t\t\t\n\t\t\t
\n\t\t\t

\n\t\t\t\tSomething went wrong\n\t\t\t

\n\t\t\t

\n\t\t\t\t{(error instanceof Error ? error.message : undefined) ||\n\t\t\t\t\t\"An unexpected error occurred\"}\n\t\t\t

\n\t\t\t{resetErrorBoundary && (\n\t\t\t\t\n\t\t\t)}\n\t\t
\n\t);\n}\n", + "content": "\"use client\";\n\nimport { AlertCircle } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { usePluginOverrides, useTranslate } from \"@btst/stack/context\";\nimport type { UIBuilderPluginOverrides } from \"../../overrides\";\nimport { uiBuilderLocalization } from \"../../localization\";\n\ninterface DefaultErrorProps {\n\terror: unknown;\n\tresetErrorBoundary?: () => void;\n}\n\nexport function DefaultError({ error, resetErrorBoundary }: DefaultErrorProps) {\n\tconst t = useTranslate();\n\tconst { localization } =\n\t\tusePluginOverrides(\"ui-builder\");\n\tconst title =\n\t\tlocalization?.common?.errorTitle ??\n\t\tt(\"uiBuilder.common.errorTitle\", uiBuilderLocalization.common.errorTitle);\n\tconst unexpectedError =\n\t\tlocalization?.common?.unexpectedError ??\n\t\tt(\n\t\t\t\"uiBuilder.common.unexpectedError\",\n\t\t\tuiBuilderLocalization.common.unexpectedError,\n\t\t);\n\tconst tryAgain =\n\t\tlocalization?.common?.tryAgain ??\n\t\tt(\"uiBuilder.common.tryAgain\", uiBuilderLocalization.common.tryAgain);\n\n\treturn (\n\t\t
\n\t\t\t
\n\t\t\t\t\n\t\t\t
\n\t\t\t

{title}

\n\t\t\t

\n\t\t\t\t{(error instanceof Error ? error.message : undefined) ||\n\t\t\t\t\tunexpectedError}\n\t\t\t

\n\t\t\t{resetErrorBoundary && (\n\t\t\t\t\n\t\t\t)}\n\t\t
\n\t);\n}\n", "target": "src/components/btst/ui-builder/client/components/shared/default-error.tsx" }, { @@ -100,19 +100,19 @@ { "path": "btst/ui-builder/client/components/shared/pagination.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { ChevronRight } from \"lucide-react\";\n\ninterface PaginationProps {\n\ttotal: number;\n\tshowing: number;\n\thasMore: boolean;\n\tisLoadingMore: boolean;\n\tonLoadMore: () => void;\n\tlabels?: {\n\t\tshowing?: string;\n\t\tprevious?: string;\n\t\tnext?: string;\n\t};\n}\n\nexport function Pagination({\n\ttotal,\n\tshowing,\n\thasMore,\n\tisLoadingMore,\n\tonLoadMore,\n\tlabels = {},\n}: PaginationProps) {\n\tconst {\n\t\tshowing: showingLabel = \"Showing {count} of {total}\",\n\t\tnext = \"Load More\",\n\t} = labels;\n\n\tconst showingText = showingLabel\n\t\t.replace(\"{count}\", String(showing))\n\t\t.replace(\"{total}\", String(total));\n\n\treturn (\n\t\t
\n\t\t\t

{showingText}

\n\t\t\t{hasMore && (\n\t\t\t\t\n\t\t\t\t\t{isLoadingMore ? \"Loading...\" : next}\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t)}\n\t\t
\n\t);\n}\n", + "content": "\"use client\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { ChevronRight } from \"lucide-react\";\n\ninterface PaginationProps {\n\ttotal: number;\n\tshowing: number;\n\thasMore: boolean;\n\tisLoadingMore: boolean;\n\tonLoadMore: () => void;\n\tlabels?: {\n\t\tshowing?: string;\n\t\tprevious?: string;\n\t\tnext?: string;\n\t\tloading?: string;\n\t};\n}\n\nexport function Pagination({\n\ttotal,\n\tshowing,\n\thasMore,\n\tisLoadingMore,\n\tonLoadMore,\n\tlabels = {},\n}: PaginationProps) {\n\tconst {\n\t\tshowing: showingLabel = \"Showing {count} of {total}\",\n\t\tnext = \"Load More\",\n\t\tloading = \"Loading...\",\n\t} = labels;\n\n\tconst showingText = showingLabel\n\t\t.replace(\"{count}\", String(showing))\n\t\t.replace(\"{total}\", String(total));\n\n\treturn (\n\t\t
\n\t\t\t

{showingText}

\n\t\t\t{hasMore && (\n\t\t\t\t\n\t\t\t\t\t{isLoadingMore ? loading : next}\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t)}\n\t\t
\n\t);\n}\n", "target": "src/components/btst/ui-builder/client/components/shared/pagination.tsx" }, { "path": "btst/ui-builder/client/localization/index.ts", "type": "registry:lib", - "content": "/**\n * UI Builder plugin localization strings\n */\nexport const uiBuilderLocalization = {\n\tpageList: {\n\t\ttitle: \"UI Builder Pages\",\n\t\tdescription:\n\t\t\t\"Create and manage visual pages with the drag-and-drop builder\",\n\t\tcreateButton: \"Create Page\",\n\t\temptyState: {\n\t\t\ttitle: \"No pages yet\",\n\t\t\tdescription: \"Create your first page with the visual builder\",\n\t\t},\n\t\tcolumns: {\n\t\t\tname: \"Name\",\n\t\t\tslug: \"Slug\",\n\t\t\tstatus: \"Status\",\n\t\t\tupdatedAt: \"Updated\",\n\t\t\tactions: \"Actions\",\n\t\t},\n\t\tactions: {\n\t\t\tedit: \"Edit\",\n\t\t\tdelete: \"Delete\",\n\t\t},\n\t\tdeleteDialog: {\n\t\t\ttitle: \"Delete Page\",\n\t\t\tdescription:\n\t\t\t\t\"Are you sure you want to delete this page? This action cannot be undone.\",\n\t\t\tcancel: \"Cancel\",\n\t\t\tconfirm: \"Delete\",\n\t\t},\n\t},\n\tpageBuilder: {\n\t\tnewPage: \"New Page\",\n\t\teditPage: \"Edit Page\",\n\t\tbackToList: \"Back to Pages\",\n\t\tsave: \"Save\",\n\t\tsaving: \"Saving...\",\n\t\tsaved: \"Saved\",\n\t\tsaveError: \"Failed to save\",\n\t\tslugLabel: \"Page Slug\",\n\t\tslugPlaceholder: \"my-page-slug\",\n\t\tslugDescription: \"URL-friendly identifier for this page\",\n\t\tstatusLabel: \"Status\",\n\t\tstatusOptions: {\n\t\t\tdraft: \"Draft\",\n\t\t\tpublished: \"Published\",\n\t\t\tarchived: \"Archived\",\n\t\t},\n\t\tvalidation: {\n\t\t\tslugRequired: \"Slug is required\",\n\t\t\tslugFormat:\n\t\t\t\t\"Slug must contain only lowercase letters, numbers, and hyphens\",\n\t\t\tlayersRequired: \"Page must have at least one component\",\n\t\t},\n\t},\n\tpageRenderer: {\n\t\tloading: \"Loading page...\",\n\t\tnotFound: \"Page not found\",\n\t\terror: \"Failed to load page\",\n\t},\n} as const;\n\nexport type UIBuilderLocalization = typeof uiBuilderLocalization;\n", + "content": "export interface UIBuilderLocalization {\n\tpageList: {\n\t\ttitle: string;\n\t\tdescription: string;\n\t\tcreateButton: string;\n\t\temptyState: { title: string; description: string };\n\t\tcolumns: {\n\t\t\tname: string;\n\t\t\tslug: string;\n\t\t\tstatus: string;\n\t\t\tupdatedAt: string;\n\t\t\tactions: string;\n\t\t};\n\t\tactions: { label: string; edit: string; delete: string };\n\t\tdeleteDialog: {\n\t\t\ttitle: string;\n\t\t\tdescription: string;\n\t\t\tcancel: string;\n\t\t\tconfirm: string;\n\t\t\tdeleting: string;\n\t\t};\n\t\tpagination: {\n\t\t\tshowing: string;\n\t\t\tloadMore: string;\n\t\t\tloading: string;\n\t\t};\n\t\tdeleteSuccess: string;\n\t\tdeleteError: string;\n\t};\n\tpageBuilder: {\n\t\tnewPage: string;\n\t\teditPage: string;\n\t\tbackToList: string;\n\t\tsave: string;\n\t\tsaving: string;\n\t\tsaved: string;\n\t\tsaveError: string;\n\t\tduplicateSlug: string;\n\t\tslugLabel: string;\n\t\tslugPlaceholder: string;\n\t\tslugDescription: string;\n\t\tstatusLabel: string;\n\t\tsettingsTitle: string;\n\t\tsettingsDescription: string;\n\t\tstatusOptions: {\n\t\t\tdraft: string;\n\t\t\tpublished: string;\n\t\t\tarchived: string;\n\t\t};\n\t\tvalidation: {\n\t\t\tslugRequired: string;\n\t\t\tslugFormat: string;\n\t\t\tlayersRequired: string;\n\t\t};\n\t};\n\tpageRenderer: { loading: string; notFound: string; error: string };\n\tcommon: {\n\t\terrorTitle: string;\n\t\tunexpectedError: string;\n\t\ttryAgain: string;\n\t};\n}\n\ntype DeepPartial = {\n\t[P in keyof T]?: T[P] extends object ? DeepPartial : T[P];\n};\n\nexport type UIBuilderLocalizationOverrides = DeepPartial;\n\nexport const uiBuilderLocalization: UIBuilderLocalization = {\n\tpageList: {\n\t\ttitle: \"UI Builder Pages\",\n\t\tdescription:\n\t\t\t\"Create and manage visual pages with the drag-and-drop builder\",\n\t\tcreateButton: \"Create Page\",\n\t\temptyState: {\n\t\t\ttitle: \"No pages yet\",\n\t\t\tdescription: \"Create your first page with the visual builder\",\n\t\t},\n\t\tcolumns: {\n\t\t\tname: \"Name\",\n\t\t\tslug: \"Slug\",\n\t\t\tstatus: \"Status\",\n\t\t\tupdatedAt: \"Updated\",\n\t\t\tactions: \"Actions\",\n\t\t},\n\t\tactions: { label: \"Actions\", edit: \"Edit\", delete: \"Delete\" },\n\t\tdeleteDialog: {\n\t\t\ttitle: \"Delete Page\",\n\t\t\tdescription:\n\t\t\t\t\"Are you sure you want to delete this page? This action cannot be undone.\",\n\t\t\tcancel: \"Cancel\",\n\t\t\tconfirm: \"Delete\",\n\t\t\tdeleting: \"Deleting...\",\n\t\t},\n\t\tpagination: {\n\t\t\tshowing: \"Showing {count} of {total}\",\n\t\t\tloadMore: \"Load More\",\n\t\t\tloading: \"Loading...\",\n\t\t},\n\t\tdeleteSuccess: \"Page deleted successfully\",\n\t\tdeleteError: \"Failed to delete page\",\n\t},\n\tpageBuilder: {\n\t\tnewPage: \"New Page\",\n\t\teditPage: \"Edit Page\",\n\t\tbackToList: \"Back to Pages\",\n\t\tsave: \"Save\",\n\t\tsaving: \"Saving...\",\n\t\tsaved: \"Saved\",\n\t\tsaveError: \"Failed to save\",\n\t\tduplicateSlug: \"A page with this slug already exists\",\n\t\tslugLabel: \"Page Slug\",\n\t\tslugPlaceholder: \"my-page-slug\",\n\t\tslugDescription: \"URL-friendly identifier for this page\",\n\t\tstatusLabel: \"Status\",\n\t\tsettingsTitle: \"Page Settings\",\n\t\tsettingsDescription: \"Configure page slug and status\",\n\t\tstatusOptions: {\n\t\t\tdraft: \"Draft\",\n\t\t\tpublished: \"Published\",\n\t\t\tarchived: \"Archived\",\n\t\t},\n\t\tvalidation: {\n\t\t\tslugRequired: \"Slug is required\",\n\t\t\tslugFormat:\n\t\t\t\t\"Slug must contain only lowercase letters, numbers, and hyphens\",\n\t\t\tlayersRequired: \"Page must have at least one component\",\n\t\t},\n\t},\n\tpageRenderer: {\n\t\tloading: \"Loading page...\",\n\t\tnotFound: \"Page not found\",\n\t\terror: \"Failed to load page\",\n\t},\n\tcommon: {\n\t\terrorTitle: \"Something went wrong\",\n\t\tunexpectedError: \"An unexpected error occurred\",\n\t\ttryAgain: \"Try again\",\n\t},\n};\n", "target": "src/components/btst/ui-builder/client/localization/index.ts" }, { "path": "btst/ui-builder/client/overrides.ts", "type": "registry:lib", - "content": "import type { ComponentType } from \"react\";\nimport type {\n\tComponentRegistry,\n\tFunctionRegistry,\n} from \"@/components/ui/ui-builder/types\";\nimport type { UIBuilderClientHooks } from \"../types\";\n\n/**\n * Context passed to lifecycle hooks\n */\nexport interface RouteContext {\n\t/** Current route path */\n\tpath: string;\n\t/** Route parameters (e.g., { id: \"123\" }) */\n\tparams?: Record;\n\t/** Whether rendering on server (true) or client (false) */\n\tisSSR: boolean;\n\t/** Additional context properties */\n\t[key: string]: unknown;\n}\n\n/**\n * Plugin overrides interface for UI Builder\n *\n * External consumers can provide their own implementations of these\n * to customize the behavior for their framework (Next.js, React Router, etc.)\n */\nexport interface UIBuilderPluginOverrides {\n\t/**\n\t * Link component for navigation\n\t */\n\tLink?: ComponentType & Record>;\n\n\t/**\n\t * Navigation function for programmatic navigation\n\t */\n\tnavigate?: (path: string) => void | Promise;\n\n\t/**\n\t * Refresh function to invalidate server-side cache (e.g., Next.js router.refresh())\n\t */\n\trefresh?: () => void | Promise;\n\n\t/**\n\t * API base URL\n\t */\n\tapiBaseURL: string;\n\n\t/**\n\t * API base path\n\t */\n\tapiBasePath: string;\n\n\t/**\n\t * Optional headers to pass with API requests (e.g., for SSR auth)\n\t */\n\theaders?: HeadersInit;\n\n\t/**\n\t * Whether to show the attribution\n\t */\n\tshowAttribution?: boolean;\n\n\t/**\n\t * Component registry for the UI Builder\n\t */\n\tcomponentRegistry?: ComponentRegistry;\n\n\t/**\n\t * Function registry for resolving bindable event handlers (onClick, onSubmit, etc.)\n\t * in the preview modal and layer renderer.\n\t */\n\tfunctionRegistry?: FunctionRegistry;\n\n\t/**\n\t * Base path for UI Builder admin pages (default: /pages/ui-builder)\n\t */\n\tsiteBasePath?: string;\n\n\t/**\n\t * SSR authorization hooks\n\t */\n\thooks?: UIBuilderClientHooks;\n\n\t// Lifecycle Hooks (optional)\n\n\t/**\n\t * Called when a route is rendered\n\t * @param routeName - Name of the route (e.g., 'pageList', 'pageBuilder')\n\t * @param context - Route context with path, params, etc.\n\t */\n\tonRouteRender?: (\n\t\trouteName: string,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n\n\t/**\n\t * Called when a route encounters an error\n\t * @param routeName - Name of the route\n\t * @param error - The error that occurred\n\t * @param context - Route context\n\t */\n\tonRouteError?: (\n\t\trouteName: string,\n\t\terror: Error,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n}\n", + "content": "import type { ComponentType } from \"react\";\nimport type {\n\tComponentRegistry,\n\tFunctionRegistry,\n} from \"@/components/ui/ui-builder/types\";\nimport type { UIBuilderClientHooks } from \"../types\";\nimport type { UIBuilderLocalizationOverrides } from \"./localization\";\n\n/**\n * Context passed to lifecycle hooks\n */\nexport interface RouteContext {\n\t/** Current route path */\n\tpath: string;\n\t/** Route parameters (e.g., { id: \"123\" }) */\n\tparams?: Record;\n\t/** Whether rendering on server (true) or client (false) */\n\tisSSR: boolean;\n\t/** Additional context properties */\n\t[key: string]: unknown;\n}\n\n/**\n * Plugin overrides interface for UI Builder\n *\n * External consumers can provide their own implementations of these\n * to customize the behavior for their framework (Next.js, React Router, etc.)\n */\nexport interface UIBuilderPluginOverrides {\n\t/**\n\t * Link component for navigation\n\t */\n\tLink?: ComponentType & Record>;\n\n\t/**\n\t * Navigation function for programmatic navigation\n\t */\n\tnavigate?: (path: string) => void | Promise;\n\n\t/**\n\t * Refresh function to invalidate server-side cache (e.g., Next.js router.refresh())\n\t */\n\trefresh?: () => void | Promise;\n\n\t/**\n\t * API base URL\n\t */\n\tapiBaseURL: string;\n\n\t/**\n\t * API base path\n\t */\n\tapiBasePath: string;\n\n\t/**\n\t * Optional headers to pass with API requests (e.g., for SSR auth)\n\t */\n\theaders?: HeadersInit;\n\n\t/**\n\t * Whether to show the attribution\n\t */\n\tshowAttribution?: boolean;\n\n\t/**\n\t * Component registry for the UI Builder\n\t */\n\tcomponentRegistry?: ComponentRegistry;\n\n\t/**\n\t * Function registry for resolving bindable event handlers (onClick, onSubmit, etc.)\n\t * in the preview modal and layer renderer.\n\t */\n\tfunctionRegistry?: FunctionRegistry;\n\n\t/** Localization overrides for built-in UI Builder plugin pages. */\n\tlocalization?: UIBuilderLocalizationOverrides;\n\n\t/**\n\t * Base path for UI Builder admin pages (default: /pages/ui-builder)\n\t */\n\tsiteBasePath?: string;\n\n\t/**\n\t * SSR authorization hooks\n\t */\n\thooks?: UIBuilderClientHooks;\n\n\t// Lifecycle Hooks (optional)\n\n\t/**\n\t * Called when a route is rendered\n\t * @param routeName - Name of the route (e.g., 'pageList', 'pageBuilder')\n\t * @param context - Route context with path, params, etc.\n\t */\n\tonRouteRender?: (\n\t\trouteName: string,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n\n\t/**\n\t * Called when a route encounters an error\n\t * @param routeName - Name of the route\n\t * @param error - The error that occurred\n\t * @param context - Route context\n\t */\n\tonRouteError?: (\n\t\trouteName: string,\n\t\terror: Error,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n}\n", "target": "src/components/btst/ui-builder/client/overrides.ts" }, { @@ -121,83 +121,11 @@ "content": "\"use client\";\n\nimport { z } from \"zod\";\nimport type {\n\tComponentRegistry,\n\tComponentLayer,\n} from \"@/components/ui/ui-builder/types\";\n\n// Import shadcn/ui components\nimport { Button } from \"@/components/ui/button\";\nimport { Badge } from \"@/components/ui/badge\";\nimport {\n\tAccordion,\n\tAccordionItem,\n\tAccordionTrigger,\n\tAccordionContent,\n} from \"@/components/ui/accordion\";\nimport {\n\tCard,\n\tCardHeader,\n\tCardFooter,\n\tCardTitle,\n\tCardDescription,\n\tCardContent,\n} from \"@/components/ui/card\";\nimport { Separator } from \"@/components/ui/separator\";\n\n// Import UI builder helper components\nimport { Flexbox } from \"@/components/ui/ui-builder/components/flexbox\";\nimport { Grid } from \"@/components/ui/ui-builder/components/grid\";\nimport { CodePanel } from \"@/components/ui/ui-builder/components/code-panel\";\nimport { Markdown } from \"@/components/ui/ui-builder/components/markdown\";\nimport {\n\tIcon,\n\ticonNames,\n} from \"@/components/ui/ui-builder/components/icon\";\n\n// Import field override helpers for props panel\nimport {\n\tclassNameFieldOverrides,\n\tchildrenFieldOverrides,\n\ticonNameFieldOverrides,\n\tcommonFieldOverrides,\n\tchildrenAsTipTapFieldOverrides,\n\tchildrenAsTextareaFieldOverrides,\n\tfunctionPropFieldOverrides,\n} from \"@/lib/ui-builder/registry/form-field-overrides\";\n\n/**\n * Primitive HTML component definitions\n * These are simple HTML elements that can be used in the UI builder\n */\nexport const primitiveComponentDefinitions: ComponentRegistry = {\n\ta: {\n\t\tschema: z.object({\n\t\t\tclassName: z.string().optional(),\n\t\t\tchildren: z.any().optional(),\n\t\t\thref: z.string().optional(),\n\t\t\ttarget: z\n\t\t\t\t.enum([\"_blank\", \"_self\", \"_parent\", \"_top\"])\n\t\t\t\t.optional()\n\t\t\t\t.default(\"_self\"),\n\t\t\trel: z.enum([\"noopener\", \"noreferrer\", \"nofollow\"]).optional(),\n\t\t\ttitle: z.string().optional(),\n\t\t\tdownload: z.boolean().optional().default(false),\n\t\t\tonClick: z.any().optional(),\n\t\t}),\n\t\tfieldOverrides: {\n\t\t\t...commonFieldOverrides(),\n\t\t\tonClick: () => functionPropFieldOverrides(\"onClick\"),\n\t\t},\n\t},\n\tbutton: {\n\t\tschema: z.object({\n\t\t\tclassName: z.string().optional(),\n\t\t\tchildren: z.any().optional(),\n\t\t\ttype: z.enum([\"button\", \"submit\", \"reset\"]).optional().default(\"button\"),\n\t\t\tdisabled: z.boolean().optional().default(false),\n\t\t\tonClick: z.any().optional(),\n\t\t}),\n\t\tdefaultChildren: \"Button\",\n\t\tfieldOverrides: {\n\t\t\t...commonFieldOverrides(),\n\t\t\tonClick: () => functionPropFieldOverrides(\"onClick\"),\n\t\t},\n\t},\n\tform: {\n\t\tschema: z.object({\n\t\t\tclassName: z.string().optional(),\n\t\t\tchildren: z.any().optional(),\n\t\t\taction: z.string().optional(),\n\t\t\tmethod: z.enum([\"get\", \"post\"]).optional(),\n\t\t\tonSubmit: z.any().optional(),\n\t\t}),\n\t\tfieldOverrides: {\n\t\t\t...commonFieldOverrides(),\n\t\t\tonSubmit: () => functionPropFieldOverrides(\"onSubmit\"),\n\t\t},\n\t},\n\tinput: {\n\t\tschema: z.object({\n\t\t\tclassName: z.string().optional(),\n\t\t\ttype: z\n\t\t\t\t.enum([\n\t\t\t\t\t\"text\",\n\t\t\t\t\t\"password\",\n\t\t\t\t\t\"email\",\n\t\t\t\t\t\"number\",\n\t\t\t\t\t\"tel\",\n\t\t\t\t\t\"url\",\n\t\t\t\t\t\"search\",\n\t\t\t\t\t\"date\",\n\t\t\t\t\t\"time\",\n\t\t\t\t\t\"hidden\",\n\t\t\t\t])\n\t\t\t\t.optional()\n\t\t\t\t.default(\"text\"),\n\t\t\tname: z.string().optional(),\n\t\t\tplaceholder: z.string().optional(),\n\t\t\tdefaultValue: z.string().optional(),\n\t\t\tdisabled: z.boolean().optional().default(false),\n\t\t\trequired: z.boolean().optional().default(false),\n\t\t\tonChange: z.any().optional(),\n\t\t\tonBlur: z.any().optional(),\n\t\t\tonFocus: z.any().optional(),\n\t\t}),\n\t\tfieldOverrides: {\n\t\t\tclassName: (layer) => classNameFieldOverrides(layer),\n\t\t\tonChange: () => functionPropFieldOverrides(\"onChange\"),\n\t\t\tonBlur: () => functionPropFieldOverrides(\"onBlur\"),\n\t\t\tonFocus: () => functionPropFieldOverrides(\"onFocus\"),\n\t\t},\n\t},\n\ttextarea: {\n\t\tschema: z.object({\n\t\t\tclassName: z.string().optional(),\n\t\t\tname: z.string().optional(),\n\t\t\tplaceholder: z.string().optional(),\n\t\t\tdefaultValue: z.string().optional(),\n\t\t\trows: z.coerce.number().optional(),\n\t\t\tdisabled: z.boolean().optional().default(false),\n\t\t\trequired: z.boolean().optional().default(false),\n\t\t\tonChange: z.any().optional(),\n\t\t\tonBlur: z.any().optional(),\n\t\t\tonFocus: z.any().optional(),\n\t\t}),\n\t\tfieldOverrides: {\n\t\t\tclassName: (layer) => classNameFieldOverrides(layer),\n\t\t\tonChange: () => functionPropFieldOverrides(\"onChange\"),\n\t\t\tonBlur: () => functionPropFieldOverrides(\"onBlur\"),\n\t\t\tonFocus: () => functionPropFieldOverrides(\"onFocus\"),\n\t\t},\n\t},\n\tselect: {\n\t\tschema: z.object({\n\t\t\tclassName: z.string().optional(),\n\t\t\tchildren: z.any().optional(),\n\t\t\tname: z.string().optional(),\n\t\t\tdefaultValue: z.string().optional(),\n\t\t\tdisabled: z.boolean().optional().default(false),\n\t\t\trequired: z.boolean().optional().default(false),\n\t\t\tonChange: z.any().optional(),\n\t\t}),\n\t\tfieldOverrides: {\n\t\t\t...commonFieldOverrides(),\n\t\t\tonChange: () => functionPropFieldOverrides(\"onChange\"),\n\t\t},\n\t},\n\tlabel: {\n\t\tschema: z.object({\n\t\t\tclassName: z.string().optional(),\n\t\t\tchildren: z.any().optional(),\n\t\t\thtmlFor: z.string().optional(),\n\t\t\tonClick: z.any().optional(),\n\t\t}),\n\t\tfieldOverrides: {\n\t\t\t...commonFieldOverrides(),\n\t\t\tonClick: () => functionPropFieldOverrides(\"onClick\"),\n\t\t},\n\t},\n\timg: {\n\t\tschema: z.object({\n\t\t\tclassName: z.string().optional(),\n\t\t\tsrc: z.string().default(\"https://placehold.co/200\"),\n\t\t\talt: z.string().optional(),\n\t\t\twidth: z.coerce.number().optional(),\n\t\t\theight: z.coerce.number().optional(),\n\t\t\tonClick: z.any().optional(),\n\t\t}),\n\t\tfieldOverrides: {\n\t\t\tclassName: (layer) => classNameFieldOverrides(layer),\n\t\t\tonClick: () => functionPropFieldOverrides(\"onClick\"),\n\t\t},\n\t},\n\tdiv: {\n\t\tschema: z.object({\n\t\t\tclassName: z.string().optional(),\n\t\t\tchildren: z.any().optional(),\n\t\t\tonClick: z.any().optional(),\n\t\t}),\n\t\tfieldOverrides: {\n\t\t\t...commonFieldOverrides(),\n\t\t\tonClick: () => functionPropFieldOverrides(\"onClick\"),\n\t\t},\n\t},\n\tiframe: {\n\t\tschema: z.object({\n\t\t\tclassName: z.string().optional(),\n\t\t\tsrc: z\n\t\t\t\t.string()\n\t\t\t\t.default(\n\t\t\t\t\t\"https://www.youtube.com/embed/dQw4w9WgXcQ?si=oc74qTYUBuCsOJwL\",\n\t\t\t\t),\n\t\t\ttitle: z.string().optional(),\n\t\t\twidth: z.coerce.number().optional(),\n\t\t\theight: z.coerce.number().optional(),\n\t\t\tframeBorder: z.number().optional(),\n\t\t\tallowFullScreen: z.boolean().optional(),\n\t\t\tallow: z.string().optional(),\n\t\t\treferrerPolicy: z\n\t\t\t\t.enum([\n\t\t\t\t\t\"no-referrer\",\n\t\t\t\t\t\"no-referrer-when-downgrade\",\n\t\t\t\t\t\"origin\",\n\t\t\t\t\t\"origin-when-cross-origin\",\n\t\t\t\t\t\"same-origin\",\n\t\t\t\t\t\"strict-origin\",\n\t\t\t\t\t\"strict-origin-when-cross-origin\",\n\t\t\t\t\t\"unsafe-url\",\n\t\t\t\t])\n\t\t\t\t.optional(),\n\t\t}),\n\t\tfieldOverrides: {\n\t\t\tclassName: (layer) => classNameFieldOverrides(layer),\n\t\t},\n\t},\n\tspan: {\n\t\tschema: z.object({\n\t\t\tclassName: z.string().optional(),\n\t\t\tchildren: z.string().optional(),\n\t\t\tonClick: z.any().optional(),\n\t\t}),\n\t\tdefaultChildren: \"Text\",\n\t\tfieldOverrides: {\n\t\t\tclassName: (layer) => classNameFieldOverrides(layer),\n\t\t\tchildren: (layer) => childrenAsTextareaFieldOverrides(layer),\n\t\t\tonClick: () => functionPropFieldOverrides(\"onClick\"),\n\t\t},\n\t},\n\th1: {\n\t\tschema: z.object({\n\t\t\tclassName: z.string().optional(),\n\t\t\tchildren: z.string().optional(),\n\t\t\tonClick: z.any().optional(),\n\t\t}),\n\t\tdefaultChildren: \"Heading 1\",\n\t\tfieldOverrides: {\n\t\t\t...commonFieldOverrides(),\n\t\t\tchildren: (layer) => childrenAsTextareaFieldOverrides(layer),\n\t\t\tonClick: () => functionPropFieldOverrides(\"onClick\"),\n\t\t},\n\t},\n\th2: {\n\t\tschema: z.object({\n\t\t\tclassName: z.string().optional(),\n\t\t\tchildren: z.string().optional(),\n\t\t\tonClick: z.any().optional(),\n\t\t}),\n\t\tdefaultChildren: \"Heading 2\",\n\t\tfieldOverrides: {\n\t\t\t...commonFieldOverrides(),\n\t\t\tchildren: (layer) => childrenAsTextareaFieldOverrides(layer),\n\t\t\tonClick: () => functionPropFieldOverrides(\"onClick\"),\n\t\t},\n\t},\n\th3: {\n\t\tschema: z.object({\n\t\t\tclassName: z.string().optional(),\n\t\t\tchildren: z.string().optional(),\n\t\t\tonClick: z.any().optional(),\n\t\t}),\n\t\tdefaultChildren: \"Heading 3\",\n\t\tfieldOverrides: {\n\t\t\t...commonFieldOverrides(),\n\t\t\tchildren: (layer) => childrenAsTextareaFieldOverrides(layer),\n\t\t\tonClick: () => functionPropFieldOverrides(\"onClick\"),\n\t\t},\n\t},\n\tp: {\n\t\tschema: z.object({\n\t\t\tclassName: z.string().optional(),\n\t\t\tchildren: z.string().optional(),\n\t\t\tonClick: z.any().optional(),\n\t\t}),\n\t\tdefaultChildren: \"Paragraph text\",\n\t\tfieldOverrides: {\n\t\t\t...commonFieldOverrides(),\n\t\t\tchildren: (layer) => childrenAsTextareaFieldOverrides(layer),\n\t\t\tonClick: () => functionPropFieldOverrides(\"onClick\"),\n\t\t},\n\t},\n\tli: {\n\t\tschema: z.object({\n\t\t\tclassName: z.string().optional(),\n\t\t\tchildren: z.string().optional(),\n\t\t\tonClick: z.any().optional(),\n\t\t}),\n\t\tdefaultChildren: \"List item\",\n\t\tfieldOverrides: {\n\t\t\t...commonFieldOverrides(),\n\t\t\tchildren: (layer) => childrenAsTextareaFieldOverrides(layer),\n\t\t\tonClick: () => functionPropFieldOverrides(\"onClick\"),\n\t\t},\n\t},\n\tul: {\n\t\tschema: z.object({\n\t\t\tclassName: z.string().optional(),\n\t\t\tchildren: z.any().optional(),\n\t\t\tonClick: z.any().optional(),\n\t\t}),\n\t\tfieldOverrides: {\n\t\t\t...commonFieldOverrides(),\n\t\t\tonClick: () => functionPropFieldOverrides(\"onClick\"),\n\t\t},\n\t},\n\tol: {\n\t\tschema: z.object({\n\t\t\tclassName: z.string().optional(),\n\t\t\tchildren: z.any().optional(),\n\t\t\tonClick: z.any().optional(),\n\t\t}),\n\t\tfieldOverrides: {\n\t\t\t...commonFieldOverrides(),\n\t\t\tonClick: () => functionPropFieldOverrides(\"onClick\"),\n\t\t},\n\t},\n};\n\n/**\n * Complex component definitions (shadcn/ui and custom components)\n * These components have React implementations and more complex schemas\n */\nexport const complexComponentDefinitions: ComponentRegistry = {\n\tButton: {\n\t\tcomponent: Button,\n\t\tschema: z.object({\n\t\t\tclassName: z.string().optional(),\n\t\t\tchildren: z.any().optional(),\n\t\t\tasChild: z.boolean().optional(),\n\t\t\tvariant: z\n\t\t\t\t.enum([\n\t\t\t\t\t\"default\",\n\t\t\t\t\t\"destructive\",\n\t\t\t\t\t\"outline\",\n\t\t\t\t\t\"secondary\",\n\t\t\t\t\t\"ghost\",\n\t\t\t\t\t\"link\",\n\t\t\t\t])\n\t\t\t\t.default(\"default\"),\n\t\t\tsize: z.enum([\"default\", \"sm\", \"lg\", \"icon\"]).default(\"default\"),\n\t\t\tonClick: z.any().optional(),\n\t\t}),\n\t\tfrom: \"@/components/ui/button\",\n\t\tdefaultChildren: [\n\t\t\t{\n\t\t\t\tid: \"button-text\",\n\t\t\t\ttype: \"span\",\n\t\t\t\tname: \"span\",\n\t\t\t\tprops: {},\n\t\t\t\tchildren: \"Button\",\n\t\t\t} satisfies ComponentLayer,\n\t\t],\n\t\tfieldOverrides: {\n\t\t\t...commonFieldOverrides(),\n\t\t\tonClick: () => functionPropFieldOverrides(\"onClick\"),\n\t\t},\n\t},\n\tBadge: {\n\t\tcomponent: Badge,\n\t\tschema: z.object({\n\t\t\tclassName: z.string().optional(),\n\t\t\tchildren: z.any().optional(),\n\t\t\tvariant: z\n\t\t\t\t.enum([\"default\", \"secondary\", \"destructive\", \"outline\"])\n\t\t\t\t.default(\"default\"),\n\t\t\tonClick: z.any().optional(),\n\t\t}),\n\t\tfrom: \"@/components/ui/badge\",\n\t\tdefaultChildren: [\n\t\t\t{\n\t\t\t\tid: \"badge-text\",\n\t\t\t\ttype: \"span\",\n\t\t\t\tname: \"span\",\n\t\t\t\tprops: {},\n\t\t\t\tchildren: \"Badge\",\n\t\t\t} satisfies ComponentLayer,\n\t\t],\n\t\tfieldOverrides: {\n\t\t\t...commonFieldOverrides(),\n\t\t\tonClick: () => functionPropFieldOverrides(\"onClick\"),\n\t\t},\n\t},\n\tFlexbox: {\n\t\tcomponent: Flexbox,\n\t\tschema: z.object({\n\t\t\tclassName: z.string().optional(),\n\t\t\tchildren: z.any().optional(),\n\t\t\tdirection: z\n\t\t\t\t.enum([\"row\", \"column\", \"rowReverse\", \"columnReverse\"])\n\t\t\t\t.default(\"row\"),\n\t\t\tjustify: z\n\t\t\t\t.enum([\"start\", \"end\", \"center\", \"between\", \"around\", \"evenly\"])\n\t\t\t\t.default(\"start\"),\n\t\t\talign: z\n\t\t\t\t.enum([\"start\", \"end\", \"center\", \"baseline\", \"stretch\"])\n\t\t\t\t.default(\"start\"),\n\t\t\twrap: z.enum([\"wrap\", \"nowrap\", \"wrapReverse\"]).default(\"nowrap\"),\n\t\t\tgap: z\n\t\t\t\t.preprocess(\n\t\t\t\t\t(val) => (typeof val === \"number\" ? String(val) : val),\n\t\t\t\t\tz.enum([\"0\", \"1\", \"2\", \"4\", \"8\"]).default(\"1\"),\n\t\t\t\t)\n\t\t\t\t.transform(Number),\n\t\t}),\n\t\tfrom: \"@/components/ui/ui-builder/flexbox\",\n\t\tfieldOverrides: commonFieldOverrides(),\n\t},\n\tGrid: {\n\t\tcomponent: Grid,\n\t\tschema: z.object({\n\t\t\tclassName: z.string().optional(),\n\t\t\tchildren: z.any().optional(),\n\t\t\tcolumns: z\n\t\t\t\t.enum([\"auto\", \"1\", \"2\", \"3\", \"4\", \"5\", \"6\", \"7\", \"8\"])\n\t\t\t\t.default(\"1\"),\n\t\t\tautoRows: z.enum([\"none\", \"min\", \"max\", \"fr\"]).default(\"none\"),\n\t\t\tjustify: z\n\t\t\t\t.enum([\"start\", \"end\", \"center\", \"between\", \"around\", \"evenly\"])\n\t\t\t\t.default(\"start\"),\n\t\t\talign: z\n\t\t\t\t.enum([\"start\", \"end\", \"center\", \"baseline\", \"stretch\"])\n\t\t\t\t.default(\"start\"),\n\t\t\ttemplateRows: z\n\t\t\t\t.enum([\"none\", \"1\", \"2\", \"3\", \"4\", \"5\", \"6\"])\n\t\t\t\t.default(\"none\")\n\t\t\t\t.transform((val) => (val === \"none\" ? val : Number(val))),\n\t\t\tgap: z\n\t\t\t\t.preprocess(\n\t\t\t\t\t(val) => (typeof val === \"number\" ? String(val) : val),\n\t\t\t\t\tz.enum([\"0\", \"1\", \"2\", \"4\", \"8\"]).default(\"0\"),\n\t\t\t\t)\n\t\t\t\t.transform(Number),\n\t\t}),\n\t\tfrom: \"@/components/ui/ui-builder/grid\",\n\t\tfieldOverrides: commonFieldOverrides(),\n\t},\n\tCodePanel: {\n\t\tcomponent: CodePanel,\n\t\tschema: z.object({\n\t\t\tclassName: z.string().optional(),\n\t\t}),\n\t\tfrom: \"@/components/ui/ui-builder/code-panel\",\n\t\tfieldOverrides: {\n\t\t\tclassName: (layer) => classNameFieldOverrides(layer),\n\t\t},\n\t},\n\tMarkdown: {\n\t\tcomponent: Markdown,\n\t\tschema: z.object({\n\t\t\tclassName: z.string().optional(),\n\t\t\tchildren: z.any().optional(),\n\t\t}),\n\t\tfrom: \"@/components/ui/ui-builder/markdown\",\n\t\tfieldOverrides: {\n\t\t\tclassName: (layer) => classNameFieldOverrides(layer),\n\t\t\tchildren: (layer) => childrenAsTipTapFieldOverrides(layer),\n\t\t},\n\t},\n\tIcon: {\n\t\tcomponent: Icon,\n\t\tschema: z.object({\n\t\t\tclassName: z.string().optional(),\n\t\t\ticonName: z\n\t\t\t\t.enum([...iconNames] as [string, ...string[]])\n\t\t\t\t.default(\"Image\"),\n\t\t\tsize: z.enum([\"small\", \"medium\", \"large\"]).default(\"medium\"),\n\t\t\tcolor: z\n\t\t\t\t.enum([\n\t\t\t\t\t\"accent\",\n\t\t\t\t\t\"accentForeground\",\n\t\t\t\t\t\"primary\",\n\t\t\t\t\t\"primaryForeground\",\n\t\t\t\t\t\"secondary\",\n\t\t\t\t\t\"secondaryForeground\",\n\t\t\t\t\t\"destructive\",\n\t\t\t\t\t\"destructiveForeground\",\n\t\t\t\t\t\"muted\",\n\t\t\t\t\t\"mutedForeground\",\n\t\t\t\t\t\"background\",\n\t\t\t\t\t\"foreground\",\n\t\t\t\t])\n\t\t\t\t.optional(),\n\t\t\trotate: z.enum([\"none\", \"90\", \"180\", \"270\"]).default(\"none\"),\n\t\t}),\n\t\tfrom: \"@/components/ui/ui-builder/icon\",\n\t\tfieldOverrides: {\n\t\t\tclassName: (layer) => classNameFieldOverrides(layer),\n\t\t\ticonName: (layer) => iconNameFieldOverrides(layer),\n\t\t},\n\t},\n\tAccordion: {\n\t\tcomponent: Accordion,\n\t\tschema: z.object({\n\t\t\tclassName: z.string().optional(),\n\t\t\tchildren: z.any().optional(),\n\t\t\ttype: z.enum([\"single\", \"multiple\"]).default(\"single\"),\n\t\t\tcollapsible: z.boolean().optional(),\n\t\t\tonValueChange: z.any().optional(),\n\t\t}),\n\t\tfrom: \"@/components/ui/accordion\",\n\t\tdefaultChildren: [\n\t\t\t{\n\t\t\t\tid: \"acc-item-1\",\n\t\t\t\ttype: \"AccordionItem\",\n\t\t\t\tname: \"AccordionItem\",\n\t\t\t\tprops: { value: \"item-1\" },\n\t\t\t\tchildren: [\n\t\t\t\t\t{\n\t\t\t\t\t\tid: \"acc-trigger-1\",\n\t\t\t\t\t\ttype: \"AccordionTrigger\",\n\t\t\t\t\t\tname: \"AccordionTrigger\",\n\t\t\t\t\t\tprops: {},\n\t\t\t\t\t\tchildren: [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tid: \"acc-trigger-1-text\",\n\t\t\t\t\t\t\t\ttype: \"span\",\n\t\t\t\t\t\t\t\tname: \"span\",\n\t\t\t\t\t\t\t\tprops: {},\n\t\t\t\t\t\t\t\tchildren: \"Accordion Item #1\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t],\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tid: \"acc-content-1\",\n\t\t\t\t\t\ttype: \"AccordionContent\",\n\t\t\t\t\t\tname: \"AccordionContent\",\n\t\t\t\t\t\tprops: {},\n\t\t\t\t\t\tchildren: [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tid: \"acc-content-1-text\",\n\t\t\t\t\t\t\t\ttype: \"span\",\n\t\t\t\t\t\t\t\tname: \"span\",\n\t\t\t\t\t\t\t\tprops: {},\n\t\t\t\t\t\t\t\tchildren: \"Accordion Content Text\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t],\n\t\t\t\t\t},\n\t\t\t\t],\n\t\t\t},\n\t\t\t{\n\t\t\t\tid: \"acc-item-2\",\n\t\t\t\ttype: \"AccordionItem\",\n\t\t\t\tname: \"AccordionItem\",\n\t\t\t\tprops: { value: \"item-2\" },\n\t\t\t\tchildren: [\n\t\t\t\t\t{\n\t\t\t\t\t\tid: \"acc-trigger-2\",\n\t\t\t\t\t\ttype: \"AccordionTrigger\",\n\t\t\t\t\t\tname: \"AccordionTrigger\",\n\t\t\t\t\t\tprops: {},\n\t\t\t\t\t\tchildren: [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tid: \"acc-trigger-2-text\",\n\t\t\t\t\t\t\t\ttype: \"span\",\n\t\t\t\t\t\t\t\tname: \"span\",\n\t\t\t\t\t\t\t\tprops: {},\n\t\t\t\t\t\t\t\tchildren: \"Accordion Item #2\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t],\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tid: \"acc-content-2\",\n\t\t\t\t\t\ttype: \"AccordionContent\",\n\t\t\t\t\t\tname: \"AccordionContent\",\n\t\t\t\t\t\tprops: {},\n\t\t\t\t\t\tchildren: [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tid: \"acc-content-2-text\",\n\t\t\t\t\t\t\t\ttype: \"span\",\n\t\t\t\t\t\t\t\tname: \"span\",\n\t\t\t\t\t\t\t\tprops: {},\n\t\t\t\t\t\t\t\tchildren: \"Accordion Content Text\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t],\n\t\t\t\t\t},\n\t\t\t\t],\n\t\t\t},\n\t\t] as ComponentLayer[],\n\t\tfieldOverrides: {\n\t\t\t...commonFieldOverrides(),\n\t\t\tonValueChange: () => functionPropFieldOverrides(\"onValueChange\"),\n\t\t},\n\t},\n\tAccordionItem: {\n\t\tcomponent: AccordionItem,\n\t\tschema: z.object({\n\t\t\tclassName: z.string().optional(),\n\t\t\tchildren: z.any().optional(),\n\t\t\tvalue: z.string().default(\"item-1\"),\n\t\t}),\n\t\tfrom: \"@/components/ui/accordion\",\n\t\tchildOf: [\"Accordion\"],\n\t\tdefaultChildren: [\n\t\t\t{\n\t\t\t\tid: \"acc-trigger-default\",\n\t\t\t\ttype: \"AccordionTrigger\",\n\t\t\t\tname: \"AccordionTrigger\",\n\t\t\t\tprops: {},\n\t\t\t\tchildren: [\n\t\t\t\t\t{\n\t\t\t\t\t\tid: \"acc-trigger-default-text\",\n\t\t\t\t\t\ttype: \"span\",\n\t\t\t\t\t\tname: \"span\",\n\t\t\t\t\t\tprops: {},\n\t\t\t\t\t\tchildren: \"Accordion Item\",\n\t\t\t\t\t} satisfies ComponentLayer,\n\t\t\t\t],\n\t\t\t},\n\t\t\t{\n\t\t\t\tid: \"acc-content-default\",\n\t\t\t\ttype: \"AccordionContent\",\n\t\t\t\tname: \"AccordionContent\",\n\t\t\t\tprops: {},\n\t\t\t\tchildren: [\n\t\t\t\t\t{\n\t\t\t\t\t\tid: \"acc-content-default-text\",\n\t\t\t\t\t\ttype: \"span\",\n\t\t\t\t\t\tname: \"span\",\n\t\t\t\t\t\tprops: {},\n\t\t\t\t\t\tchildren: \"Accordion Content\",\n\t\t\t\t\t} satisfies ComponentLayer,\n\t\t\t\t],\n\t\t\t},\n\t\t] as ComponentLayer[],\n\t\tfieldOverrides: commonFieldOverrides(),\n\t},\n\tAccordionTrigger: {\n\t\tcomponent: AccordionTrigger,\n\t\tschema: z.object({\n\t\t\tclassName: z.string().optional(),\n\t\t\tchildren: z.any().optional(),\n\t\t\tonClick: z.any().optional(),\n\t\t}),\n\t\tfrom: \"@/components/ui/accordion\",\n\t\tchildOf: [\"AccordionItem\"],\n\t\tfieldOverrides: {\n\t\t\tclassName: (layer) => classNameFieldOverrides(layer),\n\t\t\tchildren: (layer) => childrenFieldOverrides(layer),\n\t\t\tonClick: () => functionPropFieldOverrides(\"onClick\"),\n\t\t},\n\t},\n\tAccordionContent: {\n\t\tcomponent: AccordionContent,\n\t\tschema: z.object({\n\t\t\tclassName: z.string().optional(),\n\t\t\tchildren: z.any().optional(),\n\t\t}),\n\t\tfrom: \"@/components/ui/accordion\",\n\t\tchildOf: [\"AccordionItem\"],\n\t\tfieldOverrides: commonFieldOverrides(),\n\t},\n\tCard: {\n\t\tcomponent: Card,\n\t\tschema: z.object({\n\t\t\tclassName: z.string().optional(),\n\t\t\tchildren: z.any().optional(),\n\t\t\tonClick: z.any().optional(),\n\t\t}),\n\t\tfrom: \"@/components/ui/card\",\n\t\tdefaultChildren: [\n\t\t\t{\n\t\t\t\tid: \"card-header\",\n\t\t\t\ttype: \"CardHeader\",\n\t\t\t\tname: \"CardHeader\",\n\t\t\t\tprops: {},\n\t\t\t\tchildren: [\n\t\t\t\t\t{\n\t\t\t\t\t\tid: \"card-title\",\n\t\t\t\t\t\ttype: \"CardTitle\",\n\t\t\t\t\t\tname: \"CardTitle\",\n\t\t\t\t\t\tprops: {},\n\t\t\t\t\t\tchildren: [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tid: \"card-title-text\",\n\t\t\t\t\t\t\t\ttype: \"span\",\n\t\t\t\t\t\t\t\tname: \"span\",\n\t\t\t\t\t\t\t\tprops: {},\n\t\t\t\t\t\t\t\tchildren: \"Card Title\",\n\t\t\t\t\t\t\t} satisfies ComponentLayer,\n\t\t\t\t\t\t],\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tid: \"card-description\",\n\t\t\t\t\t\ttype: \"CardDescription\",\n\t\t\t\t\t\tname: \"CardDescription\",\n\t\t\t\t\t\tprops: {},\n\t\t\t\t\t\tchildren: [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tid: \"card-description-text\",\n\t\t\t\t\t\t\t\ttype: \"span\",\n\t\t\t\t\t\t\t\tname: \"span\",\n\t\t\t\t\t\t\t\tprops: {},\n\t\t\t\t\t\t\t\tchildren: \"Card Description\",\n\t\t\t\t\t\t\t} satisfies ComponentLayer,\n\t\t\t\t\t\t],\n\t\t\t\t\t},\n\t\t\t\t],\n\t\t\t},\n\t\t\t{\n\t\t\t\tid: \"card-content\",\n\t\t\t\ttype: \"CardContent\",\n\t\t\t\tname: \"CardContent\",\n\t\t\t\tprops: {},\n\t\t\t\tchildren: [\n\t\t\t\t\t{\n\t\t\t\t\t\tid: \"card-content-text\",\n\t\t\t\t\t\ttype: \"span\",\n\t\t\t\t\t\tname: \"span\",\n\t\t\t\t\t\tprops: {},\n\t\t\t\t\t\tchildren: \"Card Content\",\n\t\t\t\t\t} satisfies ComponentLayer,\n\t\t\t\t],\n\t\t\t},\n\t\t\t{\n\t\t\t\tid: \"card-footer\",\n\t\t\t\ttype: \"CardFooter\",\n\t\t\t\tname: \"CardFooter\",\n\t\t\t\tprops: {},\n\t\t\t\tchildren: [\n\t\t\t\t\t{\n\t\t\t\t\t\tid: \"card-footer-text\",\n\t\t\t\t\t\ttype: \"span\",\n\t\t\t\t\t\tname: \"span\",\n\t\t\t\t\t\tprops: {},\n\t\t\t\t\t\tchildren: \"Card Footer\",\n\t\t\t\t\t} satisfies ComponentLayer,\n\t\t\t\t],\n\t\t\t},\n\t\t] as ComponentLayer[],\n\t\tfieldOverrides: {\n\t\t\t...commonFieldOverrides(),\n\t\t\tonClick: () => functionPropFieldOverrides(\"onClick\"),\n\t\t},\n\t},\n\tCardHeader: {\n\t\tcomponent: CardHeader,\n\t\tschema: z.object({\n\t\t\tclassName: z.string().optional(),\n\t\t\tchildren: z.any().optional(),\n\t\t}),\n\t\tfrom: \"@/components/ui/card\",\n\t\tchildOf: [\"Card\"],\n\t\tfieldOverrides: commonFieldOverrides(),\n\t},\n\tCardTitle: {\n\t\tcomponent: CardTitle,\n\t\tschema: z.object({\n\t\t\tclassName: z.string().optional(),\n\t\t\tchildren: z.any().optional(),\n\t\t}),\n\t\tfrom: \"@/components/ui/card\",\n\t\tchildOf: [\"CardHeader\"],\n\t\tfieldOverrides: commonFieldOverrides(),\n\t},\n\tCardDescription: {\n\t\tcomponent: CardDescription,\n\t\tschema: z.object({\n\t\t\tclassName: z.string().optional(),\n\t\t\tchildren: z.any().optional(),\n\t\t}),\n\t\tfrom: \"@/components/ui/card\",\n\t\tchildOf: [\"CardHeader\"],\n\t\tfieldOverrides: commonFieldOverrides(),\n\t},\n\tCardContent: {\n\t\tcomponent: CardContent,\n\t\tschema: z.object({\n\t\t\tclassName: z.string().optional(),\n\t\t\tchildren: z.any().optional(),\n\t\t}),\n\t\tfrom: \"@/components/ui/card\",\n\t\tchildOf: [\"Card\"],\n\t\tfieldOverrides: commonFieldOverrides(),\n\t},\n\tCardFooter: {\n\t\tcomponent: CardFooter,\n\t\tschema: z.object({\n\t\t\tclassName: z.string().optional(),\n\t\t\tchildren: z.any().optional(),\n\t\t}),\n\t\tfrom: \"@/components/ui/card\",\n\t\tchildOf: [\"Card\"],\n\t\tfieldOverrides: commonFieldOverrides(),\n\t},\n\tSeparator: {\n\t\tcomponent: Separator,\n\t\tschema: z.object({\n\t\t\tclassName: z.string().optional(),\n\t\t\torientation: z.enum([\"horizontal\", \"vertical\"]).default(\"horizontal\"),\n\t\t\tdecorative: z.boolean().default(true),\n\t\t}),\n\t\tfrom: \"@/components/ui/separator\",\n\t\tfieldOverrides: {\n\t\t\tclassName: (layer) => classNameFieldOverrides(layer),\n\t\t},\n\t},\n};\n\n/**\n * Default component registry for the UI Builder\n *\n * Includes:\n * - Primitive HTML elements (div, span, h1-h6, p, a, img, iframe)\n * - Layout components (Flexbox, Grid)\n * - Content components (Markdown, Icon, CodePanel)\n * - shadcn/ui components (Button, Badge, Card, Accordion, Separator)\n *\n * @example\n * ```typescript\n * import { defaultComponentRegistry } from \"@btst/stack/plugins/ui-builder/client\"\n *\n * // Use as-is\n * uiBuilderClientPlugin({\n * componentRegistry: defaultComponentRegistry,\n * // ...\n * })\n *\n * // Or extend with custom components\n * import { createComponentRegistry } from \"@btst/stack/plugins/ui-builder/client\"\n *\n * const customRegistry = createComponentRegistry({\n * ...defaultComponentRegistry,\n * MyComponent: {\n * component: MyComponent,\n * schema: myComponentSchema,\n * },\n * })\n * ```\n */\nexport const defaultComponentRegistry: ComponentRegistry = {\n\t...primitiveComponentDefinitions,\n\t...complexComponentDefinitions,\n};\n\n/**\n * Helper to create a custom component registry\n *\n * This is a simple passthrough function that provides type safety\n * when creating or extending component registries.\n *\n * @example\n * ```typescript\n * // Extend the default registry\n * const customRegistry = createComponentRegistry({\n * ...defaultComponentRegistry,\n * CustomButton: {\n * component: CustomButton,\n * schema: z.object({\n * label: z.string(),\n * variant: z.enum(['primary', 'secondary']),\n * }),\n * },\n * })\n *\n * // Create a minimal registry\n * const minimalRegistry = createComponentRegistry({\n * div: primitiveComponentDefinitions.div,\n * span: primitiveComponentDefinitions.span,\n * Button: complexComponentDefinitions.Button,\n * })\n * ```\n */\nexport function createComponentRegistry(\n\tcomponents: ComponentRegistry,\n): ComponentRegistry {\n\treturn components;\n}\n", "target": "src/components/btst/ui-builder/client/registry.ts" }, - { - "path": "ui/components/ui-builder/types.ts", - "type": "registry:component", - "content": "import { type ZodObject, type ZodSchema, type ZodTuple } from \"zod\";\nimport { type ComponentType as ReactComponentType, type ReactNode } from 'react';\nimport {\n type FieldConfigItem,\n } from \"@/components/ui/auto-form/types\";\n\nexport type {\n AutoFormInputComponentProps,\n FieldConfigItem,\n } from \"@/components/ui/auto-form/types\";\n\n// Enhanced prop value types that can accommodate React props, variables, and common data types\nexport type PropValue = \n | ReactNode \n | VariableReference \n | Record \n | any[] \n | string \n | number \n | boolean \n | null \n | undefined;\n\n// Generic component props that allow for flexible but safer typing\nexport type ComponentProps = Record> = TProps;\n\n// Enhanced ComponentLayer with generic prop typing\n// Children can be:\n// - ComponentLayer[] for nested components\n// - string for text content\n// - VariableReference for dynamic text content bound to a variable\nexport interface ComponentLayer = Record> {\n id: string;\n name?: string;\n type: string;\n props: ComponentProps;\n children: ComponentLayer[] | string | VariableReference;\n}\n\n// Variable value types - more specific than before\n// 'function' type variables reference a key in the FunctionRegistry\nexport type VariableValueType = 'string' | 'number' | 'boolean' | 'function';\n\n// Type-safe variable values based on their type\n// For function type, the value is the FunctionRegistry key\nexport type VariableValue = \n T extends 'string' ? string :\n T extends 'number' ? number :\n T extends 'boolean' ? boolean :\n T extends 'function' ? string : // References functionRegistry key\n never;\n\n// Enhanced Variable interface with generic typing\nexport interface Variable {\n id: string;\n name: string;\n type: T;\n defaultValue: VariableValue;\n}\n\n// Variable reference marker for props\nexport interface VariableReference {\n __variableRef: string;\n}\n\n// Default variable binding configuration\nexport interface DefaultVariableBinding {\n propName: string;\n variableId: string;\n immutable?: boolean;\n}\n\n// Enhanced registry entry with better component typing\nexport interface RegistryEntry> {\n component?: T;\n schema: ZodObject | ZodSchema;\n from?: string;\n isFromDefaultExport?: boolean;\n defaultChildren?: ComponentLayer[] | string | VariableReference;\n defaultVariableBindings?: DefaultVariableBinding[];\n fieldOverrides?: Record;\n /** \n * If defined, this component can only be added as a child of the specified parent types.\n * Used to filter component options in the add popover and validate drag-and-drop.\n * Example: TabsTrigger has childOf: [\"TabsList\"]\n */\n childOf?: string[];\n}\n\n// Improved field config function type\nexport type FieldConfigFunction = (layer: ComponentLayer, allowVariableBinding?: boolean) => FieldConfigItem;\n\n// Enhanced ComponentRegistry with better typing\nexport type ComponentRegistry = Record>>;\n\n// Type-safe layer change handler with registry awareness\nexport type LayerChangeHandler = \n (layers: Array) => void;\n\n// Type-safe variable change handler \nexport type VariableChangeHandler = (variables: Variable[]) => void;\n\n// Helper types for extracting component props from registry\nexport type ExtractComponentProps<\n TRegistry extends ComponentRegistry,\n TComponentName extends keyof TRegistry\n> = TRegistry[TComponentName] extends RegistryEntry>\n ? TProps\n : never;\n\n// Type-safe layer change handler with registry awareness\nexport type TypedLayerChangeHandler = \n (layers: Array) => void;\n\n// Utility function types for creating variables\nexport type CreateVariable = (\n id: string,\n name: string,\n type: T,\n defaultValue: VariableValue\n) => Variable;\n\n// Utility to check if a value is a variable reference\nexport function isVariableReference(value: any): value is VariableReference {\n return typeof value === 'object' && value !== null && '__variableRef' in value;\n}\n\n// Type-safe variable creation helper\nexport const createVariable: CreateVariable = (\n id: string,\n name: string,\n type: T,\n defaultValue: VariableValue\n): Variable => ({\n id,\n name,\n type,\n defaultValue,\n});\n\n/**\n * Block definition for UI Builder.\n * Blocks are pre-built component compositions that can be inserted as templates.\n */\nexport interface BlockDefinition {\n /** Unique block name, e.g., \"login-01\" */\n name: string;\n /** Block category for grouping in UI, e.g., \"login\", \"sidebar\", \"chart\" */\n category: string;\n /** Human-readable description */\n description?: string;\n /** The ComponentLayer tree to insert when this block is selected */\n template: ComponentLayer;\n /** Optional preview image URL */\n thumbnail?: string;\n /** Required shadcn components for this block */\n requiredComponents?: string[];\n}\n\n/**\n * Block registry type - a record of block name to block definition\n */\nexport type BlockRegistry = Record;\n\n/**\n * Function definition for the function registry.\n * Describes a function that can be bound to component event handlers.\n */\nexport interface FunctionDefinition {\n /** Human-readable name for the function */\n name: string;\n /** Zod schema describing the function parameters (use z.tuple for ordered args, z.object for named params) */\n schema: ZodTuple | ZodObject | ZodSchema;\n /** The actual function to call at runtime */\n fn: (...args: any[]) => any;\n /** Optional description shown in the UI */\n description?: string;\n /** \n * Optional TypeScript type signature for code generation.\n * Use this when the Zod schema uses z.custom() or other types \n * that can't be automatically inferred at runtime.\n * @example \"(e: React.FormEvent) => void\"\n * @example \"(data: { name: string; email: string }) => Promise\"\n */\n typeSignature?: string;\n}\n\n/**\n * Function registry type - a record of function ID to function definition.\n * Used to provide callable functions that can be bound to component event handlers.\n */\nexport type FunctionRegistry = Record;\n\n\n", - "target": "src/components/ui/ui-builder/types.ts" - }, - { - "path": "ui/components/ui-builder/layer-renderer.tsx", - "type": "registry:component", - "content": "import React from \"react\";\n\nimport { type EditorConfig, RenderLayer } from \"@/components/ui/ui-builder/internal/utils/render-utils\";\nimport { DevProfiler } from \"@/components/ui/ui-builder/internal/components/dev-profiler\";\n\nimport type { Variable, ComponentLayer, ComponentRegistry, PropValue, FunctionRegistry } from '@/components/ui/ui-builder/types';\n\ninterface LayerRendererProps {\n className?: string;\n page: ComponentLayer;\n editorConfig?: EditorConfig;\n componentRegistry: TRegistry;\n /** Optional variable definitions */\n variables?: Variable[];\n /** Optional variable values to override defaults */\n variableValues?: Record;\n /** Optional function registry for resolving function-type variables */\n functionRegistry?: FunctionRegistry;\n}\n\nconst LayerRenderer = React.memo(function LayerRenderer({\n className,\n page,\n editorConfig,\n componentRegistry,\n variables,\n variableValues,\n functionRegistry,\n}) {\n\n return (\n \n
\n \n
\n
\n );\n}) as (\n props: LayerRendererProps\n) => React.JSX.Element;\n\nexport default LayerRenderer;\n\n", - "target": "src/components/ui/ui-builder/layer-renderer.tsx" - }, { "path": "ui/components/page-wrapper.tsx", "type": "registry:component", "content": "\"use client\";\n\nimport { PageLayout } from \"./page-layout\";\nimport { StackAttribution } from \"./stack-attribution\";\n\nexport interface PageWrapperProps {\n\tchildren: React.ReactNode;\n\tclassName?: string;\n\ttestId?: string;\n\t/**\n\t * Whether to show the \"Powered by BTST\" attribution.\n\t * Defaults to true.\n\t */\n\tshowAttribution?: boolean;\n}\n\n/**\n * Shared page wrapper component providing consistent layout and optional attribution\n * for plugin pages. Used by blog, CMS, and other plugins.\n *\n * @example\n * ```tsx\n * \n *
\n *

My Page

\n *
\n *
\n * ```\n */\nexport function PageWrapper({\n\tchildren,\n\tclassName,\n\ttestId,\n\tshowAttribution = true,\n}: PageWrapperProps) {\n\treturn (\n\t\t<>\n\t\t\t\n\t\t\t\t{children}\n\t\t\t\n\n\t\t\t{showAttribution && }\n\t\t\n\t);\n}\n", "target": "src/components/ui/page-wrapper.tsx" - }, - { - "path": "ui/components/ui-builder/components/flexbox.tsx", - "type": "registry:component", - "content": "import * as React from \"react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst flexboxVariants = cva(\n \"flex\",\n {\n variants: {\n direction: {\n row: \"flex-row\",\n column: \"flex-col\",\n rowReverse: \"flex-row-reverse\",\n columnReverse: \"flex-col-reverse\",\n },\n justify: {\n start: \"justify-start\",\n end: \"justify-end\",\n center: \"justify-center\",\n between: \"justify-between\",\n around: \"justify-around\",\n evenly: \"justify-evenly\",\n },\n align: {\n start: \"items-start\",\n end: \"items-end\",\n center: \"items-center\",\n baseline: \"items-baseline\",\n stretch: \"items-stretch\",\n },\n wrap: {\n nowrap: \"flex-nowrap\",\n wrap: \"flex-wrap\",\n wrapReverse: \"flex-wrap-reverse\",\n },\n gap: {\n 0: \"gap-0\",\n 1: \"gap-1\",\n 2: \"gap-2\",\n 4: \"gap-4\",\n 8: \"gap-8\",\n },\n },\n defaultVariants: {\n direction: \"row\",\n justify: \"start\",\n align: \"start\",\n wrap: \"nowrap\",\n gap: 0,\n },\n }\n)\n\nexport interface FlexboxProps\n extends React.HTMLAttributes,\n VariantProps {}\n\nconst Flexbox = React.forwardRef(\n ({ className, direction, justify, align, wrap, gap, ...props }, ref) => {\n return (\n \n )\n }\n)\nFlexbox.displayName = \"Flexbox\"\n\nexport { Flexbox, flexboxVariants }", - "target": "src/components/ui/ui-builder/components/flexbox.tsx" - }, - { - "path": "ui/components/ui-builder/components/grid.tsx", - "type": "registry:component", - "content": "import * as React from \"react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst gridVariants = cva(\n \"grid\",\n {\n variants: {\n columns: {\n 1: \"grid-cols-1\",\n 2: \"grid-cols-2\",\n 3: \"grid-cols-3\",\n 4: \"grid-cols-4\",\n 5: \"grid-cols-5\",\n 6: \"grid-cols-6\",\n 7: \"grid-cols-7\",\n 8: \"grid-cols-8\",\n auto: \"grid-cols-auto\",\n },\n autoRows: {\n none: \"auto-rows-none\",\n min: \"auto-rows-min\",\n max: \"auto-rows-max\",\n fr: \"auto-rows-fr\",\n },\n justify: {\n start: \"justify-start\",\n end: \"justify-end\",\n center: \"justify-center\",\n between: \"justify-between\",\n around: \"justify-around\",\n evenly: \"justify-evenly\",\n },\n align: {\n start: \"items-start\",\n end: \"items-end\",\n center: \"items-center\",\n baseline: \"items-baseline\",\n stretch: \"items-stretch\",\n },\n gap: {\n 0: \"gap-0\",\n 1: \"gap-1\",\n 2: \"gap-2\",\n 4: \"gap-4\",\n 8: \"gap-8\",\n },\n templateRows: {\n none: \"grid-rows-none\",\n 1: \"grid-rows-1\",\n 2: \"grid-rows-2\",\n 3: \"grid-rows-3\",\n 4: \"grid-rows-4\",\n 5: \"grid-rows-5\",\n 6: \"grid-rows-6\",\n },\n },\n defaultVariants: {\n columns: 1,\n autoRows: \"none\",\n justify: \"start\",\n align: \"start\",\n gap: 0,\n templateRows: \"none\",\n },\n }\n)\n\nexport interface GridProps\n extends React.HTMLAttributes,\n VariantProps {}\n\nconst Grid = React.forwardRef(\n ({ className, columns, autoRows, justify, align, gap, templateRows, ...props }, ref) => {\n return (\n \n )\n }\n)\nGrid.displayName = \"Grid\"\n\nexport { Grid, gridVariants }", - "target": "src/components/ui/ui-builder/components/grid.tsx" - }, - { - "path": "ui/components/ui-builder/components/code-panel.tsx", - "type": "registry:component", - "content": "import { Tabs, TabsContent, TabsList, TabsTrigger } from \"@/components/ui/tabs\";\nimport {\n useLayerStore,\n} from \"@/lib/ui-builder/store/layer-store\";\nimport type { ComponentLayer } from '../types';\nimport { useEditorStore } from \"@/lib/ui-builder/store/editor-store\";\nimport { pageLayerToCode } from \"@/components/ui/ui-builder/internal/utils/templates\";\nimport { CodeBlock } from \"@/components/ui/ui-builder/components/codeblock\";\nimport { cn } from \"@/lib/utils\";\nimport { useMemo } from \"react\";\nimport { Label } from \"../../label\";\n\n\nexport function CodePanel({className}: {className?: string}) {\n const componentRegistry = useEditorStore((state) => state.registry);\n const functionRegistry = useEditorStore((state) => state.functionRegistry);\n const selectedPageId = useLayerStore( state => state.selectedPageId);\n const findLayerById = useLayerStore( state => state.findLayerById);\n const variables = useLayerStore( state => state.variables);\n\n const page = findLayerById(selectedPageId) as ComponentLayer;\n const codeBlocks = useMemo(() => {\n // Create separate serialized data for variables and layers\n const serializedVariables = variables.map(v => ({\n id: v.id,\n name: v.name,\n type: v.type,\n defaultValue: v.defaultValue\n }));\n\n return {\n react: pageLayerToCode(page, componentRegistry, variables, functionRegistry),\n variables: JSON.stringify(\n serializedVariables,\n (key, value) => (typeof value === \"function\" ? undefined : value),\n 2\n ),\n layers: JSON.stringify(\n page,\n (key, value) => (typeof value === \"function\" ? undefined : value),\n 2\n ),\n };\n }, [page, componentRegistry, variables, functionRegistry]);\n\n return ;\n}\n\nconst CodeContent = ({\n codeBlocks,\n className,\n}: {\n codeBlocks: Record<\"react\" | \"variables\" | \"layers\", string>;\n className?: string;\n}) => {\n return (\n \n \n React\n Serialized\n \n \n
\n
\n \n
\n
\n
\n \n
\n {codeBlocks.variables!=='[]' && (\n
\n \n
\n \n
\n
\n )}\n
\n \n
\n \n
\n
\n
\n
\n
\n );\n};\n", - "target": "src/components/ui/ui-builder/components/code-panel.tsx" - }, - { - "path": "ui/components/ui-builder/components/markdown.tsx", - "type": "registry:component", - "content": "\"use client\";\n\nimport React, { type FC, memo, useMemo } from \"react\";\nimport ReactMarkdown, { type Components, type Options } from \"react-markdown\";\nimport remarkGfm from \"remark-gfm\";\nimport remarkMath from \"remark-math\";\nimport { CodeBlock } from \"@/components/ui/ui-builder/components/codeblock\";\nimport { cn } from \"@/lib/utils\";\n\ninterface MarkdownProps {\n className?: string;\n children: string;\n}\nexport function Markdown({ children, className }: MarkdownProps) {\n const components = useMemo(() => {\n return {\n a({\n children,\n href,\n className,\n }: {\n children: React.ReactNode;\n href: string;\n className: string;\n }) {\n return (\n \n {children}\n \n );\n },\n img({\n src,\n alt,\n className,\n }: {\n src: string;\n alt: string;\n className: string;\n }) {\n return (\n {alt}\n );\n },\n code({ className, children, ...props }: { className: string; children: React.ReactNode; [key: string]: any }) {\n const match = /language-(\\w+)/.exec(className || \"\");\n\n if (match) {\n return (\n \n );\n }\n\n return (\n \n {children}\n \n );\n },\n };\n }, []);\n\n const remarkPlugins = useMemo(() => {\n return [remarkGfm, remarkMath];\n }, []);\n\n return (\n \n {children}\n \n );\n}\n\nconst MemoizedReactMarkdown: FC = memo(\n ReactMarkdown,\n (prevProps, nextProps) =>\n prevProps.children === nextProps.children &&\n prevProps.className === nextProps.className\n);\n", - "target": "src/components/ui/ui-builder/components/markdown.tsx" - }, - { - "path": "ui/components/ui-builder/components/icon.tsx", - "type": "registry:component", - "content": "import * as React from \"react\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport * as LucideIcons from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\nexport type LucideIconName = keyof typeof LucideIcons;\ntype LucideIconComponent = React.ComponentType>;\n\nconst iconVariants = cva(\"inline-flex\", {\n variants: {\n size: {\n small: \"h-4 w-4\",\n medium: \"h-6 w-6\",\n large: \"h-8 w-8\",\n },\n color: {\n accent: \"text-accent\",\n accentForeground: \"text-accent-foreground\",\n primary: \"text-primary\",\n primaryForeground: \"text-primary-foreground\",\n secondary: \"text-secondary\",\n secondaryForeground: \"text-secondary-foreground\",\n destructive: \"text-destructive\",\n destructiveForeground: \"text-destructive-foreground\",\n muted: \"text-muted\",\n mutedForeground: \"text-muted-foreground\",\n background: \"text-background\",\n foreground: \"text-foreground\",\n },\n rotate: {\n none: \"rotate-0\",\n \"90\": \"rotate-90\",\n \"180\": \"rotate-180\",\n \"270\": \"rotate-270\",\n },\n },\n defaultVariants: {\n size: \"medium\",\n color: \"primary\",\n rotate: \"none\",\n },\n});\n\nexport interface IconProps\n extends Omit, \"color\">,\n VariantProps {\n iconName: LucideIconName;\n}\n\nconst Icon = React.forwardRef(\n ({ className, iconName, size, color, rotate, ...props }, ref) => {\n const IconComponent = LucideIcons[iconName] as LucideIconComponent;\n\n if (!IconComponent) {\n console.error(`Icon \"${iconName}\" does not exist in lucide-react`);\n return null;\n }\n\n return (\n \n );\n }\n);\n\nIcon.displayName = \"Icon\";\n\nexport const iconNames = Object.keys(LucideIcons)\n .filter((key) => (!key.startsWith(\"Lucide\") && !key.endsWith(\"Icon\")) && key !== \"icons\")\n .map((key) => key as LucideIconName) as [LucideIconName, ...LucideIconName[]];\n\nexport { Icon, iconVariants };\n", - "target": "src/components/ui/ui-builder/components/icon.tsx" - }, - { - "path": "ui/lib/ui-builder/store/layer-store.ts", - "type": "registry:lib", - "content": "import { create, type StateCreator } from 'zustand';\nimport { persist, createJSONStorage } from 'zustand/middleware'\nimport { produce } from 'immer';\nimport { temporal } from 'zundo';\nimport isDeepEqual from 'fast-deep-equal';\n\nimport { visitLayer, addLayer, hasLayerChildren, findLayerRecursive, createId, countLayers, duplicateWithNewIdsAndName, findAllParentLayersRecursive, migrateV1ToV2, migrateV2ToV3, migrateV5ToV6, createComponentLayer, moveLayer } from '@/lib/ui-builder/store/layer-utils';\nimport { getDefaultProps } from '@/lib/ui-builder/store/schema-utils';\nimport { useEditorStore } from '@/lib/ui-builder/store/editor-store';\nimport type { ComponentLayer, Variable, PropValue, VariableValueType } from '@workspace/ui/components/ui-builder/types';\nimport { isVariableReference } from '@workspace/ui/components/ui-builder/types';\n\nconst DEFAULT_PAGE_PROPS = {\n className: \"h-screen p-4 flex flex-col gap-2 bg-background overflow-y-scroll\",\n};\n\nexport interface LayerStore {\n pages: ComponentLayer[];\n selectedLayerId: string | null;\n selectedPageId: string;\n variables: Variable[];\n immutableBindings: Record>; // layerId -> propName -> isImmutable\n initialize: (pages: ComponentLayer[], selectedPageId?: string, selectedLayerId?: string, variables?: Variable[]) => void;\n addComponentLayer: (layerType: string, parentId: string, parentPosition?: number) => void;\n addLayerDirect: (layer: ComponentLayer, parentId: string, parentPosition?: number) => void;\n addPageLayer: (pageId: string) => void;\n duplicateLayer: (layerId: string, parentId?: string) => void;\n removeLayer: (layerId: string) => void;\n updateLayer: (layerId: string, newProps: Record, layerRest?: Partial>) => void;\n moveLayer: (sourceLayerId: string, targetParentId: string, targetPosition: number) => void;\n selectLayer: (layerId: string) => void;\n selectPage: (pageId: string) => void;\n findLayerById: (layerId: string | null) => ComponentLayer | undefined;\n findLayersForPageId: (pageId: string) => ComponentLayer[];\n isLayerAPage: (layerId: string) => boolean;\n\n addVariable: (name: string, type: T, defaultValue: Variable['defaultValue']) => void;\n updateVariable: (variableId: string, updates: Partial>) => void;\n removeVariable: (variableId: string) => void;\n bindPropToVariable: (layerId: string, propName: string, variableId: string) => void;\n unbindPropFromVariable: (layerId: string, propName: string) => void;\n bindChildrenToVariable: (layerId: string, variableId: string) => void;\n unbindChildrenFromVariable: (layerId: string) => void;\n isBindingImmutable: (layerId: string, propName: string) => boolean;\n isChildrenBindingImmutable: (layerId: string) => boolean;\n setImmutableBinding: (layerId: string, propName: string, isImmutable: boolean) => void; // Test helper\n}\n\nconst store: StateCreator = (set, get) => (\n {\n // Default to a single empty page\n pages: [\n {\n id: '1',\n type: 'div',\n name: 'Page 1',\n props: DEFAULT_PAGE_PROPS,\n children: [],\n }\n ],\n\n // Variables available for binding\n variables: [],\n // Track immutable bindings: layerId -> propName -> isImmutable\n immutableBindings: {},\n selectedLayerId: null,\n selectedPageId: '1',\n initialize: (pages: ComponentLayer[], selectedPageId?: string, selectedLayerId?: string, variables?: Variable[]) => {\n set(produce((state: LayerStore) => {\n // Set the basic state\n state.pages = pages;\n state.selectedPageId = selectedPageId || (pages.length > 0 && pages[0] ? pages[0].id : '');\n state.selectedLayerId = selectedLayerId || null;\n state.variables = variables || [];\n \n // Initialize immutable bindings for existing layers\n const { registry } = useEditorStore.getState();\n \n // Helper function to set up immutable bindings for layers\n const setupImmutableBindings = (layer: ComponentLayer) => {\n const componentDef = registry[layer.type];\n const defaultVariableBindings = componentDef?.defaultVariableBindings || [];\n \n // Check each default variable binding to see if this layer has a matching variable reference\n for (const binding of defaultVariableBindings) {\n const propValue = layer.props[binding.propName];\n \n // If the prop has a variable reference and it matches the binding's variable ID\n if (isVariableReference(propValue) && propValue.__variableRef === binding.variableId) {\n // Set up immutable binding if specified\n if (binding.immutable) {\n if (!state.immutableBindings[layer.id]) {\n state.immutableBindings[layer.id] = {};\n }\n const bindings = state.immutableBindings[layer.id];\n if (bindings) {\n bindings[binding.propName] = true;\n }\n }\n }\n }\n \n return layer;\n };\n \n // Process all pages and their layers to set up immutable bindings\n state.pages = state.pages.map(page => \n visitLayer(page, null, setupImmutableBindings)\n );\n }));\n },\n findLayerById: (layerId: string | null) => {\n const { selectedPageId, findLayersForPageId, pages } = get();\n if (!layerId) return undefined;\n if (layerId === selectedPageId) {\n return pages.find(page => page.id === selectedPageId);\n }\n const layers = findLayersForPageId(selectedPageId);\n if (!layers) return undefined;\n return findLayerRecursive(layers, layerId);\n },\n findLayersForPageId: (pageId: string) => {\n const { pages } = get();\n const page = pages.find(page => page.id === pageId);\n if(page && hasLayerChildren(page)) {\n return page?.children || [];\n }\n return [];\n },\n\n isLayerAPage: (layerId: string) => {\n const { pages } = get();\n return pages.some(page => page.id === layerId);\n },\n\n addComponentLayer: (layerType: string, parentId: string, parentPosition?: number) => set(produce((state: LayerStore) => {\n const { registry } = useEditorStore.getState();\n \n // Create the new layer using the utility function\n const newLayer = createComponentLayer(layerType, registry, {\n applyVariableBindings: true,\n variables: state.variables,\n });\n\n // Track immutable bindings for variable bindings\n const registryEntry = registry[layerType];\n const defaultVariableBindings = registryEntry?.defaultVariableBindings || [];\n for (const binding of defaultVariableBindings) {\n const variable = state.variables.find(v => v.id === binding.variableId);\n if (variable && binding.immutable) {\n if (!state.immutableBindings[newLayer.id]) {\n state.immutableBindings[newLayer.id] = {};\n }\n const bindings = state.immutableBindings[newLayer.id];\n if (bindings) {\n bindings[binding.propName] = true;\n }\n }\n }\n\n // Traverse and update the pages to add the new layer\n const updatedPages = addLayer(state.pages, newLayer, parentId, parentPosition);\n // Directly mutate the state instead of returning a new object\n state.pages = updatedPages;\n state.selectedLayerId = newLayer.id;\n })),\n\n addLayerDirect: (layer: ComponentLayer, parentId: string, parentPosition?: number) => set(produce((state: LayerStore) => {\n // Add the pre-built layer directly to the tree (used for blocks)\n const updatedPages = addLayer(state.pages, layer, parentId, parentPosition);\n state.pages = updatedPages;\n state.selectedLayerId = layer.id;\n })),\n\n addPageLayer: (pageName: string) => set(produce((state: LayerStore) => {\n const newPage: ComponentLayer = {\n id: createId(),\n type: 'div',\n name: pageName,\n props: DEFAULT_PAGE_PROPS,\n children: [],\n };\n return {\n pages: [...state.pages, newPage],\n selectedPageId: newPage.id,\n selectedLayerId: newPage.id,\n };\n })),\n\n duplicateLayer: (layerId: string) => set(produce((state: LayerStore) => {\n let layerToDuplicate: ComponentLayer | undefined;\n let parentId: string | undefined;\n let parentPosition: number | undefined;\n\n // Find the layer to duplicate\n state.pages.forEach((page) =>\n visitLayer(page, null, (layer, parent) => {\n if (layer.id === layerId) {\n layerToDuplicate = layer;\n parentId = parent?.id;\n if (parent && hasLayerChildren(parent)) {\n parentPosition = parent.children.indexOf(layer) + 1;\n }\n }\n return layer;\n })\n );\n if (!layerToDuplicate) {\n console.warn(`Layer with ID ${ layerId } not found.`);\n return;\n }\n\n const isNewLayerAPage = state.pages.some(page => page.id === layerId);\n\n const newLayer = duplicateWithNewIdsAndName(layerToDuplicate, true);\n\n if (isNewLayerAPage) {\n return {\n ...state,\n pages: [...state.pages, newLayer],\n selectedPageId: newLayer.id,\n };\n }\n\n //else add it as a child of the parent\n\n const updatedPages = addLayer(state.pages, newLayer, parentId, parentPosition);\n\n // Insert the duplicated layer\n return {\n ...state,\n pages: updatedPages\n };\n })),\n\n removeLayer: (layerId: string) => set(produce((state: LayerStore) => {\n const { selectedLayerId, pages } = get();\n\n let newSelectedLayerId = selectedLayerId;\n\n const isPage = state.pages.some(page => page.id === layerId);\n if (isPage && pages.length > 1) {\n const newPages = state.pages.filter(page => page.id !== layerId);\n const firstPage = newPages[0];\n return {\n ...state,\n pages: newPages,\n selectedPageId: firstPage ? firstPage.id : '',\n };\n }\n\n // Traverse and update the pages to remove the specified layer\n const updatedPages = pages.map((page) =>\n visitLayer(page, null, (layer) => {\n\n if (hasLayerChildren(layer)) {\n\n // Remove the layer by filtering it out from the children\n const updatedChildren = layer.children.filter((child) => child.id !== layerId);\n return { ...layer, children: updatedChildren };\n }\n\n return layer;\n })\n );\n\n if (selectedLayerId === layerId) {\n // If the removed layer was selected, deselect it \n newSelectedLayerId = null;\n }\n return {\n ...state,\n selectedLayerId: newSelectedLayerId,\n pages: updatedPages,\n };\n })),\n\n updateLayer: (layerId: string, newProps: ComponentLayer['props'], layerRest?: Partial>) => set(\n produce((state: LayerStore) => {\n const { selectedPageId, findLayersForPageId, pages } = get();\n\n const pageExists = pages.some(page => page.id === selectedPageId);\n if (!pageExists) {\n console.warn(`No layers found for page ID: ${ selectedPageId }`);\n return state;\n }\n\n if (layerId === selectedPageId) {\n const updatedPages = pages.map(page =>\n page.id === selectedPageId\n ? { ...page, props: { ...page.props, ...newProps }, ...(layerRest || {}) }\n : page\n );\n return { ...state, pages: updatedPages };\n }\n\n const layers = findLayersForPageId(selectedPageId);\n\n\n // Visitor function to update layer properties\n const visitor = (layer: ComponentLayer): ComponentLayer => {\n if (layer.id === layerId) {\n return {\n ...layer,\n ...(layerRest || {}),\n props: { ...layer.props, ...newProps },\n } as ComponentLayer\n }\n return layer;\n };\n\n // Apply the visitor to update layers\n const updatedLayers = layers.map(layer => visitLayer(layer, null, visitor));\n\n const isUnchanged = updatedLayers.every((layer, index) => layer === layers[index]);\n\n if (isUnchanged) {\n console.warn(`Layer with ID ${ layerId } was not found.`);\n return state;\n }\n\n // Update the state with the modified layers\n const updatedPages = state.pages.map(page =>\n page.id === selectedPageId ? { ...page, children: updatedLayers } : page\n );\n\n return { ...state, pages: updatedPages };\n })\n ),\n\n\n selectLayer: (layerId: string) => set(produce((state: LayerStore) => {\n const { selectedPageId, findLayersForPageId } = get();\n const layers = findLayersForPageId(selectedPageId);\n if(selectedPageId === layerId) {\n return {\n selectedLayerId: layerId\n };\n }\n if (!layers) return state;\n const layer = findLayerRecursive(layers, layerId);\n if (layer) {\n return {\n selectedLayerId: layer.id\n };\n }\n return {};\n })),\n\n selectPage: (pageId: string) => set(produce((state: LayerStore) => {\n const page = state.pages.find(page => page.id === pageId);\n if (!page) return state;\n return {\n selectedPageId: pageId\n };\n })),\n\n // Add a new variable\n addVariable: (name, type, defaultValue) => set(produce((state: LayerStore) => {\n state.variables.push({ id: createId(), name, type, defaultValue });\n })),\n\n // Update an existing variable\n updateVariable: (variableId, updates) => set(produce((state: LayerStore) => {\n const v = state.variables.find(v => v.id === variableId);\n if (v) Object.assign(v, updates);\n })),\n\n // Remove a variable\n removeVariable: (variableId) => set(produce((state: LayerStore) => {\n state.variables = state.variables.filter(v => v.id !== variableId);\n\n // Remove any references to the variable in the layers and set default value from schema\n const { registry } = useEditorStore.getState();\n\n // Helper function to clean variable references from props and children\n const cleanVariableReferences = (layer: ComponentLayer): ComponentLayer => {\n const updatedProps = { ...layer.props };\n let hasChanges = false;\n let updatedChildren = layer.children;\n\n // Check each prop for variable references\n Object.entries(updatedProps).forEach(([propName, propValue]) => {\n if (isVariableReference(propValue) && propValue.__variableRef === variableId) {\n // This prop references the variable being removed\n // Get the default value from the schema\n const layerSchema = registry[layer.type]?.schema;\n if (layerSchema && 'shape' in layerSchema && layerSchema.shape && layerSchema.shape[propName]) {\n const defaultProps = getDefaultProps(layerSchema as any);\n updatedProps[propName] = defaultProps[propName];\n hasChanges = true;\n } else {\n // Fallback: remove the prop entirely if no schema default\n delete updatedProps[propName];\n hasChanges = true;\n }\n }\n });\n\n // Check if children is a variable reference to the removed variable\n if (isVariableReference(layer.children) && layer.children.__variableRef === variableId) {\n // Reset children to empty string when the bound variable is removed\n updatedChildren = '';\n hasChanges = true;\n }\n\n if (hasChanges) {\n return { ...layer, props: updatedProps, children: updatedChildren };\n }\n return layer;\n };\n\n // Update all pages and their layers\n state.pages = state.pages.map(page =>\n visitLayer(page, null, cleanVariableReferences)\n );\n })),\n\n // Bind a component prop to a variable reference\n bindPropToVariable: (layerId, propName, variableId) => {\n // Store a special object as prop to indicate binding.\n // Also clear any existing __function_* metadata for this prop so\n // the variable binding takes effect at runtime (resolveVariableReferences\n // gives __function_* metadata strict priority over variable references).\n get().updateLayer(layerId, {\n [propName]: { __variableRef: variableId },\n [`__function_${propName}`]: undefined,\n });\n },\n\n // Unbind a component prop from a variable reference and set default value from schema\n unbindPropFromVariable: (layerId, propName) => {\n // Check if the binding is immutable\n if (get().isBindingImmutable(layerId, propName)) {\n console.warn(`Cannot unbind immutable variable binding for ${propName} on layer ${layerId}`);\n return;\n }\n\n const { registry } = useEditorStore.getState();\n const layer = get().findLayerById(layerId);\n \n if (!layer) {\n console.warn(`Layer with ID ${layerId} not found.`);\n return;\n }\n\n // Get the default value from the schema\n const layerSchema = registry[layer.type]?.schema;\n let defaultValue: any = undefined;\n \n if (layerSchema && 'shape' in layerSchema && layerSchema.shape && layerSchema.shape[propName]) {\n const defaultProps = getDefaultProps(layerSchema as any);\n defaultValue = defaultProps[propName];\n }\n \n // If no default value found in schema, use empty string for string-like props\n if (defaultValue === undefined) {\n defaultValue = \"\";\n }\n\n get().updateLayer(layerId, { [propName]: defaultValue });\n },\n\n // Bind layer children to a variable reference\n bindChildrenToVariable: (layerId, variableId) => {\n get().updateLayer(layerId, {}, { children: { __variableRef: variableId } });\n },\n\n // Unbind layer children from a variable reference and reset to empty string\n unbindChildrenFromVariable: (layerId) => {\n // Check if the children binding is immutable\n if (get().isChildrenBindingImmutable(layerId)) {\n console.warn(`Cannot unbind immutable children variable binding on layer ${layerId}`);\n return;\n }\n\n const layer = get().findLayerById(layerId);\n \n if (!layer) {\n console.warn(`Layer with ID ${layerId} not found.`);\n return;\n }\n\n // Reset children to empty string when unbinding\n get().updateLayer(layerId, {}, { children: '' });\n },\n\n // Check if a binding is immutable\n isBindingImmutable: (layerId: string, propName: string) => {\n const { immutableBindings } = get();\n return immutableBindings[layerId]?.[propName] === true;\n },\n\n // Check if children binding is immutable (uses special key '__children__')\n isChildrenBindingImmutable: (layerId: string) => {\n const { immutableBindings } = get();\n return immutableBindings[layerId]?.['__children__'] === true;\n },\n\n // Test helper\n setImmutableBinding: (layerId: string, propName: string, isImmutable: boolean) => {\n set(produce((state: LayerStore) => {\n if (!state.immutableBindings[layerId]) {\n state.immutableBindings[layerId] = {};\n }\n const bindings = state.immutableBindings[layerId];\n if (bindings) {\n bindings[propName] = isImmutable;\n }\n }));\n },\n\n moveLayer: (sourceLayerId: string, targetParentId: string, targetPosition: number) => {\n set(produce((state: LayerStore) => {\n const updatedPages = moveLayer(state.pages, sourceLayerId, targetParentId, targetPosition);\n state.pages = updatedPages;\n }));\n },\n }\n)\n\n// Custom storage adapter (mimics localStorage API for createJSONStorage)\nconst conditionalLocalStorage = {\n getItem: (name: string): Promise => {\n const { persistLayerStoreConfig } = useEditorStore.getState();\n if (!persistLayerStoreConfig) {\n return Promise.resolve(null);\n }\n const value = localStorage.getItem(name);\n return Promise.resolve(value);\n },\n setItem: (name: string, value: string): Promise => {\n const { persistLayerStoreConfig } = useEditorStore.getState();\n if (!persistLayerStoreConfig) {\n return Promise.resolve();\n }\n localStorage.setItem(name, value);\n return Promise.resolve();\n },\n removeItem: (name: string): Promise => {\n const { persistLayerStoreConfig } = useEditorStore.getState();\n if (!persistLayerStoreConfig) {\n return Promise.resolve();\n }\n localStorage.removeItem(name);\n return Promise.resolve();\n },\n};\n\nconst useLayerStore = create(persist(temporal(store,\n {\n equality: (pastState, currentState) =>\n isDeepEqual(pastState, currentState),\n }\n), {\n name: \"layer-store\",\n version: 6,\n storage: createJSONStorage(() => conditionalLocalStorage),\n migrate: (persistedState: unknown, version: number) => {\n // Chain migrations sequentially - each migration builds on the previous one\n let state = persistedState as LayerStore;\n \n /* istanbul ignore if*/\n if (version < 2) {\n state = migrateV1ToV2(state);\n }\n if (version < 3) {\n state = migrateV2ToV3(state);\n }\n if (version < 4) {\n // New variable support: ensure variables array exists\n state = { ...state, variables: [] as Variable[], immutableBindings: {} };\n }\n if (version < 5) {\n // New immutable bindings support: ensure immutableBindings object exists\n state = { ...state, immutableBindings: {} };\n }\n if (version < 6) {\n // Tailwind v4 migration: clean up old-format theme styles from page layers\n state = migrateV5ToV6(state);\n }\n \n return state;\n }\n}))\n\nexport { useLayerStore, countLayers, findAllParentLayersRecursive };\n", - "target": "src/lib/ui-builder/store/layer-store.ts" - }, - { - "path": "ui/lib/ui-builder/registry/form-field-overrides.tsx", - "type": "registry:lib", - "content": "import React from \"react\";\nimport {\n FormControl,\n FormDescription,\n FormItem,\n FormLabel,\n} from \"@workspace/ui/components/form\";\nimport { ChildrenSearchableSelect } from \"@workspace/ui/components/ui-builder/internal/form-fields/children-searchable-select\";\nimport type {\n AutoFormInputComponentProps,\n ComponentLayer,\n FieldConfigFunction,\n Variable,\n} from \"@workspace/ui/components/ui-builder/types\";\nimport IconNameField from \"@workspace/ui/components/ui-builder/internal/form-fields/iconname-field\";\nimport { Textarea } from \"@workspace/ui/components/textarea\";\nimport { MinimalTiptapEditor } from \"@workspace/ui/components/minimal-tiptap\";\nimport {\n Tooltip,\n TooltipContent,\n TooltipTrigger,\n} from \"@workspace/ui/components/tooltip\";\nimport { useLayerStore } from \"../store/layer-store\";\nimport { isVariableReference } from \"../utils/variable-resolver\";\nimport { Link, LockKeyhole, Unlink } from \"lucide-react\";\nimport { Button } from \"@workspace/ui/components/button\";\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuTrigger,\n} from \"@workspace/ui/components/dropdown-menu\";\nimport { Input } from \"@workspace/ui/components/input\";\nimport { useEditorStore } from \"../store/editor-store\";\nimport { Card, CardContent } from \"@workspace/ui/components/card\";\nimport BreakpointClassNameControl from \"@workspace/ui/components/ui-builder/internal/form-fields/classname-control\";\nimport { Label } from \"@workspace/ui/components/label\";\nimport { Badge } from \"@workspace/ui/components/badge\";\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from \"@workspace/ui/components/select\";\n\nexport const classNameFieldOverrides: FieldConfigFunction = (\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n layer,\n) => {\n return {\n fieldType: ({\n label,\n isRequired,\n field,\n fieldConfigItem,\n }: AutoFormInputComponentProps) => (\n \n \n \n ),\n };\n};\n\n \nexport const childrenFieldOverrides = (\n layer: ComponentLayer,\n allowVariableBinding = true\n) => {\n return {\n renderParent: allowVariableBinding\n ? ({ children }: { children: React.ReactNode }) => (\n \n {children}\n \n )\n : undefined,\n fieldType: ({\n label,\n isRequired,\n fieldConfigItem,\n field,\n fieldProps,\n }: AutoFormInputComponentProps) => (\n \n \n \n ),\n };\n};\n\nexport const iconNameFieldOverrides: FieldConfigFunction = (layer) => {\n return {\n fieldType: ({\n label,\n isRequired,\n field,\n fieldProps,\n }: AutoFormInputComponentProps) => (\n \n ),\n };\n};\n\nexport const childrenAsTextareaFieldOverrides = (\n layer: ComponentLayer,\n allowVariableBinding = true\n) => {\n return {\n renderParent: allowVariableBinding\n ? ({ children }: { children: React.ReactNode }) => (\n \n {children}\n \n )\n : undefined,\n fieldType: ({\n label,\n isRequired,\n fieldConfigItem,\n field,\n fieldProps,\n }: AutoFormInputComponentProps) => (\n \n \n \n ),\n };\n};\n\nexport const childrenAsTipTapFieldOverrides = (\n layer: ComponentLayer,\n allowVariableBinding = true\n) => {\n return {\n renderParent: allowVariableBinding\n ? ({ children }: { children: React.ReactNode }) => (\n \n {children}\n \n )\n : undefined,\n fieldType: ({\n label,\n isRequired,\n fieldConfigItem,\n field,\n fieldProps,\n }: AutoFormInputComponentProps) => (\n \n {\n //if string call field.onChange\n if (typeof content === \"string\") {\n field.onChange(content);\n } else {\n console.warn(\"Tiptap content is not a string\");\n }\n }}\n {...fieldProps}\n />\n \n ),\n };\n};\n\n// Memoized common field overrides to avoid recreating objects\nconst memoizedCommonFieldOverrides = new Map ReturnType>>();\n\nexport const commonFieldOverrides = (allowBinding = true) => {\n if (memoizedCommonFieldOverrides.has(allowBinding)) {\n return memoizedCommonFieldOverrides.get(allowBinding)!;\n }\n \n const overrides = {\n className: (layer: ComponentLayer) => classNameFieldOverrides(layer),\n children: (layer: ComponentLayer) => childrenFieldOverrides(layer, allowBinding),\n };\n \n memoizedCommonFieldOverrides.set(allowBinding, overrides);\n return overrides;\n};\n\nexport const commonVariableRenderParentOverrides = (propName: string) => {\n return {\n renderParent: ({ children }: { children: React.ReactNode }) => (\n {children}\n ),\n };\n};\n\n/**\n * Component for function props that shows a dropdown of all functions\n * from the function registry. Variable binding is handled by the wrapper.\n */\nfunction FunctionPropField({\n propName,\n label,\n isRequired,\n fieldConfigItem,\n}: {\n propName: string;\n label: string;\n isRequired?: boolean;\n fieldConfigItem?: { description?: React.ReactNode };\n}) {\n const selectedLayerId = useLayerStore((state) => state.selectedLayerId);\n const findLayerById = useLayerStore((state) => state.findLayerById);\n const updateLayer = useLayerStore((state) => state.updateLayer);\n const incrementRevision = useEditorStore((state) => state.incrementRevision);\n const functionRegistry = useEditorStore((state) => state.functionRegistry);\n const unbindPropFromVariable = useLayerStore(\n (state) => state.unbindPropFromVariable\n );\n\n const selectedLayer = findLayerById(selectedLayerId);\n\n if (!selectedLayer || !functionRegistry) {\n return (\n \n \n \n );\n }\n\n // Get the function registry entries as array\n const functionEntries = Object.entries(functionRegistry);\n\n // Get the current selected function ID (direct binding only, variable binding handled by wrapper)\n const getCurrentFunctionId = (): string => {\n const directFuncId = selectedLayer.props[`__function_${propName}`];\n if (typeof directFuncId === 'string') {\n return directFuncId;\n }\n return '';\n };\n\n const handleValueChange = (value: string) => {\n if (value === '__none__') {\n // Clear the function\n unbindPropFromVariable(selectedLayer.id, propName);\n // Also remove any direct function binding\n // Note: We must explicitly set values to undefined rather than deleting keys,\n // because updateLayer merges props with { ...layer.props, ...newProps }\n updateLayer(selectedLayer.id, {\n [`__function_${propName}`]: undefined,\n [propName]: undefined,\n });\n incrementRevision();\n return;\n }\n\n // Direct function binding from registry\n const funcDef = functionRegistry[value];\n if (funcDef) {\n // Store the function ID for code generation and store the actual function\n unbindPropFromVariable(selectedLayer.id, propName);\n updateLayer(selectedLayer.id, { \n [propName]: funcDef.fn,\n [`__function_${propName}`]: value,\n });\n incrementRevision();\n }\n };\n\n const currentFunctionId = getCurrentFunctionId();\n\n // Get display text for current selection\n const getDisplayText = () => {\n if (currentFunctionId) {\n const funcDef = functionRegistry[currentFunctionId];\n return funcDef?.name || currentFunctionId;\n }\n return 'Select a function...';\n };\n\n return (\n \n \n \n \n {getDisplayText()}\n \n \n \n {/* Option to clear */}\n \n None (clear)\n \n\n {/* Direct functions from registry */}\n {functionEntries.length > 0 && (\n <>\n {functionEntries.map(([id, funcDef]) => (\n \n
\n {funcDef.name}\n {funcDef.description && (\n \n {funcDef.description}\n \n )}\n
\n
\n ))}\n \n )}\n
\n \n \n );\n}\n\n/**\n * Field override for function props (onClick, onSubmit, etc.)\n * Shows a dropdown to select directly from functionRegistry,\n * with a separate bind button for function-type variables (consistent with other fields).\n */\nexport const functionPropFieldOverrides = (propName: string): ReturnType => {\n return {\n renderParent: ({ children }: { children: React.ReactNode }) => (\n \n {children}\n \n ),\n fieldType: (props: AutoFormInputComponentProps) => (\n \n ),\n };\n};\n\nexport const textInputFieldOverrides = (\n layer: ComponentLayer,\n allowVariableBinding = false,\n propName: string\n) => {\n return {\n renderParent: allowVariableBinding\n ? ({ children }: { children: React.ReactNode }) => (\n \n {children}\n \n )\n : undefined,\n fieldType: ({\n label,\n isRequired,\n fieldConfigItem,\n field,\n fieldProps,\n }: AutoFormInputComponentProps) => (\n \n field.onChange(e.target.value)}\n {...fieldProps}\n />\n \n ),\n };\n};\n\nexport function VariableBindingWrapper({\n propName,\n children,\n isFunctionProp = false,\n}: {\n propName: string;\n children: React.ReactNode;\n /** Set to true when binding to function props (onClick, onSubmit, etc.) */\n isFunctionProp?: boolean;\n}) {\n const variables = useLayerStore((state) => state.variables);\n const selectedLayerId = useLayerStore((state) => state.selectedLayerId);\n const findLayerById = useLayerStore((state) => state.findLayerById);\n const isBindingImmutable = useLayerStore((state) => state.isBindingImmutable);\n const incrementRevision = useEditorStore((state) => state.incrementRevision);\n const functionRegistry = useEditorStore((state) => state.functionRegistry);\n const unbindPropFromVariable = useLayerStore(\n (state) => state.unbindPropFromVariable\n );\n const bindPropToVariable = useLayerStore((state) => state.bindPropToVariable);\n\n const selectedLayer = findLayerById(selectedLayerId);\n\n // If variable binding is not allowed or no propName provided, just render the form wrapper\n if (!selectedLayer) {\n return <>{children};\n }\n\n // Filter variables based on prop type\n // Function props should only show function-type variables\n // Non-function props should show non-function variables\n const filteredVariables = variables.filter((v) => \n isFunctionProp ? v.type === 'function' : v.type !== 'function'\n );\n\n const currentValue = selectedLayer.props[propName];\n const isCurrentlyBound = isVariableReference(currentValue);\n const boundVariable = isCurrentlyBound\n ? variables.find((v) => v.id === currentValue.__variableRef)\n : null;\n const isImmutable = isBindingImmutable(selectedLayer.id, propName);\n\n // Get function display name for function-type variables\n const getFunctionDisplayValue = (variable: Variable) => {\n if (variable.type === 'function' && functionRegistry) {\n const funcId = String(variable.defaultValue);\n const funcDef = functionRegistry[funcId];\n return funcDef ? funcDef.name : funcId;\n }\n return String(variable.defaultValue);\n };\n\n const handleBindToVariable = (variableId: string) => {\n bindPropToVariable(selectedLayer.id, propName, variableId);\n incrementRevision();\n };\n\n const handleUnbind = () => {\n // Use the new unbind function which sets default value from schema\n unbindPropFromVariable(selectedLayer.id, propName);\n incrementRevision();\n };\n\n const emptyMessage = isFunctionProp \n ? \"No function variables defined\" \n : \"No variables defined\";\n\n const bindLabel = isFunctionProp \n ? \"Bind to Function Variable\" \n : \"Bind to Variable\";\n\n const tooltipLabel = isFunctionProp \n ? \"Bind Function\" \n : \"Bind Variable\";\n\n return (\n
\n {isCurrentlyBound && boundVariable ? (\n // Bound state - show variable info and unbind button\n
\n \n
\n \n \n
\n \n
\n
\n {boundVariable.name}\n \n {boundVariable.type}\n \n {isImmutable && (\n \n \n \n )}\n
\n \n {getFunctionDisplayValue(boundVariable)}\n \n
\n
\n
\n
\n {!isImmutable && (\n \n \n \n \n \n \n Unbind Variable\n \n )}\n
\n
\n ) : (\n // Unbound state - show normal field with bind button\n <>\n
{children}
\n
\n \n \n \n \n \n \n \n {tooltipLabel}\n \n \n
\n {bindLabel}\n
\n {filteredVariables.length > 0 ? (\n filteredVariables.map((variable) => (\n handleBindToVariable(variable.id)}\n className=\"flex flex-col items-start p-3\"\n >\n
\n \n
\n
\n {variable.name}\n \n {variable.type}\n \n
\n \n {getFunctionDisplayValue(variable)}\n \n
\n
\n \n ))\n ) : (\n
\n {emptyMessage}\n
\n )}\n
\n
\n
\n \n )}\n
\n );\n}\n\nexport function FormFieldWrapper({\n label,\n isRequired,\n fieldConfigItem,\n children,\n}: {\n label: string;\n isRequired?: boolean;\n fieldConfigItem?: { description?: React.ReactNode };\n children: React.ReactNode;\n}) {\n return (\n \n \n {label}\n {isRequired && *}\n \n {children}\n {fieldConfigItem?.description && (\n {fieldConfigItem.description}\n )}\n \n );\n}\n\n/**\n * Wrapper component for children variable binding.\n * Similar to VariableBindingWrapper but specifically for layer.children.\n */\nexport function ChildrenVariableBindingWrapper({\n children,\n}: {\n children: React.ReactNode;\n}) {\n const variables = useLayerStore((state) => state.variables);\n const selectedLayerId = useLayerStore((state) => state.selectedLayerId);\n const findLayerById = useLayerStore((state) => state.findLayerById);\n const isChildrenBindingImmutable = useLayerStore((state) => state.isChildrenBindingImmutable);\n const incrementRevision = useEditorStore((state) => state.incrementRevision);\n const unbindChildrenFromVariable = useLayerStore(\n (state) => state.unbindChildrenFromVariable\n );\n const bindChildrenToVariable = useLayerStore((state) => state.bindChildrenToVariable);\n\n const selectedLayer = findLayerById(selectedLayerId);\n\n if (!selectedLayer) {\n return <>{children};\n }\n\n const currentValue = selectedLayer.children;\n const isCurrentlyBound = isVariableReference(currentValue);\n const boundVariable = isCurrentlyBound\n ? variables.find((v) => v.id === currentValue.__variableRef)\n : null;\n const isImmutable = isChildrenBindingImmutable(selectedLayer.id);\n\n const handleBindToVariable = (variableId: string) => {\n bindChildrenToVariable(selectedLayer.id, variableId);\n incrementRevision();\n };\n\n const handleUnbind = () => {\n unbindChildrenFromVariable(selectedLayer.id);\n incrementRevision();\n };\n\n return (\n
\n {isCurrentlyBound && boundVariable ? (\n // Bound state - show variable info and unbind button\n
\n \n
\n \n \n
\n \n
\n
\n {boundVariable.name}\n \n {boundVariable.type}\n \n {isImmutable && (\n \n \n \n )}\n
\n \n {String(boundVariable.defaultValue)}\n \n
\n
\n
\n
\n {!isImmutable && (\n \n \n \n \n \n \n Unbind Variable\n \n )}\n
\n
\n ) : (\n // Unbound state - show normal field with bind button\n <>\n
{children}
\n
\n \n \n \n \n \n \n \n Bind Children to Variable\n \n \n
\n Bind Children to Variable\n
\n {variables.filter(v => v.type === 'string').length > 0 ? (\n variables\n .filter(v => v.type === 'string')\n .map((variable) => (\n handleBindToVariable(variable.id)}\n className=\"flex flex-col items-start p-3\"\n >\n
\n \n
\n
\n {variable.name}\n \n {variable.type}\n \n
\n \n {String(variable.defaultValue)}\n \n
\n
\n \n ))\n ) : (\n
\n No string variables defined\n
\n )}\n
\n
\n
\n \n )}\n
\n );\n}\n", - "target": "src/lib/ui-builder/registry/form-field-overrides.tsx" - }, - { - "path": "ui/lib/ui-builder/store/layer-utils.ts", - "type": "registry:lib", - "content": "import type { LayerStore } from \"@/lib/ui-builder/store/layer-store\";\nimport type { ComponentLayer, ComponentRegistry } from '@workspace/ui/components/ui-builder/types';\nimport { getDefaultProps } from '@/lib/ui-builder/store/schema-utils';\nimport { TAILWIND_V4_COLOR_KEYS } from '@workspace/ui/components/ui-builder/internal/utils/base-colors';\n\n/**\n * Recursively visits each layer in the layer tree and applies the provided visitor function to each layer.\n * The visitor function can modify the layer and its children as needed.\n *\n * @param layer - The current layer to visit.\n * @param visitor - A function that takes a layer and returns a modified layer.\n * @returns The modified layer after applying the visitor function.\n */\nexport const visitLayer = (layer: ComponentLayer, parentLayer: ComponentLayer | null, visitor: (layer: ComponentLayer, parentLayer: ComponentLayer | null) => ComponentLayer): ComponentLayer => {\n // Apply the visitor to the current layer\n const updatedLayer = visitor(layer, parentLayer);\n\n // Recursively traverse and update children if they exist\n if (hasLayerChildren(updatedLayer)) {\n const updatedChildren = updatedLayer.children.map((child) =>\n visitLayer(child, updatedLayer, visitor)\n );\n return { ...updatedLayer, children: updatedChildren };\n }\n\n return updatedLayer;\n};\n\nexport const countLayers = (layers: ComponentLayer['children']): number => {\n if (typeof layers === 'string' || !Array.isArray(layers)) {\n // String or VariableReference\n return 0;\n }\n return layers.reduce((count, layer) => {\n if (hasLayerChildren(layer)) {\n return count + 1 + countLayers(layer.children);\n }\n return count + 1;\n }, 0);\n};\n\nexport const addLayer = (layers: ComponentLayer[], newLayer: ComponentLayer, parentId?: string, parentPosition?: number): ComponentLayer[] => {\n const updatedPages = layers.map((page) =>\n visitLayer(page, null, (layer) => {\n if (layer.id === parentId) {\n // Handle both layers with existing children and those with undefined/null children\n let updatedChildren: ComponentLayer[] = [];\n \n if (hasLayerChildren(layer)) {\n updatedChildren = [...layer.children];\n } else if (layer.children === undefined || layer.children === null || (Array.isArray(layer.children) && layer.children.length === 0)) {\n // Initialize children array for layers with undefined/null children or empty arrays\n updatedChildren = [];\n } else {\n // For layers with string children or other non-array types, we can't add children\n return layer;\n }\n\n if (parentPosition !== undefined) {\n if (parentPosition < 0) {\n // If parentPosition is negative, insert at the beginning\n updatedChildren = [newLayer, ...updatedChildren];\n } else if (parentPosition >= updatedChildren.length) {\n // If parentPosition is greater than or equal to the length, append to the end\n updatedChildren = [...updatedChildren, newLayer];\n } else {\n // Insert at the specified position\n updatedChildren = [\n ...updatedChildren.slice(0, parentPosition),\n newLayer,\n ...updatedChildren.slice(parentPosition)\n ];\n }\n } else {\n // If parentPosition is undefined, append to the end\n updatedChildren = [...updatedChildren, newLayer];\n }\n\n return { ...layer, children: updatedChildren };\n }\n\n return layer;\n })\n );\n return updatedPages;\n}\n\nexport const findAllParentLayersRecursive = (layers: ComponentLayer[], layerId: string): ComponentLayer[] => {\n const parents: ComponentLayer[] = [];\n\n const findParents = (layers: ComponentLayer[], targetId: string): boolean => {\n for (const layer of layers) {\n if (hasLayerChildren(layer)) {\n if (layer.children.some(child => child.id === targetId)) {\n parents.push(layer);\n // Continue searching upwards\n findParents(layers, layer.id);\n return true;\n }\n\n if (findParents(layer.children, targetId)) {\n parents.push(layer);\n return true;\n }\n }\n }\n return false;\n };\n\n findParents(layers, layerId);\n return parents;\n};\n\nexport const findLayerRecursive = (layers: ComponentLayer[], layerId: string): ComponentLayer | undefined => {\n for (const layer of layers) {\n if (layer.id === layerId) {\n return layer;\n }\n if (hasLayerChildren(layer)) {\n const foundInChildren = findLayerRecursive(layer.children, layerId);\n if (foundInChildren) {\n return foundInChildren;\n }\n }\n }\n return undefined;\n};\n\nexport const duplicateWithNewIdsAndName = (layer: ComponentLayer, addCopySuffix: boolean = true): ComponentLayer => {\n const newLayer: ComponentLayer = { ...layer, id: createId() };\n if (layer.name) {\n newLayer.name = `${ layer.name }${ addCopySuffix ? ' (Copy)' : ''}`;\n }\n if (hasLayerChildren(newLayer) && hasLayerChildren(layer)) {\n newLayer.children = layer.children.map(child => duplicateWithNewIdsAndName(child, false));\n }\n return newLayer;\n };\n\n\nexport function createId(): string {\n const ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';\n const ID_LENGTH = 7;\n let result = '';\n const alphabetLength = ALPHABET.length;\n\n for (let i = 0; i < ID_LENGTH; i++) {\n const randomIndex = Math.floor(Math.random() * alphabetLength);\n result += ALPHABET.charAt(randomIndex);\n }\n\n return result;\n}\n\n/**\n * Checks if a layer has ComponentLayer[] children (not string or VariableReference)\n * Note: VariableReference is an object (not an array), so Array.isArray returns false for it\n */\nexport const hasLayerChildren = (layer: ComponentLayer): layer is ComponentLayer & { children: ComponentLayer[] } => {\n return Array.isArray(layer.children) && typeof layer.children !== 'string';\n};\n\nexport function migrateV1ToV2(persistedState: unknown): LayerStore {\n type TextLayer = {\n id: string;\n name?: string;\n type: '_text_';\n \n props: Record;\n text: string;\n textType: 'text' | 'markdown';\n };\n\n console.log(\"Migrating store\", { persistedState, version: 1 });\n\n const migratedState = persistedState as LayerStore;\n\n // Utilize visitLayer to transform all layers recursively\n const transformLayer = (layer: ComponentLayer): ComponentLayer => {\n if (layer.type === \"_text_\") {\n const textLayer = layer as unknown as TextLayer;\n const transformedTextLayer: ComponentLayer = {\n type: textLayer.textType === \"markdown\" ? \"Markdown\" : \"span\",\n children: textLayer.text,\n id: textLayer.id,\n name: textLayer.name,\n props: textLayer.props,\n };\n console.log(\"Transformed text layer\", transformedTextLayer);\n return transformedTextLayer;\n }\n\n return layer;\n };\n\n const migratedPages = migratedState.pages.map((page: ComponentLayer) => {\n return visitLayer(page, null, transformLayer) as ComponentLayer;\n }) satisfies ComponentLayer[];\n\n return {\n ...migratedState,\n pages: migratedPages,\n } satisfies LayerStore;\n}\n\nexport function migrateV2ToV3(persistedState: unknown): LayerStore {\n \n \n type PageLayer = {\n id: string;\n name?: string;\n type: '_page_';\n \n props: Record;\n children: ComponentLayer[];\n }\n\n console.log(\"Migrating store\", { persistedState, version: 1 });\n\n const migratedState = persistedState as LayerStore;\n\n const keysToMap = { \n borderRadius: \"data-border-radius\",\n colorTheme: \"data-color-theme\",\n mode: \"data-mode\",\n };\n\n const migratedPages = migratedState.pages.map((page: ComponentLayer) => {\n if (page.type === \"_page_\") {\n const pageLayer = page as unknown as PageLayer;\n const transformedPageLayer: ComponentLayer = {\n type: \"div\",\n children: pageLayer.children,\n id: pageLayer.id,\n name: pageLayer.name,\n //map keys called borderRadius, colorTheme, mode to data-border-radius, data-color-theme, data-mode\n props: Object.fromEntries(Object.entries(pageLayer.props).map(([key, value]) => {\n if (keysToMap[key as keyof typeof keysToMap]) {\n return [keysToMap[key as keyof typeof keysToMap], value];\n }\n return [key, value];\n })),\n };\n console.log(\"Transformed page layer\", transformedPageLayer);\n return transformedPageLayer;\n }\n return page;\n }) satisfies ComponentLayer[];\n\n\n return {\n ...migratedState,\n pages: migratedPages,\n } satisfies LayerStore;\n}\n\n/**\n * Converts old-format theme styles to Tailwind v4 format.\n * Adds --color-* variables and --radius-* variables.\n */\nfunction convertStyleToTailwindV4(oldStyle: Record): Record {\n const newStyle: Record = { ...oldStyle };\n \n // Add --color-* variables based on existing base variables\n TAILWIND_V4_COLOR_KEYS.forEach((key) => {\n const baseVar = `--${key}`;\n if (oldStyle[baseVar] && !oldStyle[`--color-${key}`]) {\n newStyle[`--color-${key}`] = `hsl(${oldStyle[baseVar]})`;\n }\n });\n \n // Add radius variables if --radius exists\n const radiusValue = oldStyle[\"--radius\"];\n if (radiusValue && !oldStyle[\"--radius-lg\"]) {\n newStyle[\"--radius-lg\"] = radiusValue;\n newStyle[\"--radius-md\"] = `calc(${radiusValue} - 2px)`;\n newStyle[\"--radius-sm\"] = `calc(${radiusValue} - 4px)`;\n }\n \n // Add backgroundColor if background exists\n if (oldStyle[\"--background\"] && !oldStyle[\"backgroundColor\"]) {\n newStyle[\"backgroundColor\"] = `hsl(${oldStyle[\"--background\"]})`;\n }\n \n return newStyle;\n}\n\n/**\n * Migration from v5 to v6: Convert old-format theme styles to Tailwind v4 format.\n * \n * In Tailwind v4, utility classes use `--color-foreground`, `--color-background`, etc.\n * Old page layers may have a `style` prop with just `--foreground`, `--background`, etc.\n * This migration adds the required `--color-*` and `--radius-*` variables.\n */\nexport function migrateV5ToV6(persistedState: unknown): LayerStore {\n console.log(\"Migrating store\", { persistedState, version: 5 });\n\n const migratedState = persistedState as LayerStore;\n\n const migratedPages = migratedState.pages.map((page: ComponentLayer) => {\n // Only process top-level pages (divs)\n if (page.type === \"div\" && page.props) {\n const hasStyle = 'style' in page.props && page.props.style != null;\n const hasCustomTheme = 'data-color-theme' in page.props && page.props[\"data-color-theme\"] != null;\n \n // If page has style with custom theme, convert to Tailwind v4 format\n if (hasStyle && hasCustomTheme) {\n console.log(\"Converting style to Tailwind v4 format\", { \n pageId: page.id, \n pageName: page.name,\n colorTheme: page.props[\"data-color-theme\"]\n });\n const oldStyle = page.props.style as Record;\n const newStyle = convertStyleToTailwindV4(oldStyle);\n return {\n ...page,\n props: {\n ...page.props,\n style: newStyle,\n },\n };\n }\n \n // If page has old style but no custom theme, remove the style to use default theme\n if (hasStyle && !hasCustomTheme) {\n console.log(\"Removing old-format style from page (no custom theme)\", { pageId: page.id, pageName: page.name });\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n const { style: _removedStyle, ...restProps } = page.props;\n return {\n ...page,\n props: restProps,\n };\n }\n }\n return page;\n }) satisfies ComponentLayer[];\n\n return {\n ...migratedState,\n pages: migratedPages,\n } satisfies LayerStore;\n}\n\n/**\n * Creates a new component layer with default props and children initialized from the component registry.\n * This utility function consolidates the layer initialization logic used across the application.\n * \n * @param layerType - The type of component to create\n * @param componentRegistry - The component registry containing component definitions\n * @param options - Optional configuration for the layer\n * @returns A new ComponentLayer with initialized props and children\n */\nexport const createComponentLayer = (\n layerType: string,\n componentRegistry: ComponentRegistry,\n options: {\n id?: string;\n name?: string;\n applyVariableBindings?: boolean;\n variables?: Array<{ id: string; defaultValue: any }>;\n } = {}\n): ComponentLayer => {\n const { id, name, applyVariableBindings = false, variables = [] } = options;\n \n const componentDef = componentRegistry[layerType as keyof typeof componentRegistry];\n if (!componentDef) {\n throw new Error(`Component definition not found for type: ${layerType}`);\n }\n\n const schema = componentDef.schema;\n \n // Safely check if schema has shape property (ZodObject)\n const defaultProps = 'shape' in schema && schema.shape ? getDefaultProps(schema as any) : {};\n const defaultChildrenRaw = componentDef.defaultChildren;\n const defaultChildren: ComponentLayer['children'] = typeof defaultChildrenRaw === \"string\" \n ? defaultChildrenRaw \n : Array.isArray(defaultChildrenRaw)\n ? defaultChildrenRaw.map(child => duplicateWithNewIdsAndName(child, false))\n : defaultChildrenRaw ?? []; // handles VariableReference or undefined\n\n const initialProps = Object.entries(defaultProps).reduce((acc, [key, propDef]) => {\n if (key !== \"children\") {\n acc[key] = propDef;\n }\n return acc;\n }, {} as Record);\n\n const newLayer: ComponentLayer = {\n id: id || createId(),\n type: layerType,\n name: name || layerType,\n props: initialProps,\n children: defaultChildren,\n };\n\n // Apply default variable bindings if requested\n if (applyVariableBindings) {\n const defaultVariableBindings = componentDef.defaultVariableBindings || [];\n \n for (const binding of defaultVariableBindings) {\n const variable = variables.find(v => v.id === binding.variableId);\n if (variable) {\n // Set the variable reference in the props\n newLayer.props[binding.propName] = { __variableRef: binding.variableId };\n }\n }\n }\n\n return newLayer;\n};\n\n/**\n * Moves a layer from one position to another in the layer tree.\n * This function supports moving layers between different parents and reordering within the same parent.\n *\n * @param layers - The array of root layers (pages)\n * @param sourceLayerId - The ID of the layer to move\n * @param targetParentId - The ID of the target parent layer\n * @param targetPosition - The position in the target parent's children array (0-based index)\n * @returns The updated layers array with the layer moved to its new position\n */\nexport const moveLayer = (\n layers: ComponentLayer[],\n sourceLayerId: string,\n targetParentId: string,\n targetPosition: number\n): ComponentLayer[] => {\n let layerToMove: ComponentLayer | null = null;\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n let sourceParentId: string | null = null;\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n let sourcePosition: number = -1;\n\n // Find the layer to move and its current parent\n const findLayerAndParent = (layers: ComponentLayer[], parentId: string | null = null): boolean => {\n for (let i = 0; i < layers.length; i++) {\n const layer = layers[i];\n if (!layer) continue;\n if (layer.id === sourceLayerId) {\n layerToMove = layer;\n sourceParentId = parentId;\n sourcePosition = i;\n return true;\n }\n if (hasLayerChildren(layer) && layer.children) {\n if (findLayerAndParent(layer.children, layer.id)) {\n return true;\n }\n }\n }\n return false;\n };\n\n // Find the layer in the tree\n findLayerAndParent(layers);\n\n if (!layerToMove) {\n console.warn(`Source layer with ID ${sourceLayerId} not found`);\n return layers;\n }\n\n // Remove the layer from its current position\n const layersWithoutSource = layers.map(page =>\n visitLayer(page, null, (layer) => {\n if (hasLayerChildren(layer)) {\n const updatedChildren = layer.children.filter(child => child.id !== sourceLayerId);\n return { ...layer, children: updatedChildren };\n }\n return layer;\n })\n );\n\n // Add the layer to its new position\n const updatedLayers = addLayer(layersWithoutSource, layerToMove, targetParentId, targetPosition);\n\n return updatedLayers;\n};\n\n/**\n * Checks if a layer can accept children (has a children property that is an array)\n *\n * @param layer - The layer to check\n * @param componentRegistry - The component registry to check schema\n * @returns true if the layer can accept children\n */\nexport const canLayerAcceptChildren = (\n layer: ComponentLayer,\n componentRegistry: ComponentRegistry\n): boolean => {\n const componentDef = componentRegistry[layer.type as keyof typeof componentRegistry];\n if (!componentDef) return false;\n\n // Safely check if schema has shape property (ZodObject) and children field\n const hasChildrenField = 'shape' in componentDef.schema && \n componentDef.schema.shape && \n componentDef.schema.shape.children !== undefined;\n\n return hasChildrenField && hasLayerChildren(layer);\n};\n\n", - "target": "src/lib/ui-builder/store/layer-utils.ts" - }, - { - "path": "ui/lib/ui-builder/store/schema-utils.ts", - "type": "registry:lib", - "content": "import { z } from 'zod';\n\n/**\n * Helper to get the Zod v4 def type from a schema.\n */\nfunction getDefType(schema: z.ZodType): string {\n return (schema as any)._zod?.def?.type || \"\";\n}\n\n/**\n * Helper to get the def object from a Zod v4 schema.\n */\nfunction getDef(schema: z.ZodType): any {\n return (schema as any)._zod?.def;\n}\n\n/**\n * Generates default props based on the provided Zod schema.\n * Supports boolean, date, number, string, enum, objects composed of these primitives, and arrays of these primitives.\n * Logs a warning for unsupported types.\n *\n * @param schema - The Zod schema object.\n * @returns An object containing default values for the schema.\n */\nexport function getDefaultProps(schema: z.ZodObject): Record {\n const shape = schema.shape;\n const defaultProps: Record = {};\n\n for (const key in shape) {\n if (Object.prototype.hasOwnProperty.call(shape, key)) {\n const fieldSchema = shape[key];\n const value = getDefaultValue(fieldSchema, key);\n if(value !== undefined){\n defaultProps[key] = value;\n }\n }\n }\n\n return defaultProps;\n}\n\n/**\n * Determines the default value for a given Zod schema.\n * Handles nullable and coerced fields appropriately.\n *\n * @param schema - The Zod schema for the field.\n * @param fieldName - The name of the field (used for logging).\n * @returns The default value for the field.\n */\nfunction getDefaultValue(schema: z.ZodType, fieldName: string): any {\n const defType = getDefType(schema);\n const def = getDef(schema);\n \n // Handle ZodDefault to return the specified default value\n if (defType === \"default\" && def) {\n // In Zod v4, defaultValue can be the value directly or a function\n const defaultValue = def.defaultValue;\n if (typeof defaultValue === \"function\") {\n return defaultValue();\n }\n return defaultValue;\n }\n\n if (!schema.isOptional()){\n console.warn(`No default value set for required field \"${fieldName}\".`);\n }\n return undefined;\n}\n\n/**\n * Patches the given Zod object schema by transforming unions of literals to enums,\n * coercing number and date types, and adding an optional `className` property.\n *\n * @param schema - The original Zod object schema to be patched.\n * @returns A new Zod object schema with the specified transformations applied.\n */\n\nexport function patchSchema(schema: z.ZodObject): z.ZodObject {\n const schemaWithFixedEnums = transformUnionToEnum(schema);\n const schemaWithCoercedTypes = addCoerceToNumberAndDate(schemaWithFixedEnums);\n const schemaWithCommon = addCommon(schemaWithCoercedTypes);\n\n return schemaWithCommon;\n}\n\n/**\n * Extends the given Zod object schema by adding an optional `className` property.\n *\n * @param schema - The original Zod object schema.\n * @returns A new Zod object schema with the `className` property added.\n */\nfunction addCommon(\n schema: z.ZodObject\n) {\n return schema.extend({\n className: z.string().optional(),\n });\n}\n\n/**\n * Transforms a ZodUnion of ZodLiterals into a ZodEnum with a default value.\n * If the schema is nullable or optional, it recursively applies the transformation to the inner schema.\n *\n * @param schema - The original Zod schema, which can be a ZodUnion, ZodNullable, ZodOptional, or ZodObject.\n * @returns A transformed Zod schema with unions of literals converted to enums, or the original schema if no transformation is needed.\n */\nfunction transformUnionToEnum(schema: T): T {\n const defType = getDefType(schema);\n const def = getDef(schema);\n \n // Handle ZodUnion of string literals\n if (defType === \"union\" && def?.options) {\n const options = def.options;\n\n // Check if all options are ZodLiteral instances with string values\n const allStringLiterals = options.every((option: any) => {\n const optDef = getDef(option);\n return optDef?.type === \"literal\" && typeof optDef?.value === 'string';\n });\n \n if (allStringLiterals) {\n const enumValues = options.map((option: any) => {\n const optDef = getDef(option);\n return optDef.value;\n }).reverse();\n\n // Ensure there is at least one value to create an enum\n if (enumValues.length === 0) {\n throw new Error(\"Cannot create enum with no values.\");\n }\n\n // Create a ZodEnum from the string literals\n const enumSchema = z.enum(enumValues as [string, ...string[]]);\n\n // Determine if the original schema was nullable or optional\n let transformedSchema: z.ZodType = enumSchema;\n\n // Apply default before adding modifiers to ensure it doesn't get overridden\n transformedSchema = enumSchema.default(enumValues[0]);\n\n\n if (schema.isNullable()) {\n transformedSchema = transformedSchema.nullable();\n }\n\n if (schema.isOptional()) {\n transformedSchema = transformedSchema.optional();\n }\n\n return transformedSchema as unknown as T;\n }\n }\n\n // Recursively handle nullable schemas\n if (defType === \"nullable\" && def?.innerType) {\n const inner = def.innerType;\n const transformedInner = transformUnionToEnum(inner);\n return transformedInner.nullable() as any;\n }\n\n // Recursively handle optional schemas\n if (defType === \"optional\" && def?.innerType) {\n const inner = def.innerType;\n const transformedInner = transformUnionToEnum(inner);\n return transformedInner.optional() as any;\n }\n\n // Recursively handle ZodObjects by transforming their shape\n if (defType === \"object\") {\n const shape = (schema as unknown as z.ZodObject).shape;\n const transformedShape: Record = {};\n\n for (const [key, value] of Object.entries(shape)) {\n transformedShape[key] = transformUnionToEnum(value as z.ZodType);\n }\n\n return z.object(transformedShape) as unknown as T;\n }\n\n // Handle ZodArrays by transforming their element type\n if (defType === \"array\" && def?.element) {\n const transformedElement = transformUnionToEnum(def.element);\n return z.array(transformedElement) as unknown as T;\n }\n\n // Handle ZodTuples by transforming each element type\n if (defType === \"tuple\" && def?.items) {\n const transformedItems = def.items.map((item: any) => transformUnionToEnum(item));\n return z.tuple(transformedItems as [z.ZodType, ...z.ZodType[]]) as unknown as T;\n }\n\n // If none of the above, return the schema unchanged\n return schema;\n}\n\n/**\n * Recursively applies coercion to number and date fields within the given Zod schema.\n * Handles nullable, optional, objects, arrays, unions, and enums appropriately to ensure type safety.\n *\n * @param schema - The original Zod schema to transform.\n * @returns A new Zod schema with coercions applied where necessary.\n */\nfunction addCoerceToNumberAndDate(schema: T): T {\n const defType = getDefType(schema);\n const def = getDef(schema);\n \n // Handle nullable schemas\n if (defType === \"nullable\" && def?.innerType) {\n const inner = def.innerType;\n return addCoerceToNumberAndDate(inner).nullable() as any;\n }\n\n // Handle optional schemas\n if (defType === \"optional\" && def?.innerType) {\n const inner = def.innerType;\n return addCoerceToNumberAndDate(inner).optional() as any;\n }\n\n // Handle objects by recursively applying the transformation to each property\n if (defType === \"object\") {\n const shape = (schema as unknown as z.ZodObject).shape;\n const transformedShape: Record = {};\n\n for (const [key, value] of Object.entries(shape)) {\n transformedShape[key] = addCoerceToNumberAndDate(value as z.ZodType);\n }\n\n return z.object(transformedShape) as any;\n }\n\n // Handle arrays by applying the transformation to the array's element type\n if (defType === \"array\" && def?.element) {\n const innerType = def.element;\n return z.array(addCoerceToNumberAndDate(innerType)) as any;\n }\n\n // Apply coercion to number fields (handles number, int, float in Zod v4)\n if ([\"number\", \"int\", \"float\"].includes(defType)) {\n return z.coerce.number().optional() as any;\n }\n\n // Apply coercion to date fields\n if (defType === \"date\") {\n return z.coerce.date().optional() as any;\n }\n\n // Handle unions by applying the transformation to each option\n if (defType === \"union\" && def?.options) {\n const transformedOptions = def.options.map((option: any) => addCoerceToNumberAndDate(option));\n return z.union(transformedOptions as [z.ZodType, z.ZodType, ...z.ZodType[]]) as any;\n }\n\n // Handle enums by returning them as-is\n if (defType === \"enum\") {\n return schema;\n }\n\n // If none of the above, return the schema unchanged\n return schema;\n}\n\n// patch for autoform to respect existing values, specifically for enums\nexport function addDefaultValues>(\n schema: T,\n defaultValues: Partial>\n): T {\n const shape = schema.shape;\n\n const updatedShape = { ...shape };\n\n for (const key in defaultValues) {\n if (updatedShape[key]) {\n // Apply the default value to the existing schema field\n updatedShape[key] = updatedShape[key].default(defaultValues[key]);\n } else if (process.env.NODE_ENV !== \"production\") {\n console.warn(\n `Key \"${key}\" does not exist in the schema and will be ignored.`\n );\n }\n }\n\n return z.object(updatedShape) as T;\n}\n\n/**\n * Checks if a component schema can accept child components.\n * This combines the logic for checking:\n * 1. Schema has a shape property (is a ZodObject)\n * 2. Has a children field of type any\n * 3. Children field is NOT string-only\n * \n * @param schema - The component's Zod schema\n * @returns true if the component can accept child components\n */\nexport function canComponentAcceptChildren(schema: z.ZodType): boolean {\n // Check if schema has shape property (ZodObject)\n if (!(\"shape\" in schema)) {\n return false;\n }\n \n const objectSchema = schema as z.ZodObject;\n return hasAnyChildrenField(objectSchema) && !hasChildrenFieldOfTypeString(objectSchema);\n}\n\n/**\n * Checks if a Zod schema has a children field of type ANY\n */\nexport function hasAnyChildrenField(schema: z.ZodObject): boolean {\n const shape = schema.shape;\n if (!shape.children) {\n return false;\n }\n \n // Unwrap optional and nullable wrappers to get the inner type\n let childrenSchema = shape.children;\n let childDefType = getDefType(childrenSchema);\n \n while (childDefType === \"optional\" || childDefType === \"nullable\") {\n const childDef = getDef(childrenSchema);\n if (childDef?.innerType) {\n childrenSchema = childDef.innerType;\n childDefType = getDefType(childrenSchema);\n } else {\n break;\n }\n }\n \n return childDefType === \"any\";\n}\n\n/**\n* Checks if a Zod schema has a children field of type String\n*/\nexport function hasChildrenFieldOfTypeString(schema: z.ZodObject): boolean {\n const shape = schema.shape;\n if (!shape.children) {\n return false;\n }\n \n // Unwrap optional and nullable wrappers to get the inner type\n let childrenSchema = shape.children;\n let childDefType = getDefType(childrenSchema);\n \n while (childDefType === \"optional\" || childDefType === \"nullable\") {\n const childDef = getDef(childrenSchema);\n if (childDef?.innerType) {\n childrenSchema = childDef.innerType;\n childDefType = getDefType(childrenSchema);\n } else {\n break;\n }\n }\n \n return childDefType === \"string\";\n}\n", - "target": "src/lib/ui-builder/store/schema-utils.ts" - }, - { - "path": "ui/lib/ui-builder/store/editor-store.ts", - "type": "registry:lib", - "content": "import { create, type StateCreator } from 'zustand';\nimport type { ComponentType as ReactComponentType } from \"react\";\nimport type { RegistryEntry, ComponentRegistry, BlockRegistry, FunctionRegistry, FunctionDefinition, ComponentLayer } from '@workspace/ui/components/ui-builder/types';\n\n/**\n * Clipboard state for copy/cut/paste operations\n */\nexport interface ClipboardState {\n layer: ComponentLayer | null;\n isCut: boolean;\n sourceLayerId: string | null;\n}\n\n/**\n * Context menu state for right-click menus\n */\nexport interface ContextMenuState {\n open: boolean;\n x: number;\n y: number;\n layerId: string | null;\n}\n\n\n\nexport interface EditorStore {\n previewMode: 'mobile' | 'tablet' | 'desktop' | 'responsive';\n setPreviewMode: (mode: 'mobile' | 'tablet' | 'desktop' | 'responsive') => void;\n\n registry: ComponentRegistry;\n blocks: BlockRegistry | undefined;\n functionRegistry: FunctionRegistry | undefined;\n\n initialize: (registry: ComponentRegistry, persistLayerStoreConfig: boolean, allowPagesCreation: boolean, allowPagesDeletion: boolean, allowVariableEditing: boolean, blocks?: BlockRegistry, functionRegistry?: FunctionRegistry) => void;\n getComponentDefinition: (type: string) => RegistryEntry> | undefined;\n getFunctionDefinition: (id: string) => FunctionDefinition | undefined;\n\n persistLayerStoreConfig: boolean;\n setPersistLayerStoreConfig: (shouldPersist: boolean) => void;\n\n // Revision counter to track state changes for form revalidation\n revisionCounter: number;\n incrementRevision: () => void;\n\n allowPagesCreation: boolean;\n setAllowPagesCreation: (allow: boolean) => void;\n allowPagesDeletion: boolean;\n setAllowPagesDeletion: (allow: boolean) => void;\n allowVariableEditing: boolean;\n setAllowVariableEditing: (allow: boolean) => void;\n\n // Panel visibility state\n showLeftPanel: boolean;\n setShowLeftPanel: (show: boolean) => void;\n showRightPanel: boolean;\n setShowRightPanel: (show: boolean) => void;\n\n // Clipboard state for copy/cut/paste\n clipboard: ClipboardState;\n setClipboard: (clipboard: ClipboardState) => void;\n clearClipboard: () => void;\n\n // Context menu state for right-click menus\n contextMenu: ContextMenuState;\n openContextMenu: (x: number, y: number, layerId: string) => void;\n closeContextMenu: () => void;\n}\n\nconst store: StateCreator = (set, get) => ({\n previewMode: 'responsive',\n setPreviewMode: (mode) => set({ previewMode: mode }),\n\n registry: {},\n blocks: undefined,\n functionRegistry: undefined,\n\n initialize: (registry, persistLayerStoreConfig, allowPagesCreation, allowPagesDeletion, allowVariableEditing, blocks, functionRegistry) => {\n set(state => ({ ...state, registry, persistLayerStoreConfig, allowPagesCreation, allowPagesDeletion, allowVariableEditing, blocks, functionRegistry }));\n },\n getComponentDefinition: (type: string) => {\n const { registry } = get();\n if (!registry) {\n console.warn(\"Registry accessed via editor store before initialization.\");\n return undefined;\n }\n return registry[type];\n },\n getFunctionDefinition: (id: string) => {\n const { functionRegistry } = get();\n if (!functionRegistry) {\n return undefined;\n }\n return functionRegistry[id];\n },\n\n persistLayerStoreConfig: true,\n setPersistLayerStoreConfig: (shouldPersist) => set({ persistLayerStoreConfig: shouldPersist }),\n\n revisionCounter: 0,\n incrementRevision: () => set(state => ({ revisionCounter: state.revisionCounter + 1 })),\n\n allowPagesCreation: true,\n setAllowPagesCreation: (allow) => set({ allowPagesCreation: allow }),\n allowPagesDeletion: true,\n setAllowPagesDeletion: (allow) => set({ allowPagesDeletion: allow }),\n allowVariableEditing: true,\n setAllowVariableEditing: (allow) => set({ allowVariableEditing: allow }),\n\n // Panel visibility state\n showLeftPanel: true,\n setShowLeftPanel: (show) => set({ showLeftPanel: show }),\n showRightPanel: true,\n setShowRightPanel: (show) => set({ showRightPanel: show }),\n\n // Clipboard state for copy/cut/paste\n clipboard: {\n layer: null,\n isCut: false,\n sourceLayerId: null,\n },\n setClipboard: (clipboard) => set({ clipboard }),\n clearClipboard: () => set({ \n clipboard: { \n layer: null, \n isCut: false, \n sourceLayerId: null \n } \n }),\n\n // Context menu state for right-click menus\n contextMenu: {\n open: false,\n x: 0,\n y: 0,\n layerId: null,\n },\n openContextMenu: (x, y, layerId) => set({\n contextMenu: {\n open: true,\n x,\n y,\n layerId,\n }\n }),\n closeContextMenu: () => set({\n contextMenu: {\n open: false,\n x: 0,\n y: 0,\n layerId: null,\n }\n }),\n});\n\nexport const useEditorStore = create()(store);", - "target": "src/lib/ui-builder/store/editor-store.ts" } ], "docs": "https://better-stack.ai/docs/plugins/ui-builder" diff --git a/packages/stack/scripts/build-registry.ts b/packages/stack/scripts/build-registry.ts index 3926013c..c20278b2 100644 --- a/packages/stack/scripts/build-registry.ts +++ b/packages/stack/scripts/build-registry.ts @@ -62,6 +62,11 @@ const EXTERNAL_REGISTRY_COMPONENTS: Record = { "https://raw.githubusercontent.com/olliethedev/ui-builder/refs/heads/main/registry/block-registry.json", }; +// These registry items are the sole source of truth for every component and +// lib subpath under their top-level name. Do not embed local copies: keeping +// them external lets consumers and this monorepo sync from upstream cleanly. +const EXTERNAL_ONLY_REGISTRY_COMPONENTS = new Set(["ui-builder"]); + // --------------------------------------------------------------------------- // Standard shadcn component names // These go into registryDependencies, not as embedded files. @@ -886,6 +891,10 @@ async function resolveWorkspaceUiDeps( ); } + if (EXTERNAL_ONLY_REGISTRY_COMPONENTS.has(topLevel)) { + continue; + } + // Also try to embed the specific deep file from the workspace const file = await loadWorkspaceUiComponent(comp); if (file) { @@ -988,6 +997,18 @@ async function resolveWorkspaceUiDeps( if (processedLibs.has(lib)) continue; processedLibs.add(lib); + const topLevel = lib.split("/")[0]!; + if (EXTERNAL_ONLY_REGISTRY_COMPONENTS.has(topLevel)) { + const registryUrl = EXTERNAL_REGISTRY_COMPONENTS[topLevel]; + if (registryUrl) { + shadcnDeps.add(registryUrl); + console.log( + ` ext @workspace/ui/lib/${topLevel} → external registry URL`, + ); + } + continue; + } + const file = await loadWorkspaceUiLib(lib); if (file) { if (!addedTargets.has(file.target ?? "")) { @@ -1147,6 +1168,19 @@ async function buildPlugin(config: PluginConfig): Promise { console.log(` add ${f.target} (${f.type}) [from @workspace/ui]`); } + if (pluginName === "ui-builder") { + const embeddedUpstreamFile = registryFiles.find( + (file) => + file.target?.startsWith("src/components/ui/ui-builder/") || + file.target?.startsWith("src/lib/ui-builder/"), + ); + if (embeddedUpstreamFile) { + throw new Error( + `UI Builder registry must not embed upstream source: ${embeddedUpstreamFile.target}`, + ); + } + } + // ---- Assemble the registry item ---------------------------------------- const item: RegistryItem = { name: `btst-${pluginName}`, diff --git a/packages/stack/src/__tests__/client-plugin-ssr-loaders.test.ts b/packages/stack/src/__tests__/client-plugin-ssr-loaders.test.ts index 9c61e02f..26900505 100644 --- a/packages/stack/src/__tests__/client-plugin-ssr-loaders.test.ts +++ b/packages/stack/src/__tests__/client-plugin-ssr-loaders.test.ts @@ -11,7 +11,7 @@ import { formBuilderClientPlugin } from "../plugins/form-builder/client"; import type { FormBuilderApiRouter } from "../plugins/form-builder/api"; import { createFormBuilderQueryKeys } from "../plugins/form-builder/query-keys"; import { uiBuilderClientPlugin } from "../plugins/ui-builder/client"; -import { UI_BUILDER_TYPE_SLUG } from "../plugins/ui-builder"; +import { createUIBuilderQueryKeys } from "../plugins/ui-builder/query-keys"; import { commentsClientPlugin } from "../plugins/comments/client"; import type { CommentsApiRouter } from "../plugins/comments/api"; import { createCommentsQueryKeys } from "../plugins/comments/query-keys"; @@ -178,15 +178,10 @@ describe("client plugin SSR loaders", () => { baseURL: API_BASE_URL, basePath: API_BASE_PATH, }); - const queries = createCMSQueryKeys(client, TEST_HEADERS); - const listQuery = queries.cmsContent.list({ - typeSlug: UI_BUILDER_TYPE_SLUG, - limit: 20, - offset: 0, - }); - const uiBuilderQueryKey = [...listQuery.queryKey, "ui-builder"] as const; + const queries = createUIBuilderQueryKeys(client, TEST_HEADERS); + const listQuery = queries.cmsContent.list({ limit: 10, offset: 0 }); - expect(getErrorMessage(queryClient, uiBuilderQueryKey)).toBe( + expect(getErrorMessage(queryClient, listQuery.queryKey)).toBe( SSR_LOADER_ERROR_MESSAGE, ); }); diff --git a/packages/stack/src/plugins/ui-builder/__tests__/client-sweep.test.tsx b/packages/stack/src/plugins/ui-builder/__tests__/client-sweep.test.tsx new file mode 100644 index 00000000..a1c1d601 --- /dev/null +++ b/packages/stack/src/plugins/ui-builder/__tests__/client-sweep.test.tsx @@ -0,0 +1,335 @@ +// @vitest-environment jsdom +import { QueryClient } from "@tanstack/react-query"; +import { act, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + StackProvider, + type StackAuthProvider, + type StackI18nProvider, +} from "@btst/stack/context"; +import { createApiClient } from "@btst/stack/plugins/client"; +import type { CMSApiRouter } from "../../cms/api"; +import { createCMSQueryKeys } from "../../cms/query-keys"; +import { PageBuilderPage } from "../client/components/pages/page-builder-page.internal"; +import { PageListPage } from "../client/components/pages/page-list-page.internal"; +import { createUIBuilderQueryKeys } from "../query-keys"; +import { UI_BUILDER_TYPE_SLUG } from "../schemas"; +import type { SerializedUIBuilderPage } from "../types"; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +const hooks = vi.hoisted(() => ({ + useSuspenseUIBuilderPages: vi.fn(), + useDeleteUIBuilderPage: vi.fn(), + useSuspenseUIBuilderPage: vi.fn(), + useUIBuilderPageForm: vi.fn(), +})); + +vi.mock("../client/hooks/ui-builder-hooks", () => hooks); +vi.mock("@btst/stack/plugins/ai-chat/client/context", () => ({ + useRegisterPageAIContext: vi.fn(), +})); +vi.mock("@workspace/ui/lib/ui-builder/store/layer-store", () => ({ + useLayerStore: { getState: vi.fn() }, +})); +vi.mock("@workspace/ui/components/ui-builder", () => ({ + default: ({ + navLeftChildren, + navRightChildren, + onChange, + }: { + navLeftChildren?: ReactNode; + navRightChildren?: ReactNode; + onChange?: (layers: unknown[]) => void; + }) => ( +
+ {navLeftChildren} + {navRightChildren} + +
+ ), +})); + +const page: SerializedUIBuilderPage = { + id: "page-1", + contentTypeId: "type-1", + slug: "home", + data: JSON.stringify({ + layers: "[]", + variables: "[]", + status: "draft", + }), + createdAt: new Date("2024-01-01").toISOString(), + updatedAt: new Date("2024-01-02").toISOString(), + parsedData: { layers: "[]", variables: "[]", status: "draft" }, +}; + +let container: HTMLDivElement; +let root: Root; +let queryClient: QueryClient; + +beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + hooks.useSuspenseUIBuilderPages.mockReturnValue({ + pages: [page], + total: 1, + loadMore: vi.fn(), + hasMore: false, + isLoadingMore: false, + refetch: vi.fn(), + }); + hooks.useDeleteUIBuilderPage.mockReturnValue({ + mutateAsync: vi.fn().mockResolvedValue({ success: true }), + isPending: false, + }); + hooks.useSuspenseUIBuilderPage.mockReturnValue({ page, refetch: vi.fn() }); + hooks.useUIBuilderPageForm.mockReturnValue({ + action: "create", + record: null, + isLoadingRecord: false, + recordError: null, + defaultValues: undefined, + submit: vi.fn().mockResolvedValue(page), + isSubmitting: false, + error: null, + fieldErrors: {}, + clearErrors: vi.fn(), + }); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + document.body.innerHTML = ""; + queryClient.clear(); + vi.clearAllMocks(); +}); + +function createMockRouter() { + return { + navigate: vi.fn(), + getSearchParams: () => new URLSearchParams(), + setSearchParams: vi.fn(), + }; +} + +function overrides() { + return { + apiBaseURL: "http://test.local", + apiBasePath: "/api/data", + queryClient, + navigate: vi.fn(), + componentRegistry: {}, + }; +} + +async function renderPage( + pageNode: ReactNode, + options: { + auth?: StackAuthProvider; + i18n?: StackI18nProvider; + notify?: { + success: ReturnType; + error: ReturnType; + }; + localization?: Record; + } = {}, +) { + await act(async () => { + root.render( + + {pageNode} + , + ); + }); +} + +function buttonWithText(text: string) { + return Array.from( + document.querySelectorAll("button"), + ).find((button) => button.textContent?.includes(text)); +} + +describe("UI Builder query keys", () => { + it("matches the underlying CMS keys used by SSR and CMS consumers", () => { + const client = createApiClient({ + baseURL: "http://test.local", + basePath: "/api/data", + }); + const uiBuilderQueries = createUIBuilderQueryKeys(client); + const cmsQueries = createCMSQueryKeys(client); + + expect( + uiBuilderQueries.cmsContent.list({ limit: 10, offset: 0 }).queryKey, + ).toEqual( + cmsQueries.cmsContent.list({ + typeSlug: UI_BUILDER_TYPE_SLUG, + limit: 10, + offset: 0, + }).queryKey, + ); + expect(uiBuilderQueries.cmsContent.detail(page.id).queryKey).toEqual( + cmsQueries.cmsContent.detail(UI_BUILDER_TYPE_SLUG, page.id).queryKey, + ); + }); +}); + +describe("UI Builder page permissions", () => { + it("keeps create controls visible without an auth provider", async () => { + await renderPage(); + expect(document.body.textContent).toContain("Create Page"); + }); + + it("hides writes when the auth provider only grants read", async () => { + const can = vi.fn( + ({ action }: { resource: string; action: string }) => action === "read", + ); + await renderPage(, { + auth: { getIdentity: () => ({ id: "viewer" }), can }, + }); + + expect(document.body.textContent).not.toContain("Create Page"); + expect(can).toHaveBeenCalledWith( + expect.objectContaining({ + resource: "ui-builder:page", + action: "create", + }), + ); + }); +}); + +describe("UI Builder notifications and localization", () => { + it("sends delete success through the notify provider", async () => { + const notify = { success: vi.fn(), error: vi.fn() }; + await renderPage(, { notify }); + + const actionsTrigger = + container.querySelector("tbody button")!; + await act(async () => { + actionsTrigger.dispatchEvent( + new MouseEvent("pointerdown", { bubbles: true, button: 0 }), + ); + }); + const deleteItem = Array.from( + document.querySelectorAll("[role=menuitem]"), + ).find((item) => item.textContent?.includes("Delete")); + await act(async () => deleteItem?.click()); + const deleteButtons = Array.from( + document.querySelectorAll("button"), + ).filter((button) => button.textContent === "Delete"); + await act(async () => deleteButtons.at(-1)?.click()); + + expect( + hooks.useDeleteUIBuilderPage.mock.results[0]!.value.mutateAsync, + ).toHaveBeenCalledWith(page.id); + expect(notify.success).toHaveBeenCalledWith("Page deleted successfully"); + expect(notify.error).not.toHaveBeenCalled(); + }); + + it("routes copy through i18n and lets localization overrides win", async () => { + hooks.useSuspenseUIBuilderPages.mockReturnValue({ + pages: [], + total: 0, + loadMore: vi.fn(), + hasMore: false, + isLoadingMore: false, + refetch: vi.fn(), + }); + const translate = vi.fn((key: string, fallback: string) => + key === "uiBuilder.pageList.emptyState.title" + ? "Noch keine Seiten" + : fallback, + ); + + await renderPage(, { i18n: { translate } }); + expect(document.body.textContent).toContain("Noch keine Seiten"); + + await act(async () => root.unmount()); + root = createRoot(container); + await renderPage(, { + i18n: { translate }, + localization: { pageList: { emptyState: { title: "Custom empty" } } }, + }); + expect(document.body.textContent).toContain("Custom empty"); + }); +}); + +describe("UI Builder editor resource form", () => { + it("uses the notify provider for local validation", async () => { + const notify = { success: vi.fn(), error: vi.fn() }; + await renderPage(, { notify }); + + await act(async () => buttonWithText("Save")?.click()); + + expect(notify.error).toHaveBeenCalledWith("Slug is required"); + expect( + hooks.useUIBuilderPageForm.mock.results[0]!.value.submit, + ).not.toHaveBeenCalled(); + }); + + it("submits editor state through the resource form", async () => { + await renderPage(); + await act(async () => buttonWithText("Add layer")?.click()); + await act(async () => buttonWithText("Save")?.click()); + + expect( + hooks.useUIBuilderPageForm.mock.results.at(-1)!.value.submit, + ).toHaveBeenCalledWith( + expect.objectContaining({ + slug: "home-page", + status: "draft", + layers: [expect.objectContaining({ id: "root" })], + }), + ); + }); + + it("renders server slug errors inline", async () => { + hooks.useUIBuilderPageForm.mockReturnValue({ + action: "create", + record: null, + isLoadingRecord: false, + recordError: null, + defaultValues: undefined, + submit: vi.fn(), + isSubmitting: false, + error: new Error("Validation failed"), + fieldErrors: { slug: "Slug is invalid" }, + clearErrors: vi.fn(), + }); + + await renderPage(); + expect(document.body.textContent).toContain("Slug is invalid"); + expect(container.querySelector("input[aria-invalid=true]")).toBeTruthy(); + }); +}); diff --git a/packages/stack/src/plugins/ui-builder/client/components/page-renderer.tsx b/packages/stack/src/plugins/ui-builder/client/components/page-renderer.tsx index d5232b97..c5c6e217 100644 --- a/packages/stack/src/plugins/ui-builder/client/components/page-renderer.tsx +++ b/packages/stack/src/plugins/ui-builder/client/components/page-renderer.tsx @@ -3,6 +3,7 @@ import type { ComponentType, ReactNode } from "react"; import { Suspense } from "react"; import { ErrorBoundary } from "react-error-boundary"; +import { usePluginOverrides, useTranslate } from "@btst/stack/context"; import LayerRenderer from "@workspace/ui/components/ui-builder/layer-renderer"; import type { ComponentRegistry, @@ -12,15 +13,23 @@ import type { import { useSuspenseUIBuilderPageBySlug } from "../hooks/ui-builder-hooks"; import { defaultComponentRegistry } from "../registry"; import { uiBuilderLocalization } from "../localization"; +import type { UIBuilderPluginOverrides } from "../overrides"; /** * Default loading component for PageRenderer */ function DefaultLoadingComponent(): ReactNode { + const t = useTranslate(); + const { localization } = + usePluginOverrides("ui-builder"); return (
- {uiBuilderLocalization.pageRenderer.loading} + {localization?.pageRenderer?.loading ?? + t( + "uiBuilder.pageRenderer.loading", + uiBuilderLocalization.pageRenderer.loading, + )}
); @@ -30,10 +39,17 @@ function DefaultLoadingComponent(): ReactNode { * Default error component for PageRenderer */ function DefaultErrorComponent({ error }: { error: unknown }): ReactNode { + const t = useTranslate(); + const { localization } = + usePluginOverrides("ui-builder"); return (
- {uiBuilderLocalization.pageRenderer.error} + {localization?.pageRenderer?.error ?? + t( + "uiBuilder.pageRenderer.error", + uiBuilderLocalization.pageRenderer.error, + )}
{error instanceof Error ? error.message : String(error)} @@ -46,10 +62,17 @@ function DefaultErrorComponent({ error }: { error: unknown }): ReactNode { * Default not found component for PageRenderer */ function DefaultNotFoundComponent(): ReactNode { + const t = useTranslate(); + const { localization } = + usePluginOverrides("ui-builder"); return (
- {uiBuilderLocalization.pageRenderer.notFound} + {localization?.pageRenderer?.notFound ?? + t( + "uiBuilder.pageRenderer.notFound", + uiBuilderLocalization.pageRenderer.notFound, + )}
); diff --git a/packages/stack/src/plugins/ui-builder/client/components/pages/page-builder-page.internal.tsx b/packages/stack/src/plugins/ui-builder/client/components/pages/page-builder-page.internal.tsx index adefb390..9a049fad 100644 --- a/packages/stack/src/plugins/ui-builder/client/components/pages/page-builder-page.internal.tsx +++ b/packages/stack/src/plugins/ui-builder/client/components/pages/page-builder-page.internal.tsx @@ -1,7 +1,12 @@ "use client"; import { useState, useCallback } from "react"; -import { usePluginOverrides, useBasePath } from "@btst/stack/context"; +import { + useBasePath, + useNotify, + usePluginOverrides, + useTranslate, +} from "@btst/stack/context"; import { Button } from "@workspace/ui/components/button"; import { Input } from "@workspace/ui/components/input"; import { @@ -18,7 +23,6 @@ import { } from "@workspace/ui/components/popover"; import { Label } from "@workspace/ui/components/label"; import { ArrowLeft, Save, Settings2 } from "lucide-react"; -import { toast } from "sonner"; import UIBuilder from "@workspace/ui/components/ui-builder"; import type { ComponentLayer, @@ -30,8 +34,7 @@ import { useLayerStore } from "@workspace/ui/lib/ui-builder/store/layer-store"; import { useRegisterPageAIContext } from "@btst/stack/plugins/ai-chat/client/context"; import { useSuspenseUIBuilderPage, - useCreateUIBuilderPage, - useUpdateUIBuilderPage, + useUIBuilderPageForm, } from "../../hooks/ui-builder-hooks"; import type { UIBuilderPluginOverrides } from "../../overrides"; import { uiBuilderLocalization } from "../../localization"; @@ -186,6 +189,13 @@ interface PageBuilderPageContentProps { existingPage?: SerializedUIBuilderPage | null; } +interface PageBuilderFormValues { + slug: string; + layers: ComponentLayer[]; + variables: Variable[]; + status: "published" | "draft" | "archived"; +} + /** * Parse JSON strings safely */ @@ -211,20 +221,93 @@ function PageBuilderPageContent({ id, existingPage, }: PageBuilderPageContentProps) { + const t = useTranslate(); + const notify = useNotify(); const { - navigate, Link, componentRegistry: customRegistry, functionRegistry, + localization, } = usePluginOverrides("ui-builder"); const basePath = useBasePath(); - - const createMutation = useCreateUIBuilderPage(); - const updateMutation = useUpdateUIBuilderPage(); - - const loc = uiBuilderLocalization; const LinkComponent = Link || "a"; const componentRegistry = customRegistry || defaultComponentRegistry; + const localized = ( + override: string | undefined, + key: string, + fallback: string, + ) => override ?? t(key, fallback); + const savedMessage = localized( + localization?.pageBuilder?.saved, + "uiBuilder.pageBuilder.saved", + uiBuilderLocalization.pageBuilder.saved, + ); + const saveErrorMessage = localized( + localization?.pageBuilder?.saveError, + "uiBuilder.pageBuilder.saveError", + uiBuilderLocalization.pageBuilder.saveError, + ); + const duplicateSlugMessage = localized( + localization?.pageBuilder?.duplicateSlug, + "uiBuilder.pageBuilder.duplicateSlug", + uiBuilderLocalization.pageBuilder.duplicateSlug, + ); + const loc = { + pageBuilder: { + slugLabel: localized( + localization?.pageBuilder?.slugLabel, + "uiBuilder.pageBuilder.slugLabel", + uiBuilderLocalization.pageBuilder.slugLabel, + ), + slugPlaceholder: localized( + localization?.pageBuilder?.slugPlaceholder, + "uiBuilder.pageBuilder.slugPlaceholder", + uiBuilderLocalization.pageBuilder.slugPlaceholder, + ), + statusLabel: localized( + localization?.pageBuilder?.statusLabel, + "uiBuilder.pageBuilder.statusLabel", + uiBuilderLocalization.pageBuilder.statusLabel, + ), + settingsTitle: localized( + localization?.pageBuilder?.settingsTitle, + "uiBuilder.pageBuilder.settingsTitle", + uiBuilderLocalization.pageBuilder.settingsTitle, + ), + settingsDescription: localized( + localization?.pageBuilder?.settingsDescription, + "uiBuilder.pageBuilder.settingsDescription", + uiBuilderLocalization.pageBuilder.settingsDescription, + ), + save: localized( + localization?.pageBuilder?.save, + "uiBuilder.pageBuilder.save", + uiBuilderLocalization.pageBuilder.save, + ), + saving: localized( + localization?.pageBuilder?.saving, + "uiBuilder.pageBuilder.saving", + uiBuilderLocalization.pageBuilder.saving, + ), + statusOptions: { + draft: localized( + localization?.pageBuilder?.statusOptions?.draft, + "uiBuilder.pageBuilder.statusOptions.draft", + uiBuilderLocalization.pageBuilder.statusOptions.draft, + ), + published: localized( + localization?.pageBuilder?.statusOptions?.published, + "uiBuilder.pageBuilder.statusOptions.published", + uiBuilderLocalization.pageBuilder.statusOptions.published, + ), + archived: localized( + localization?.pageBuilder?.statusOptions?.archived, + "uiBuilder.pageBuilder.statusOptions.archived", + uiBuilderLocalization.pageBuilder.statusOptions.archived, + ), + }, + }, + }; // Parse existing page data const existingLayers = parseLayers(existingPage?.parsedData?.layers); @@ -237,6 +320,27 @@ function PageBuilderPageContent({ ); const [layers, setLayers] = useState(existingLayers); const [variables, setVariables] = useState(existingVariables); + const pageForm = useUIBuilderPageForm({ + action: id ? "edit" : "create", + id, + record: existingPage, + toCreateVars: (values) => values, + toUpdateVars: (values) => ({ + id: id!, + data: { + layers: values.layers, + variables: values.variables, + status: values.status, + }, + }), + successMessage: savedMessage, + errorMessage: (error) => + error.message.includes("slug already exists") + ? duplicateSlugMessage + : saveErrorMessage, + redirect: (page, action) => + action === "create" ? `${basePath}/ui-builder/${page.id}/edit` : false, + }); // Auto-generate slug from first page name const [autoSlug, setAutoSlug] = useState(!id); @@ -292,52 +396,46 @@ function PageBuilderPageContent({ const handleSave = async () => { if (!slug.trim()) { - toast.error(loc.pageBuilder.validation.slugRequired); + notify.error( + localized( + localization?.pageBuilder?.validation?.slugRequired, + "uiBuilder.pageBuilder.validation.slugRequired", + uiBuilderLocalization.pageBuilder.validation.slugRequired, + ), + ); return; } if (!/^[a-z0-9-]+$/.test(slug)) { - toast.error(loc.pageBuilder.validation.slugFormat); + notify.error( + localized( + localization?.pageBuilder?.validation?.slugFormat, + "uiBuilder.pageBuilder.validation.slugFormat", + uiBuilderLocalization.pageBuilder.validation.slugFormat, + ), + ); return; } if (layers.length === 0) { - toast.error(loc.pageBuilder.validation.layersRequired); + notify.error( + localized( + localization?.pageBuilder?.validation?.layersRequired, + "uiBuilder.pageBuilder.validation.layersRequired", + uiBuilderLocalization.pageBuilder.validation.layersRequired, + ), + ); return; } - try { - if (id) { - await updateMutation.mutateAsync({ - id, - data: { - layers, - variables, - status, - }, - }); - toast.success(loc.pageBuilder.saved); - } else { - const newPage = await createMutation.mutateAsync({ - slug, - layers, - variables, - status, - }); - toast.success(loc.pageBuilder.saved); - navigate?.(`${basePath}/ui-builder/${newPage.id}/edit`); - } - } catch (error) { - const message = error instanceof Error ? error.message : "Unknown error"; - if (message.includes("slug already exists")) { - toast.error("A page with this slug already exists"); - } else { - toast.error(loc.pageBuilder.saveError); - } - } + await pageForm.submit({ slug, layers, variables, status }); }; - const isSaving = createMutation.isPending || updateMutation.isPending; + const isSaving = pageForm.isSubmitting; + const slugFieldError = pageForm.fieldErrors.slug; + const slugErrorMessage = Array.isArray(slugFieldError) + ? slugFieldError[0] + : slugFieldError; // Shared form fields - used in both mobile popover and desktop inline const pageSettingsFields = (isMobile: boolean) => ( @@ -356,13 +454,20 @@ function PageBuilderPageContent({ onChange={(e) => { setSlug(e.target.value); setAutoSlug(false); + pageForm.clearErrors(); }} placeholder={loc.pageBuilder.slugPlaceholder} className={ isMobile ? "h-9 font-mono text-sm" : "h-8 w-48 font-mono text-sm" } disabled={!!id} + aria-invalid={!!slugErrorMessage} /> + {slugErrorMessage && ( +

+ {slugErrorMessage} +

+ )}
@@ -417,9 +522,11 @@ function PageBuilderPageContent({
-

Page Settings

+

+ {loc.pageBuilder.settingsTitle} +

- Configure page slug and status + {loc.pageBuilder.settingsDescription}

{pageSettingsFields(true)} diff --git a/packages/stack/src/plugins/ui-builder/client/components/pages/page-builder-page.tsx b/packages/stack/src/plugins/ui-builder/client/components/pages/page-builder-page.tsx index 575c4ec9..1bb37010 100644 --- a/packages/stack/src/plugins/ui-builder/client/components/pages/page-builder-page.tsx +++ b/packages/stack/src/plugins/ui-builder/client/components/pages/page-builder-page.tsx @@ -1,9 +1,11 @@ "use client"; -import { lazy, Suspense } from "react"; +import { lazy } from "react"; +import { ComposedRoute } from "@btst/stack/client/components"; +import { usePluginOverrides } from "@btst/stack/context"; import { PageBuilderSkeleton } from "../loading/page-builder-skeleton"; -import { ErrorBoundary } from "react-error-boundary"; import { DefaultError } from "../shared/default-error"; +import type { UIBuilderPluginOverrides } from "../../overrides"; const PageBuilderPageInternal = lazy(() => import("./page-builder-page.internal").then((m) => ({ @@ -16,11 +18,30 @@ export interface PageBuilderPageProps { } export function PageBuilderPage({ id }: PageBuilderPageProps) { + const { onRouteError } = + usePluginOverrides("ui-builder"); + const path = id ? `/ui-builder/${id}/edit` : "/ui-builder/new"; + return ( - - }> - - - + null} + props={{ id }} + onError={(error) => { + onRouteError?.("pageBuilder", error, { + path, + params: id ? { id } : {}, + isSSR: typeof window === "undefined", + }); + }} + /> ); } diff --git a/packages/stack/src/plugins/ui-builder/client/components/pages/page-list-page.internal.tsx b/packages/stack/src/plugins/ui-builder/client/components/pages/page-list-page.internal.tsx index aca1f77f..34d621b5 100644 --- a/packages/stack/src/plugins/ui-builder/client/components/pages/page-list-page.internal.tsx +++ b/packages/stack/src/plugins/ui-builder/client/components/pages/page-list-page.internal.tsx @@ -1,7 +1,13 @@ "use client"; import { useState } from "react"; -import { usePluginOverrides, useBasePath } from "@btst/stack/context"; +import { + CanAccess, + useBasePath, + useNotify, + usePluginOverrides, + useTranslate, +} from "@btst/stack/context"; import { Button } from "@workspace/ui/components/button"; import { Table, @@ -28,7 +34,6 @@ import { AlertDialogTitle, } from "@workspace/ui/components/alert-dialog"; import { MoreHorizontal, Plus, Pencil, Trash2 } from "lucide-react"; -import { toast } from "sonner"; import { useSuspenseUIBuilderPages, @@ -41,16 +46,17 @@ import { EmptyState } from "../shared/empty-state"; import { Pagination } from "../shared/pagination"; export function PageListPage() { - const { navigate, Link } = + const t = useTranslate(); + const notify = useNotify(); + const { navigate, Link, localization } = usePluginOverrides("ui-builder"); const basePath = useBasePath(); - const { pages, total, hasMore, isLoadingMore, loadMore, refetch } = + const { pages, total, hasMore, isLoadingMore, loadMore } = useSuspenseUIBuilderPages(); const deleteMutation = useDeleteUIBuilderPage(); const [deleteId, setDeleteId] = useState(null); - const loc = uiBuilderLocalization; const LinkComponent = Link || "a"; const handleDelete = async () => { @@ -58,12 +64,24 @@ export function PageListPage() { try { await deleteMutation.mutateAsync(deleteId); - toast.success("Page deleted successfully"); - setDeleteId(null); - await refetch(); } catch { - toast.error("Failed to delete page"); + notify.error( + localization?.pageList?.deleteError ?? + t( + "uiBuilder.pageList.deleteError", + uiBuilderLocalization.pageList.deleteError, + ), + ); + return; } + notify.success( + localization?.pageList?.deleteSuccess ?? + t( + "uiBuilder.pageList.deleteSuccess", + uiBuilderLocalization.pageList.deleteSuccess, + ), + ); + setDeleteId(null); }; const getStatusBadge = (status: string) => { @@ -78,41 +96,74 @@ export function PageListPage() { - {loc.pageBuilder.statusOptions[ - status as keyof typeof loc.pageBuilder.statusOptions - ] || status} + {localization?.pageBuilder?.statusOptions?.[ + status as keyof typeof uiBuilderLocalization.pageBuilder.statusOptions + ] ?? + t( + `uiBuilder.pageBuilder.statusOptions.${status}`, + uiBuilderLocalization.pageBuilder.statusOptions[ + status as keyof typeof uiBuilderLocalization.pageBuilder.statusOptions + ] ?? status, + )} ); }; + const createButton = ( + + + + ); + return ( -
+
-

{loc.pageList.title}

-

{loc.pageList.description}

+

+ {localization?.pageList?.title ?? + t( + "uiBuilder.pageList.title", + uiBuilderLocalization.pageList.title, + )} +

+

+ {localization?.pageList?.description ?? + t( + "uiBuilder.pageList.description", + uiBuilderLocalization.pageList.description, + )} +

- + {createButton}
{pages.length === 0 ? ( - - - {loc.pageList.createButton} - - + title={ + localization?.pageList?.emptyState?.title ?? + t( + "uiBuilder.pageList.emptyState.title", + uiBuilderLocalization.pageList.emptyState.title, + ) + } + description={ + localization?.pageList?.emptyState?.description ?? + t( + "uiBuilder.pageList.emptyState.description", + uiBuilderLocalization.pageList.emptyState.description, + ) } + action={createButton} /> ) : ( <> @@ -120,11 +171,33 @@ export function PageListPage() { - {loc.pageList.columns.slug} - {loc.pageList.columns.status} - {loc.pageList.columns.updatedAt} + + {localization?.pageList?.columns?.slug ?? + t( + "uiBuilder.pageList.columns.slug", + uiBuilderLocalization.pageList.columns.slug, + )} + + + {localization?.pageList?.columns?.status ?? + t( + "uiBuilder.pageList.columns.status", + uiBuilderLocalization.pageList.columns.status, + )} + + + {localization?.pageList?.columns?.updatedAt ?? + t( + "uiBuilder.pageList.columns.updatedAt", + uiBuilderLocalization.pageList.columns.updatedAt, + )} + - {loc.pageList.columns.actions} + {localization?.pageList?.columns?.actions ?? + t( + "uiBuilder.pageList.columns.actions", + uiBuilderLocalization.pageList.columns.actions, + )} @@ -144,28 +217,56 @@ export function PageListPage() { - - navigate?.( - `${basePath}/ui-builder/${page.id}/edit`, - ) - } + - - {loc.pageList.actions.edit} - - setDeleteId(page.id)} + + navigate?.( + `${basePath}/ui-builder/${page.id}/edit`, + ) + } + > + + {localization?.pageList?.actions?.edit ?? + t( + "uiBuilder.pageList.actions.edit", + uiBuilderLocalization.pageList.actions.edit, + )} + + + - - {loc.pageList.actions.delete} - + setDeleteId(page.id)} + > + + {localization?.pageList?.actions?.delete ?? + t( + "uiBuilder.pageList.actions.delete", + uiBuilderLocalization.pageList.actions + .delete, + )} + + @@ -181,6 +282,26 @@ export function PageListPage() { hasMore={hasMore} isLoadingMore={isLoadingMore} onLoadMore={loadMore} + labels={{ + showing: + localization?.pageList?.pagination?.showing ?? + t( + "uiBuilder.pageList.pagination.showing", + uiBuilderLocalization.pageList.pagination.showing, + ), + next: + localization?.pageList?.pagination?.loadMore ?? + t( + "uiBuilder.pageList.pagination.loadMore", + uiBuilderLocalization.pageList.pagination.loadMore, + ), + loading: + localization?.pageList?.pagination?.loading ?? + t( + "uiBuilder.pageList.pagination.loading", + uiBuilderLocalization.pageList.pagination.loading, + ), + }} /> )} @@ -191,23 +312,43 @@ export function PageListPage() { - {loc.pageList.deleteDialog.title} + {localization?.pageList?.deleteDialog?.title ?? + t( + "uiBuilder.pageList.deleteDialog.title", + uiBuilderLocalization.pageList.deleteDialog.title, + )} - {loc.pageList.deleteDialog.description} + {localization?.pageList?.deleteDialog?.description ?? + t( + "uiBuilder.pageList.deleteDialog.description", + uiBuilderLocalization.pageList.deleteDialog.description, + )} - {loc.pageList.deleteDialog.cancel} + {localization?.pageList?.deleteDialog?.cancel ?? + t( + "uiBuilder.pageList.deleteDialog.cancel", + uiBuilderLocalization.pageList.deleteDialog.cancel, + )} {deleteMutation.isPending - ? "Deleting..." - : loc.pageList.deleteDialog.confirm} + ? (localization?.pageList?.deleteDialog?.deleting ?? + t( + "uiBuilder.pageList.deleteDialog.deleting", + uiBuilderLocalization.pageList.deleteDialog.deleting, + )) + : (localization?.pageList?.deleteDialog?.confirm ?? + t( + "uiBuilder.pageList.deleteDialog.confirm", + uiBuilderLocalization.pageList.deleteDialog.confirm, + ))} diff --git a/packages/stack/src/plugins/ui-builder/client/components/pages/page-list-page.tsx b/packages/stack/src/plugins/ui-builder/client/components/pages/page-list-page.tsx index d02f5f37..a9cb7e94 100644 --- a/packages/stack/src/plugins/ui-builder/client/components/pages/page-list-page.tsx +++ b/packages/stack/src/plugins/ui-builder/client/components/pages/page-list-page.tsx @@ -1,9 +1,11 @@ "use client"; -import { lazy, Suspense } from "react"; +import { lazy } from "react"; +import { ComposedRoute } from "@btst/stack/client/components"; +import { usePluginOverrides } from "@btst/stack/context"; import { PageListSkeleton } from "../loading/page-list-skeleton"; -import { ErrorBoundary } from "react-error-boundary"; import { DefaultError } from "../shared/default-error"; +import type { UIBuilderPluginOverrides } from "../../overrides"; const PageListPageInternal = lazy(() => import("./page-list-page.internal").then((m) => ({ @@ -12,11 +14,23 @@ const PageListPageInternal = lazy(() => ); export function PageListPage() { + const { onRouteError } = + usePluginOverrides("ui-builder"); + return ( - - }> - - - + null} + onError={(error) => { + onRouteError?.("pageList", error, { + path: "/ui-builder", + isSSR: typeof window === "undefined", + }); + }} + /> ); } diff --git a/packages/stack/src/plugins/ui-builder/client/components/shared/default-error.tsx b/packages/stack/src/plugins/ui-builder/client/components/shared/default-error.tsx index 671570f9..83b3c780 100644 --- a/packages/stack/src/plugins/ui-builder/client/components/shared/default-error.tsx +++ b/packages/stack/src/plugins/ui-builder/client/components/shared/default-error.tsx @@ -2,6 +2,9 @@ import { AlertCircle } from "lucide-react"; import { Button } from "@workspace/ui/components/button"; +import { usePluginOverrides, useTranslate } from "@btst/stack/context"; +import type { UIBuilderPluginOverrides } from "../../overrides"; +import { uiBuilderLocalization } from "../../localization"; interface DefaultErrorProps { error: unknown; @@ -9,21 +12,35 @@ interface DefaultErrorProps { } export function DefaultError({ error, resetErrorBoundary }: DefaultErrorProps) { + const t = useTranslate(); + const { localization } = + usePluginOverrides("ui-builder"); + const title = + localization?.common?.errorTitle ?? + t("uiBuilder.common.errorTitle", uiBuilderLocalization.common.errorTitle); + const unexpectedError = + localization?.common?.unexpectedError ?? + t( + "uiBuilder.common.unexpectedError", + uiBuilderLocalization.common.unexpectedError, + ); + const tryAgain = + localization?.common?.tryAgain ?? + t("uiBuilder.common.tryAgain", uiBuilderLocalization.common.tryAgain); + return (
-

- Something went wrong -

+

{title}

{(error instanceof Error ? error.message : undefined) || - "An unexpected error occurred"} + unexpectedError}

{resetErrorBoundary && ( )}
diff --git a/packages/stack/src/plugins/ui-builder/client/components/shared/pagination.tsx b/packages/stack/src/plugins/ui-builder/client/components/shared/pagination.tsx index 2e9da19e..ffb3613f 100644 --- a/packages/stack/src/plugins/ui-builder/client/components/shared/pagination.tsx +++ b/packages/stack/src/plugins/ui-builder/client/components/shared/pagination.tsx @@ -13,6 +13,7 @@ interface PaginationProps { showing?: string; previous?: string; next?: string; + loading?: string; }; } @@ -27,6 +28,7 @@ export function Pagination({ const { showing: showingLabel = "Showing {count} of {total}", next = "Load More", + loading = "Loading...", } = labels; const showingText = showingLabel @@ -43,7 +45,7 @@ export function Pagination({ onClick={onLoadMore} disabled={isLoadingMore} > - {isLoadingMore ? "Loading..." : next} + {isLoadingMore ? loading : next} )} diff --git a/packages/stack/src/plugins/ui-builder/client/hooks/index.tsx b/packages/stack/src/plugins/ui-builder/client/hooks/index.tsx index e9b426de..15ab0839 100644 --- a/packages/stack/src/plugins/ui-builder/client/hooks/index.tsx +++ b/packages/stack/src/plugins/ui-builder/client/hooks/index.tsx @@ -12,9 +12,12 @@ export { useCreateUIBuilderPage, useUpdateUIBuilderPage, useDeleteUIBuilderPage, + useUIBuilderPageForm, // Types type UseUIBuilderPagesOptions, type UseUIBuilderPagesResult, type CreateUIBuilderPageInput, type UpdateUIBuilderPageInput, + type UIBuilderPageFormValues, + type UIBuilderPageUpdateValues, } from "./ui-builder-hooks"; diff --git a/packages/stack/src/plugins/ui-builder/client/hooks/ui-builder-hooks.tsx b/packages/stack/src/plugins/ui-builder/client/hooks/ui-builder-hooks.tsx index 5c479b16..bdc0c28e 100644 --- a/packages/stack/src/plugins/ui-builder/client/hooks/ui-builder-hooks.tsx +++ b/packages/stack/src/plugins/ui-builder/client/hooks/ui-builder-hooks.tsx @@ -1,98 +1,41 @@ "use client"; -import { - useQuery, - useMutation, - useQueryClient, - useSuspenseQuery, - useInfiniteQuery, - useSuspenseInfiniteQuery, - type InfiniteData, -} from "@tanstack/react-query"; -import { createApiClient } from "@btst/stack/plugins/client"; -import { usePluginOverrides } from "@btst/stack/context"; -import type { CMSApiRouter } from "../../../cms/api"; import type { - PaginatedContentItems, - SerializedContentItemWithType, -} from "../../../cms/types"; -import { createCMSQueryKeys } from "../../../cms/query-keys"; -import { - UI_BUILDER_TYPE_SLUG, - type UIBuilderPageSchemaType, -} from "../../schemas"; -import type { UIBuilderPluginOverrides } from "../overrides"; -import type { SerializedUIBuilderPage, UIBuilderPageData } from "../../types"; + ResourceFormConfig, + ResourceFormResult, +} from "@btst/stack/plugins/client/hooks"; import type { ComponentLayer, Variable, } from "@workspace/ui/components/ui-builder/types"; +import type { + CreateUIBuilderPageInput, + UpdateUIBuilderPageInput, +} from "../../query-keys"; +import type { + PaginatedUIBuilderPages, + SerializedUIBuilderPage, +} from "../../types"; +import { uiBuilder } from "./ui-builder-resource"; -// Type guard for better-call error responses -function isErrorResponse( - response: unknown, -): response is { error: unknown; data?: never } { - if (typeof response !== "object" || response === null) { - return false; - } - const obj = response as Record; - return "error" in obj && obj.error !== null && obj.error !== undefined; -} - -// Helper to convert error to a proper Error object with meaningful message -function toError(error: unknown): Error { - if (error instanceof Error) { - return error; - } - - if (typeof error === "object" && error !== null) { - const errorObj = error as Record; - const message = - (typeof errorObj.message === "string" ? errorObj.message : null) || - (typeof errorObj.error === "string" ? errorObj.error : null) || - JSON.stringify(error); - - const err = new Error(message); - Object.assign(err, error); - return err; - } - - return new Error(String(error)); -} - -/** - * Shared React Query configuration - */ -const SHARED_QUERY_CONFIG = { - retry: false, - refetchOnWindowFocus: false, - refetchOnMount: false, - refetchOnReconnect: false, - staleTime: 1000 * 60 * 5, // 5 minutes - gcTime: 1000 * 60 * 10, // 10 minutes -} as const; +export type { + CreateUIBuilderPageInput, + UpdateUIBuilderPageInput, +} from "../../query-keys"; -/** - * Convert a CMS content item to a serialized UI Builder page - */ -function toUIBuilderPage( - item: SerializedContentItemWithType, -): SerializedUIBuilderPage { +function flattenPages(pages: PaginatedUIBuilderPages[] | undefined): { + pages: SerializedUIBuilderPage[]; + total: number; +} { return { - id: item.id, - contentTypeId: item.contentTypeId, - slug: item.slug, - data: item.data, - authorId: item.authorId, - createdAt: item.createdAt, - updatedAt: item.updatedAt, - parsedData: item.parsedData as UIBuilderPageData, + pages: + pages?.flatMap((page) => + Array.isArray(page?.items) ? page.items : [], + ) ?? [], + total: pages?.[0]?.total ?? 0, }; } -/** - * Parse UI Builder page data from JSON strings - */ function parseLayers(layersJson: string): ComponentLayer[] { try { return JSON.parse(layersJson) as ComponentLayer[]; @@ -109,12 +52,10 @@ function parseVariables(variablesJson: string): Variable[] { } } -// ========== List Hooks ========== - export interface UseUIBuilderPagesOptions { - /** Number of items per page (default: 10) */ + /** Number of items per page (default: 10). */ limit?: number; - /** Whether to enable the query (default: true) */ + /** Whether to enable the query (default: true). */ enabled?: boolean; } @@ -129,29 +70,10 @@ export interface UseUIBuilderPagesResult { refetch: () => void; } -/** - * Hook for fetching paginated UI Builder pages with load more functionality. - * - * @example - * ```typescript - * const { pages, loadMore, hasMore, isLoading } = useUIBuilderPages() - * ``` - */ export function useUIBuilderPages( options: UseUIBuilderPagesOptions = {}, ): UseUIBuilderPagesResult { - const { apiBaseURL, apiBasePath, headers } = - usePluginOverrides("ui-builder"); - const client = createApiClient({ - baseURL: apiBaseURL, - basePath: apiBasePath, - }); - const queries = createCMSQueryKeys(client, headers); const { limit = 10, enabled = true } = options; - const typeSlug = UI_BUILDER_TYPE_SLUG; - - const baseQuery = queries.cmsContent.list({ typeSlug, limit, offset: 0 }); - const { data, isLoading, @@ -160,52 +82,11 @@ export function useUIBuilderPages( hasNextPage, isFetchingNextPage, refetch, - } = useInfiniteQuery({ - queryKey: [...baseQuery.queryKey, "ui-builder"], - queryFn: async ({ pageParam = 0 }) => { - const response: unknown = await client("/content/:typeSlug", { - method: "GET", - params: { typeSlug }, - query: { limit, offset: pageParam }, - headers, - }); - if (isErrorResponse(response)) { - throw toError(response.error); - } - return (response as { data?: unknown }) - .data as PaginatedContentItems; - }, - ...SHARED_QUERY_CONFIG, - initialPageParam: 0, - getNextPageParam: (lastPage, allPages) => { - if (!lastPage || typeof lastPage !== "object") return undefined; - const items = (lastPage as PaginatedContentItems)?.items; - if (!Array.isArray(items) || items.length < limit) return undefined; - const loadedCount = (allPages || []).reduce( - (sum, page) => - sum + - (Array.isArray((page as PaginatedContentItems)?.items) - ? (page as PaginatedContentItems).items.length - : 0), - 0, - ); - const total = (lastPage as PaginatedContentItems)?.total ?? 0; - if (loadedCount >= total) return undefined; - return loadedCount; - }, - enabled: enabled, - }); - - type PageData = PaginatedContentItems; - const pagesData = (data as InfiniteData | undefined)?.pages; - const items = (pagesData?.flatMap((page) => - Array.isArray(page?.items) ? page.items : [], - ) ?? []) as SerializedContentItemWithType[]; - const total = pagesData?.[0]?.total ?? 0; + } = uiBuilder.cmsContent.list.useInfinite([{ limit }], { enabled }); + const flattened = flattenPages(data?.pages); return { - pages: items.map(toUIBuilderPage), - total, + ...flattened, isLoading, error, loadMore: fetchNextPage, @@ -215,9 +96,6 @@ export function useUIBuilderPages( }; } -/** - * Suspense variant of useUIBuilderPages - */ export function useSuspenseUIBuilderPages( options: UseUIBuilderPagesOptions = {}, ): { @@ -228,75 +106,13 @@ export function useSuspenseUIBuilderPages( isLoadingMore: boolean; refetch: () => Promise; } { - const { apiBaseURL, apiBasePath, headers } = - usePluginOverrides("ui-builder"); - const client = createApiClient({ - baseURL: apiBaseURL, - basePath: apiBasePath, - }); - const queries = createCMSQueryKeys(client, headers); const { limit = 10 } = options; - const typeSlug = UI_BUILDER_TYPE_SLUG; - - const baseQuery = queries.cmsContent.list({ typeSlug, limit, offset: 0 }); - - const { - data, - fetchNextPage, - hasNextPage, - isFetchingNextPage, - refetch, - error, - isFetching, - } = useSuspenseInfiniteQuery({ - queryKey: [...baseQuery.queryKey, "ui-builder"], - queryFn: async ({ pageParam = 0 }) => { - const response: unknown = await client("/content/:typeSlug", { - method: "GET", - params: { typeSlug }, - query: { limit, offset: pageParam }, - headers, - }); - if (isErrorResponse(response)) { - throw toError(response.error); - } - return (response as { data?: unknown }) - .data as PaginatedContentItems; - }, - ...SHARED_QUERY_CONFIG, - initialPageParam: 0, - getNextPageParam: (lastPage, allPages) => { - if (!lastPage || typeof lastPage !== "object") return undefined; - const items = (lastPage as PaginatedContentItems)?.items; - if (!Array.isArray(items) || items.length < limit) return undefined; - const loadedCount = (allPages || []).reduce( - (sum, page) => - sum + - (Array.isArray((page as PaginatedContentItems)?.items) - ? (page as PaginatedContentItems).items.length - : 0), - 0, - ); - const total = (lastPage as PaginatedContentItems)?.total ?? 0; - if (loadedCount >= total) return undefined; - return loadedCount; - }, - }); - - if (error && !isFetching) { - throw error; - } - - type PageData = PaginatedContentItems; - const pagesData = data.pages as PageData[]; - const items = (pagesData?.flatMap((page) => - Array.isArray(page?.items) ? page.items : [], - ) ?? []) as SerializedContentItemWithType[]; - const total = pagesData?.[0]?.total ?? 0; + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, refetch } = + uiBuilder.cmsContent.list.useSuspenseInfinite([{ limit }]); + const flattened = flattenPages(data.pages); return { - pages: items.map(toUIBuilderPage), - total, + ...flattened, loadMore: fetchNextPage, hasMore: !!hasNextPage, isLoadingMore: isFetchingNextPage, @@ -304,94 +120,27 @@ export function useSuspenseUIBuilderPages( }; } -// ========== Single Page Hooks ========== - -/** - * Hook for fetching a single UI Builder page by ID - * - * @example - * ```typescript - * const { page, isLoading, error } = useUIBuilderPage(pageId) - * ``` - */ export function useUIBuilderPage(id: string): { page: SerializedUIBuilderPage | null; isLoading: boolean; error: Error | null; refetch: () => void; } { - const { apiBaseURL, apiBasePath, headers } = - usePluginOverrides("ui-builder"); - const client = createApiClient({ - baseURL: apiBaseURL, - basePath: apiBasePath, - }); - const queries = createCMSQueryKeys(client, headers); - const typeSlug = UI_BUILDER_TYPE_SLUG; - const baseQuery = queries.cmsContent.detail(typeSlug, id); - - const { data, isLoading, error, refetch } = useQuery({ - ...baseQuery, - ...SHARED_QUERY_CONFIG, - enabled: !!id, - }); - - return { - page: data - ? toUIBuilderPage( - data as SerializedContentItemWithType, - ) - : null, - isLoading, - error, - refetch, - }; + const { data, isLoading, error, refetch } = uiBuilder.cmsContent.detail.use( + [id], + { enabled: !!id }, + ); + return { page: data ?? null, isLoading, error, refetch }; } -/** - * Suspense variant of useUIBuilderPage - */ export function useSuspenseUIBuilderPage(id: string): { page: SerializedUIBuilderPage | null; refetch: () => Promise; } { - const { apiBaseURL, apiBasePath, headers } = - usePluginOverrides("ui-builder"); - const client = createApiClient({ - baseURL: apiBaseURL, - basePath: apiBasePath, - }); - const queries = createCMSQueryKeys(client, headers); - const typeSlug = UI_BUILDER_TYPE_SLUG; - const baseQuery = queries.cmsContent.detail(typeSlug, id); - - const { data, refetch, error, isFetching } = useSuspenseQuery({ - ...baseQuery, - ...SHARED_QUERY_CONFIG, - }); - - if (error && !isFetching) { - throw error; - } - - return { - page: data - ? toUIBuilderPage( - data as SerializedContentItemWithType, - ) - : null, - refetch, - }; + const { data, refetch } = uiBuilder.cmsContent.detail.useSuspense([id]); + return { page: data ?? null, refetch }; } -/** - * Hook for fetching a UI Builder page by slug - * - * @example - * ```typescript - * const { page, isLoading, error } = useUIBuilderPageBySlug("my-page") - * ``` - */ export function useUIBuilderPageBySlug(slug: string): { page: SerializedUIBuilderPage | null; layers: ComponentLayer[]; @@ -400,28 +149,11 @@ export function useUIBuilderPageBySlug(slug: string): { error: Error | null; refetch: () => void; } { - const { apiBaseURL, apiBasePath, headers } = - usePluginOverrides("ui-builder"); - const client = createApiClient({ - baseURL: apiBaseURL, - basePath: apiBasePath, - }); - const queries = createCMSQueryKeys(client, headers); - const typeSlug = UI_BUILDER_TYPE_SLUG; - const baseQuery = queries.cmsContent.bySlug(typeSlug, slug); - - const { data, isLoading, error, refetch } = useQuery({ - ...baseQuery, - ...SHARED_QUERY_CONFIG, - enabled: !!slug, - }); - - const page = data - ? toUIBuilderPage( - data as SerializedContentItemWithType, - ) - : null; - + const { data, isLoading, error, refetch } = uiBuilder.cmsContent.bySlug.use( + [slug], + { enabled: !!slug }, + ); + const page = data ?? null; return { page, layers: page ? parseLayers(page.parsedData.layers) : [], @@ -432,40 +164,14 @@ export function useUIBuilderPageBySlug(slug: string): { }; } -/** - * Suspense variant of useUIBuilderPageBySlug - */ export function useSuspenseUIBuilderPageBySlug(slug: string): { page: SerializedUIBuilderPage | null; layers: ComponentLayer[]; variables: Variable[]; refetch: () => Promise; } { - const { apiBaseURL, apiBasePath, headers } = - usePluginOverrides("ui-builder"); - const client = createApiClient({ - baseURL: apiBaseURL, - basePath: apiBasePath, - }); - const queries = createCMSQueryKeys(client, headers); - const typeSlug = UI_BUILDER_TYPE_SLUG; - const baseQuery = queries.cmsContent.bySlug(typeSlug, slug); - - const { data, refetch, error, isFetching } = useSuspenseQuery({ - ...baseQuery, - ...SHARED_QUERY_CONFIG, - }); - - if (error && !isFetching) { - throw error; - } - - const page = data - ? toUIBuilderPage( - data as SerializedContentItemWithType, - ) - : null; - + const { data, refetch } = uiBuilder.cmsContent.bySlug.useSuspense([slug]); + const page = data ?? null; return { page, layers: page ? parseLayers(page.parsedData.layers) : [], @@ -474,218 +180,39 @@ export function useSuspenseUIBuilderPageBySlug(slug: string): { }; } -// ========== Mutation Hooks ========== - -export interface CreateUIBuilderPageInput { - slug: string; - layers: ComponentLayer[]; - variables?: Variable[]; - status?: "published" | "draft" | "archived"; +export function useCreateUIBuilderPage() { + return uiBuilder.cmsContent.create.use(); } -export interface UpdateUIBuilderPageInput { - slug?: string; - layers?: ComponentLayer[]; - variables?: Variable[]; - status?: "published" | "draft" | "archived"; +export function useUpdateUIBuilderPage() { + return uiBuilder.cmsContent.update.use(); } -/** - * Hook for creating a UI Builder page - * - * @example - * ```typescript - * const createPage = useCreateUIBuilderPage() - * - * createPage.mutate({ - * slug: "my-new-page", - * layers: [...], - * status: "draft" - * }) - * ``` - */ -export function useCreateUIBuilderPage() { - const { refresh, apiBaseURL, apiBasePath, headers } = - usePluginOverrides("ui-builder"); - const client = createApiClient({ - baseURL: apiBaseURL, - basePath: apiBasePath, - }); - const queryClient = useQueryClient(); - const queries = createCMSQueryKeys(client, headers); - const typeSlug = UI_BUILDER_TYPE_SLUG; - - return useMutation({ - mutationKey: [...queries.cmsContent._def, typeSlug, "create", "ui-builder"], - mutationFn: async (input) => { - const data: UIBuilderPageSchemaType = { - layers: JSON.stringify(input.layers), - variables: JSON.stringify(input.variables ?? []), - status: input.status ?? "draft", - }; - - const response: unknown = await client("@post/content/:typeSlug", { - method: "POST", - params: { typeSlug }, - body: { slug: input.slug, data }, - headers, - }); - - if (isErrorResponse(response)) { - throw toError(response.error); - } - - return toUIBuilderPage( - (response as { data?: unknown }) - .data as SerializedContentItemWithType, - ); - }, - onSuccess: async () => { - await queryClient.invalidateQueries({ - queryKey: queries.cmsContent.list._def, - }); - await queryClient.invalidateQueries({ - queryKey: queries.cmsTypes.list._def, - }); - if (refresh) { - await refresh(); - } - }, - }); +export function useDeleteUIBuilderPage() { + return uiBuilder.cmsContent.delete.use(); } /** - * Hook for updating a UI Builder page - * - * @example - * ```typescript - * const updatePage = useUpdateUIBuilderPage() - * - * updatePage.mutate({ - * id: pageId, - * data: { - * layers: updatedLayers, - * status: "published" - * } - * }) - * ``` + * Create/edit lifecycle for the UI Builder page editor: selects the mutation, + * awaits invalidation, notifies, redirects, and exposes server field errors. */ -export function useUpdateUIBuilderPage() { - const { refresh, apiBaseURL, apiBasePath, headers } = - usePluginOverrides("ui-builder"); - const client = createApiClient({ - baseURL: apiBaseURL, - basePath: apiBasePath, - }); - const queryClient = useQueryClient(); - const queries = createCMSQueryKeys(client, headers); - const typeSlug = UI_BUILDER_TYPE_SLUG; - - return useMutation< +export function useUIBuilderPageForm( + config: ResourceFormConfig< + TValues, + SerializedUIBuilderPage | null, + SerializedUIBuilderPage + >, +): ResourceFormResult< + TValues, + SerializedUIBuilderPage | null, + SerializedUIBuilderPage +> { + return uiBuilder.cmsContent.useForm< + TValues, SerializedUIBuilderPage, - Error, - { id: string; data: UpdateUIBuilderPageInput } - >({ - mutationKey: [...queries.cmsContent._def, typeSlug, "update", "ui-builder"], - mutationFn: async ({ id, data: input }) => { - const data: Partial = {}; - - if (input.layers !== undefined) { - data.layers = JSON.stringify(input.layers); - } - if (input.variables !== undefined) { - data.variables = JSON.stringify(input.variables); - } - if (input.status !== undefined) { - data.status = input.status; - } - - const body: { slug?: string; data?: Partial } = - {}; - if (input.slug !== undefined) { - body.slug = input.slug; - } - if (Object.keys(data).length > 0) { - body.data = data; - } - - const response: unknown = await client("@put/content/:typeSlug/:id", { - method: "PUT", - params: { typeSlug, id }, - body, - headers, - }); - - if (isErrorResponse(response)) { - throw toError(response.error); - } - - return toUIBuilderPage( - (response as { data?: unknown }) - .data as SerializedContentItemWithType, - ); - }, - onSuccess: async (updated) => { - if (updated) { - queryClient.setQueryData( - queries.cmsContent.detail(typeSlug, updated.id).queryKey, - updated, - ); - } - await queryClient.invalidateQueries({ - queryKey: queries.cmsContent.list._def, - }); - if (refresh) { - await refresh(); - } - }, - }); + SerializedUIBuilderPage | null + >(config); } -/** - * Hook for deleting a UI Builder page - * - * @example - * ```typescript - * const deletePage = useDeleteUIBuilderPage() - * - * deletePage.mutate(pageId) - * ``` - */ -export function useDeleteUIBuilderPage() { - const { refresh, apiBaseURL, apiBasePath, headers } = - usePluginOverrides("ui-builder"); - const client = createApiClient({ - baseURL: apiBaseURL, - basePath: apiBasePath, - }); - const queryClient = useQueryClient(); - const queries = createCMSQueryKeys(client, headers); - const typeSlug = UI_BUILDER_TYPE_SLUG; - - return useMutation<{ success: boolean }, Error, string>({ - mutationKey: [...queries.cmsContent._def, typeSlug, "delete", "ui-builder"], - mutationFn: async (id) => { - const response: unknown = await client("@delete/content/:typeSlug/:id", { - method: "DELETE", - params: { typeSlug, id }, - headers, - }); - if (isErrorResponse(response)) { - throw toError(response.error); - } - return (response as { data?: unknown }).data as { success: boolean }; - }, - onSuccess: async () => { - await queryClient.invalidateQueries({ - queryKey: queries.cmsContent._def, - }); - await queryClient.invalidateQueries({ - queryKey: queries.cmsTypes.list._def, - }); - if (refresh) { - await refresh(); - } - }, - }); -} +export type UIBuilderPageFormValues = CreateUIBuilderPageInput; +export type UIBuilderPageUpdateValues = UpdateUIBuilderPageInput; diff --git a/packages/stack/src/plugins/ui-builder/client/hooks/ui-builder-resource.ts b/packages/stack/src/plugins/ui-builder/client/hooks/ui-builder-resource.ts new file mode 100644 index 00000000..a9f97686 --- /dev/null +++ b/packages/stack/src/plugins/ui-builder/client/hooks/ui-builder-resource.ts @@ -0,0 +1,10 @@ +"use client"; + +import { createResource } from "@btst/stack/plugins/client/hooks"; +import { uiBuilderResources } from "../../query-keys"; + +/** Internal generated resource; public hooks remain in ui-builder-hooks.tsx. */ +export const uiBuilder = createResource({ + plugin: "ui-builder", + resources: uiBuilderResources, +}); diff --git a/packages/stack/src/plugins/ui-builder/client/index.ts b/packages/stack/src/plugins/ui-builder/client/index.ts index fe043c3b..d80f9f68 100644 --- a/packages/stack/src/plugins/ui-builder/client/index.ts +++ b/packages/stack/src/plugins/ui-builder/client/index.ts @@ -47,17 +47,21 @@ export { useCreateUIBuilderPage, useUpdateUIBuilderPage, useDeleteUIBuilderPage, + useUIBuilderPageForm, // Types type UseUIBuilderPagesOptions, type UseUIBuilderPagesResult, type CreateUIBuilderPageInput, type UpdateUIBuilderPageInput, + type UIBuilderPageFormValues, + type UIBuilderPageUpdateValues, } from "./hooks/ui-builder-hooks"; // Localization export { uiBuilderLocalization, type UIBuilderLocalization, + type UIBuilderLocalizationOverrides, } from "./localization"; // Re-export types diff --git a/packages/stack/src/plugins/ui-builder/client/localization/index.ts b/packages/stack/src/plugins/ui-builder/client/localization/index.ts index 6ac082fc..8f7792ca 100644 --- a/packages/stack/src/plugins/ui-builder/client/localization/index.ts +++ b/packages/stack/src/plugins/ui-builder/client/localization/index.ts @@ -1,7 +1,73 @@ -/** - * UI Builder plugin localization strings - */ -export const uiBuilderLocalization = { +export interface UIBuilderLocalization { + pageList: { + title: string; + description: string; + createButton: string; + emptyState: { title: string; description: string }; + columns: { + name: string; + slug: string; + status: string; + updatedAt: string; + actions: string; + }; + actions: { label: string; edit: string; delete: string }; + deleteDialog: { + title: string; + description: string; + cancel: string; + confirm: string; + deleting: string; + }; + pagination: { + showing: string; + loadMore: string; + loading: string; + }; + deleteSuccess: string; + deleteError: string; + }; + pageBuilder: { + newPage: string; + editPage: string; + backToList: string; + save: string; + saving: string; + saved: string; + saveError: string; + duplicateSlug: string; + slugLabel: string; + slugPlaceholder: string; + slugDescription: string; + statusLabel: string; + settingsTitle: string; + settingsDescription: string; + statusOptions: { + draft: string; + published: string; + archived: string; + }; + validation: { + slugRequired: string; + slugFormat: string; + layersRequired: string; + }; + }; + pageRenderer: { loading: string; notFound: string; error: string }; + common: { + errorTitle: string; + unexpectedError: string; + tryAgain: string; + }; +} + +type DeepPartial = { + [P in keyof T]?: T[P] extends object ? DeepPartial : T[P]; +}; + +export type UIBuilderLocalizationOverrides = DeepPartial; + +export const uiBuilderLocalization: UIBuilderLocalization = { pageList: { title: "UI Builder Pages", description: @@ -18,17 +84,22 @@ export const uiBuilderLocalization = { updatedAt: "Updated", actions: "Actions", }, - actions: { - edit: "Edit", - delete: "Delete", - }, + actions: { label: "Actions", edit: "Edit", delete: "Delete" }, deleteDialog: { title: "Delete Page", description: "Are you sure you want to delete this page? This action cannot be undone.", cancel: "Cancel", confirm: "Delete", + deleting: "Deleting...", }, + pagination: { + showing: "Showing {count} of {total}", + loadMore: "Load More", + loading: "Loading...", + }, + deleteSuccess: "Page deleted successfully", + deleteError: "Failed to delete page", }, pageBuilder: { newPage: "New Page", @@ -38,10 +109,13 @@ export const uiBuilderLocalization = { saving: "Saving...", saved: "Saved", saveError: "Failed to save", + duplicateSlug: "A page with this slug already exists", slugLabel: "Page Slug", slugPlaceholder: "my-page-slug", slugDescription: "URL-friendly identifier for this page", statusLabel: "Status", + settingsTitle: "Page Settings", + settingsDescription: "Configure page slug and status", statusOptions: { draft: "Draft", published: "Published", @@ -59,6 +133,9 @@ export const uiBuilderLocalization = { notFound: "Page not found", error: "Failed to load page", }, -} as const; - -export type UIBuilderLocalization = typeof uiBuilderLocalization; + common: { + errorTitle: "Something went wrong", + unexpectedError: "An unexpected error occurred", + tryAgain: "Try again", + }, +}; diff --git a/packages/stack/src/plugins/ui-builder/client/overrides.ts b/packages/stack/src/plugins/ui-builder/client/overrides.ts index 3d19fc87..5f46803f 100644 --- a/packages/stack/src/plugins/ui-builder/client/overrides.ts +++ b/packages/stack/src/plugins/ui-builder/client/overrides.ts @@ -4,6 +4,7 @@ import type { FunctionRegistry, } from "@workspace/ui/components/ui-builder/types"; import type { UIBuilderClientHooks } from "../types"; +import type { UIBuilderLocalizationOverrides } from "./localization"; /** * Context passed to lifecycle hooks @@ -72,6 +73,9 @@ export interface UIBuilderPluginOverrides { */ functionRegistry?: FunctionRegistry; + /** Localization overrides for built-in UI Builder plugin pages. */ + localization?: UIBuilderLocalizationOverrides; + /** * Base path for UI Builder admin pages (default: /pages/ui-builder) */ diff --git a/packages/stack/src/plugins/ui-builder/client/plugin.tsx b/packages/stack/src/plugins/ui-builder/client/plugin.tsx index ac94b54c..c1678381 100644 --- a/packages/stack/src/plugins/ui-builder/client/plugin.tsx +++ b/packages/stack/src/plugins/ui-builder/client/plugin.tsx @@ -10,9 +10,8 @@ import { defineRoute, defineRoutes } from "@btst/yar"; import type { ComponentType } from "react"; import type { QueryClient } from "@tanstack/react-query"; import type { CMSApiRouter } from "../../cms/api"; -import { createCMSQueryKeys } from "../../cms/query-keys"; import { createSanitizedSSRLoaderError } from "../../utils"; -import { UI_BUILDER_TYPE_SLUG } from "../schemas"; +import { createUIBuilderQueryKeys } from "../query-keys"; import type { UIBuilderClientHooks, LoaderContext, @@ -74,8 +73,6 @@ function createPageListLoader(config: UIBuilderClientConfig) { return async () => { if (typeof window === "undefined") { const { queryClient, apiBasePath, apiBaseURL, headers, hooks } = config; - const typeSlug = UI_BUILDER_TYPE_SLUG; - const context: LoaderContext = { path: "/ui-builder", isSSR: true, @@ -87,14 +84,8 @@ function createPageListLoader(config: UIBuilderClientConfig) { baseURL: apiBaseURL, basePath: apiBasePath, }); - const queries = createCMSQueryKeys(client, headers); - const limit = 20; - const listQuery = queries.cmsContent.list({ - typeSlug, - limit, - offset: 0, - }); - const uiBuilderListQueryKey = [...listQuery.queryKey, "ui-builder"]; + const queries = createUIBuilderQueryKeys(client, headers); + const listQuery = queries.cmsContent.list({ limit: 10, offset: 0 }); try { // Before hook - authorization check @@ -107,24 +98,7 @@ function createPageListLoader(config: UIBuilderClientConfig) { // Prefetch pages using infinite query await queryClient.prefetchInfiniteQuery({ - queryKey: uiBuilderListQueryKey, - queryFn: async ({ pageParam = 0 }) => { - const response: unknown = await client("/content/:typeSlug", { - method: "GET", - params: { typeSlug }, - query: { limit, offset: pageParam }, - headers, - }); - if ( - typeof response === "object" && - response !== null && - "error" in response && - response.error - ) { - throw new Error(String(response.error)); - } - return (response as { data?: unknown }).data; - }, + ...listQuery, initialPageParam: 0, }); @@ -134,9 +108,7 @@ function createPageListLoader(config: UIBuilderClientConfig) { } // Check if there was an error - const queryState = queryClient.getQueryState([ - ...uiBuilderListQueryKey, - ]); + const queryState = queryClient.getQueryState(listQuery.queryKey); if (queryState?.error && hooks?.onLoadError) { const error = queryState.error instanceof Error @@ -149,12 +121,12 @@ function createPageListLoader(config: UIBuilderClientConfig) { if (isConnectionError(error)) { console.warn( "[btst/ui-builder] route.loader() failed — no server running at build time. " + - "Use myStack.api.uiBuilder.prefetchForRoute() for SSG data prefetching.", + "Use myStack.api.cms.prefetchForRoute() for SSG data prefetching.", ); } else { const errToStore = createSanitizedSSRLoaderError(); await queryClient.prefetchInfiniteQuery({ - queryKey: uiBuilderListQueryKey, + queryKey: listQuery.queryKey, queryFn: () => { throw errToStore; }, @@ -180,8 +152,6 @@ function createPageBuilderLoader( return async () => { if (typeof window === "undefined") { const { queryClient, apiBasePath, apiBaseURL, headers, hooks } = config; - const typeSlug = UI_BUILDER_TYPE_SLUG; - const context: LoaderContext = { path: id ? `/ui-builder/${id}/edit` : "/ui-builder/new", params: id ? { id } : {}, @@ -194,10 +164,8 @@ function createPageBuilderLoader( baseURL: apiBaseURL, basePath: apiBasePath, }); - const queries = createCMSQueryKeys(client, headers); - const pageQuery = id - ? queries.cmsContent.detail(typeSlug, id) - : undefined; + const queries = createUIBuilderQueryKeys(client, headers); + const pageQuery = id ? queries.cmsContent.detail(id) : undefined; try { // Before hook - authorization check @@ -234,7 +202,7 @@ function createPageBuilderLoader( if (isConnectionError(error)) { console.warn( "[btst/ui-builder] route.loader() failed — no server running at build time. " + - "Use myStack.api.uiBuilder.prefetchForRoute() for SSG data prefetching.", + "Use myStack.api.cms.prefetchForRoute() for SSG data prefetching.", ); } else if (pageQuery) { const errToStore = createSanitizedSSRLoaderError(); @@ -277,17 +245,15 @@ function createPageBuilderMeta( ) { return () => { const { queryClient, apiBasePath, apiBaseURL, headers } = config; - const typeSlug = UI_BUILDER_TYPE_SLUG; - let pageSlug = ""; if (id) { const client = createApiClient({ baseURL: apiBaseURL, basePath: apiBasePath, }); - const queries = createCMSQueryKeys(client, headers); + const queries = createUIBuilderQueryKeys(client, headers); const page = queryClient.getQueryData( - queries.cmsContent.detail(typeSlug, id).queryKey, + queries.cmsContent.detail(id).queryKey, ) as { slug: string } | undefined; pageSlug = page?.slug || ""; } diff --git a/packages/stack/src/plugins/ui-builder/query-keys.ts b/packages/stack/src/plugins/ui-builder/query-keys.ts new file mode 100644 index 00000000..ee42662c --- /dev/null +++ b/packages/stack/src/plugins/ui-builder/query-keys.ts @@ -0,0 +1,216 @@ +import { + createApiClient, + createResourceQueryKeys, + type ResourcesDeclaration, +} from "@btst/stack/plugins/client"; +import type { + ComponentLayer, + Variable, +} from "@workspace/ui/components/ui-builder/types"; +import type { CMSApiRouter } from "../cms/api"; +import { contentListDiscriminator } from "../cms/api/query-key-defs"; +import type { + PaginatedContentItems, + SerializedContentItemWithType, +} from "../cms/types"; +import { UI_BUILDER_TYPE_SLUG, type UIBuilderPageSchemaType } from "./schemas"; +import type { + PaginatedUIBuilderPages, + SerializedUIBuilderPage, + UIBuilderPageData, +} from "./types"; + +export interface UIBuilderPageListParams { + /** Number of items per page (default: 10). */ + limit?: number; + /** Included in the cache discriminator; infinite queries start at zero. */ + offset?: number; +} + +export interface CreateUIBuilderPageInput { + slug: string; + layers: ComponentLayer[]; + variables?: Variable[]; + status?: "published" | "draft" | "archived"; +} + +export interface UpdateUIBuilderPageInput { + slug?: string; + layers?: ComponentLayer[]; + variables?: Variable[]; + status?: "published" | "draft" | "archived"; +} + +function toUIBuilderPage( + item: SerializedContentItemWithType, +): SerializedUIBuilderPage { + return { + ...item, + parsedData: item.parsedData as UIBuilderPageData, + }; +} + +function toUIBuilderPageList( + data: PaginatedContentItems, +): PaginatedUIBuilderPages { + return { + ...data, + items: data.items.map(toUIBuilderPage), + }; +} + +function paginatedNextPageParam( + lastPage: PaginatedUIBuilderPages, + allPages: PaginatedUIBuilderPages[], + params: UIBuilderPageListParams, +): number | undefined { + const limit = params.limit ?? 10; + const items = Array.isArray(lastPage?.items) ? lastPage.items : []; + if (items.length < limit) return undefined; + + const loadedCount = allPages.reduce( + (sum, page) => sum + (Array.isArray(page?.items) ? page.items.length : 0), + 0, + ); + if (loadedCount >= (lastPage?.total ?? 0)) return undefined; + return loadedCount; +} + +function serializePageData(input: UpdateUIBuilderPageInput) { + const data: Partial = {}; + if (input.layers !== undefined) data.layers = JSON.stringify(input.layers); + if (input.variables !== undefined) { + data.variables = JSON.stringify(input.variables); + } + if (input.status !== undefined) data.status = input.status; + return data; +} + +/** + * UI Builder resource declaration. The `cmsContent` resource name and key + * discriminators intentionally match the CMS plugin because UI Builder pages + * are CMS content items. This keeps CMS, SSR-loader, and UI Builder caches in + * sync without duplicating the CMS hook implementation. + */ +export const uiBuilderResources = { + cmsContent: { + queries: { + list: { + path: "/content/:typeSlug", + params: (_params: UIBuilderPageListParams = {}) => ({ + typeSlug: UI_BUILDER_TYPE_SLUG, + }), + query: (params: UIBuilderPageListParams = {}) => ({ + limit: params.limit ?? 10, + }), + key: (params: UIBuilderPageListParams = {}) => [ + contentListDiscriminator({ + typeSlug: UI_BUILDER_TYPE_SLUG, + limit: params.limit ?? 10, + offset: params.offset ?? 0, + }), + ], + select: ( + data: PaginatedContentItems, + ): PaginatedUIBuilderPages => toUIBuilderPageList(data), + infinite: true, + pageSize: (params: UIBuilderPageListParams = {}) => params.limit ?? 10, + nextPageParam: paginatedNextPageParam, + }, + + detail: { + path: "/content/:typeSlug/:id", + params: (id: string) => ({ typeSlug: UI_BUILDER_TYPE_SLUG, id }), + key: (id: string) => [UI_BUILDER_TYPE_SLUG, id], + select: ( + data: SerializedContentItemWithType | null, + ): SerializedUIBuilderPage | null => + data ? toUIBuilderPage(data) : null, + skip: (id: string) => !id, + }, + + bySlug: { + path: "/content/:typeSlug", + params: (_slug: string) => ({ typeSlug: UI_BUILDER_TYPE_SLUG }), + query: (slug: string) => ({ slug, limit: 1 }), + key: (slug: string) => ["bySlug", UI_BUILDER_TYPE_SLUG, slug], + select: ( + data: PaginatedContentItems, + ): SerializedUIBuilderPage | null => + data?.items?.[0] ? toUIBuilderPage(data.items[0]) : null, + skip: (slug: string) => !slug, + }, + }, + + mutations: { + create: { + path: "@post/content/:typeSlug", + method: "POST" as const, + input: (input: CreateUIBuilderPageInput) => ({ + params: { typeSlug: UI_BUILDER_TYPE_SLUG }, + body: { + slug: input.slug, + data: { + layers: JSON.stringify(input.layers), + variables: JSON.stringify(input.variables ?? []), + status: input.status ?? "draft", + } satisfies UIBuilderPageSchemaType, + }, + }), + select: ( + data: SerializedContentItemWithType, + ) => toUIBuilderPage(data), + invalidates: ["cmsContent.list", "cmsTypes.list"], + refetchType: "all" as const, + setData: { + query: "detail", + args: (created: SerializedUIBuilderPage) => [created.id], + }, + }, + + update: { + path: "@put/content/:typeSlug/:id", + method: "PUT" as const, + input: (vars: { id: string; data: UpdateUIBuilderPageInput }) => { + const data = serializePageData(vars.data); + return { + params: { typeSlug: UI_BUILDER_TYPE_SLUG, id: vars.id }, + body: { + ...(vars.data.slug !== undefined ? { slug: vars.data.slug } : {}), + ...(Object.keys(data).length > 0 ? { data } : {}), + }, + }; + }, + select: ( + data: SerializedContentItemWithType, + ) => toUIBuilderPage(data), + invalidates: ["cmsContent.list"], + refetchType: "all" as const, + setData: { + query: "detail", + args: (updated: SerializedUIBuilderPage) => [updated.id], + }, + }, + + delete: { + path: "@delete/content/:typeSlug/:id", + method: "DELETE" as const, + input: (id: string) => ({ + params: { typeSlug: UI_BUILDER_TYPE_SLUG, id }, + }), + select: (data: { success: boolean }) => data, + invalidates: ["cmsContent", "cmsTypes.list"], + refetchType: "all" as const, + }, + }, + }, +} satisfies ResourcesDeclaration; + +export function createUIBuilderQueryKeys( + client: ReturnType>, + headers?: HeadersInit, +) { + return createResourceQueryKeys(client, uiBuilderResources, headers); +} + +export type UIBuilderQueryKeys = ReturnType; From f1649b0c9d15afa26c4c9a0ad4368bbf51648741 Mon Sep 17 00:00:00 2001 From: olliethedev <5933733+olliethedev@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:13:00 +0000 Subject: [PATCH 2/2] chore: retrigger preview deployment