- {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 = (
+
+
+
+
+ {localization?.pageList?.createButton ??
+ t(
+ "uiBuilder.pageList.createButton",
+ uiBuilderLocalization.pageList.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,
+ )}
+
-
-
-
- {loc.pageList.createButton}
-
-
+ {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() {
-
- Actions
+
+
+ {localization?.pageList?.actions?.label ??
+ t(
+ "uiBuilder.pageList.actions.label",
+ uiBuilderLocalization.pageList.actions
+ .label,
+ )}
+
-
- 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 && (
- Try again
+ {tryAgain}
)}
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;