From 61a84c3fac52d0dd0e7dfe27483c9ffb6c086e12 Mon Sep 17 00:00:00 2001 From: Devansh Jagtap Date: Fri, 25 Sep 2026 21:36:43 +0530 Subject: [PATCH 1/3] feat(plugins): add classNames overrides for targeted styling --- docs/content/docs/plugins/form-builder.mdx | 1 + .../__tests__/classnames-overrides.test.tsx | 248 ++++++++++++++++++ .../ai-chat/client/components/chat-input.tsx | 7 +- .../client/components/chat-interface.tsx | 3 + .../ai-chat/client/components/chat-layout.tsx | 11 +- .../client/components/chat-message.tsx | 18 +- .../client/components/chat-sidebar.tsx | 8 +- .../src/plugins/ai-chat/client/overrides.ts | 21 ++ .../__tests__/classnames-overrides.test.tsx | 186 +++++++++++++ .../client/components/shared/page-wrapper.tsx | 5 +- .../client/components/shared/post-card.tsx | 41 ++- .../client/components/shared/posts-list.tsx | 5 +- .../src/plugins/blog/client/overrides.ts | 12 + .../__tests__/classnames-overrides.test.tsx | 200 ++++++++++++++ .../pages/content-list-page.internal.tsx | 21 +- .../pages/dashboard-page.internal.tsx | 17 +- .../client/components/shared/page-wrapper.tsx | 5 +- .../stack/src/plugins/cms/client/overrides.ts | 19 ++ .../__tests__/classnames-overrides.test.tsx | 214 +++++++++++++++ .../client/components/forms/form-renderer.tsx | 13 +- .../pages/form-list-page.internal.tsx | 14 +- .../client/components/shared/page-wrapper.tsx | 5 +- .../client/components/shared/pagination.tsx | 8 +- .../plugins/form-builder/client/overrides.ts | 17 ++ 24 files changed, 1053 insertions(+), 46 deletions(-) create mode 100644 packages/stack/src/plugins/ai-chat/__tests__/classnames-overrides.test.tsx create mode 100644 packages/stack/src/plugins/blog/__tests__/classnames-overrides.test.tsx create mode 100644 packages/stack/src/plugins/cms/__tests__/classnames-overrides.test.tsx create mode 100644 packages/stack/src/plugins/form-builder/__tests__/classnames-overrides.test.tsx diff --git a/docs/content/docs/plugins/form-builder.mdx b/docs/content/docs/plugins/form-builder.mdx index 9dc116efa..d2ca530e2 100644 --- a/docs/content/docs/plugins/form-builder.mdx +++ b/docs/content/docs/plugins/form-builder.mdx @@ -637,6 +637,7 @@ Creates the client plugin with routes and SSR loaders. | Property | Type | Required | Description | |----------|------|----------|-------------| | `fieldComponents` | `Record` | No | Custom field components | +| `classNames` | `{ container?, table?, tableRow?, pagination?, form? }` | No | Class names merged onto specific UI elements | | `localization` | `Partial` | No | Custom labels | | `showAttribution` | `boolean` | No | Show BTST attribution | | `onRouteRender` | `(route, context) => void` | No | Lifecycle hook | diff --git a/packages/stack/src/plugins/ai-chat/__tests__/classnames-overrides.test.tsx b/packages/stack/src/plugins/ai-chat/__tests__/classnames-overrides.test.tsx new file mode 100644 index 000000000..65aa82a89 --- /dev/null +++ b/packages/stack/src/plugins/ai-chat/__tests__/classnames-overrides.test.tsx @@ -0,0 +1,248 @@ +// @vitest-environment jsdom +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +// Core primitives MUST be imported from the package entry (not relative src +// paths) so they share module identity — and React context — with the plugin +// components, which resolve `@btst/stack/*` via package self-reference. +import { StackProvider } from "@btst/stack/context"; +import type { UIMessage } from "ai"; +import { ChatInput } from "../client/components/chat-input"; +import { ChatLayout } from "../client/components/chat-layout"; +import { ChatMessage } from "../client/components/chat-message"; +import { aiChatClientPlugin } from "../client/plugin"; +import type { SerializedConversation } from "../types"; +import { createTestClientStack } from "../../../__tests__/client-stack-test-utils"; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; +(globalThis as any).ResizeObserver ??= class { + observe() {} + unobserve() {} + disconnect() {} +}; +Element.prototype.scrollIntoView ??= () => {}; + +const mocks = vi.hoisted(() => ({ + useChat: vi.fn(), + useConversation: vi.fn(), + useConversations: vi.fn(), + useRenameConversationForm: vi.fn(), + useDeleteConversation: vi.fn(), + useAiChatIdentityPartition: vi.fn(), +})); + +vi.mock("@ai-sdk/react", () => ({ useChat: mocks.useChat })); +vi.mock("../client/hooks/chat-hooks", () => ({ + useAiChatIdentityPartition: mocks.useAiChatIdentityPartition, + useConversation: mocks.useConversation, + useConversations: mocks.useConversations, + useRenameConversationForm: mocks.useRenameConversationForm, + useDeleteConversation: mocks.useDeleteConversation, +})); +vi.mock("../client/context/page-ai-context", () => ({ + usePageAIContext: () => undefined, +})); +// Markdown rendering is unrelated to class wiring and drags in highlight/katex. +vi.mock("@workspace/ui/components/markdown-content", () => ({ + MarkdownContent: ({ content }: { content: string }) =>
{content}
, +})); + +const CLASS_NAMES = { + container: "bg-red-100", + sidebar: "bg-blue-100", + messageList: "bg-yellow-50", + message: "text-purple-700", + userMessage: "border-4 border-orange-500", + assistantMessage: "border-4 border-green-500", + input: "bg-pink-100", +} as const; + +const conversation: SerializedConversation = { + id: "conv-1", + userId: "owner-1", + title: "First conversation", + createdAt: new Date("2024-01-01").toISOString(), + updatedAt: new Date("2024-01-02").toISOString(), +}; + +const userMessage: UIMessage = { + id: "user-1", + role: "user", + parts: [{ type: "text", text: "Hi there" }], +}; +const assistantMessage: UIMessage = { + id: "assistant-1", + role: "assistant", + parts: [{ type: "text", text: "Hello back" }], +}; + +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 } }, + }); + + mocks.useAiChatIdentityPartition.mockReturnValue("anonymous"); + mocks.useChat.mockReturnValue({ + messages: [userMessage, assistantMessage], + sendMessage: vi.fn(), + status: "ready", + error: null, + setMessages: vi.fn(), + regenerate: vi.fn(), + addToolOutput: vi.fn(), + stop: vi.fn(), + }); + mocks.useConversation.mockReturnValue({ + conversation: null, + isLoading: false, + error: null, + refetch: vi.fn(), + }); + mocks.useConversations.mockReturnValue({ + conversations: [conversation], + isLoading: false, + error: null, + refetch: vi.fn(), + }); + mocks.useRenameConversationForm.mockReturnValue({ + submit: vi.fn(), + isSubmitting: false, + error: null, + fieldErrors: {}, + clearErrors: vi.fn(), + }); + mocks.useDeleteConversation.mockReturnValue({ + mutateAsync: vi.fn(), + isPending: false, + }); +}); + +afterEach(async () => { + await act(async () => { + root.unmount(); + }); + container.remove(); + vi.clearAllMocks(); +}); + +async function render(ui: React.ReactElement) { + await act(async () => { + root.render( + + + {ui} + + , + ); + }); +} + +function query(selector: string): HTMLElement { + const el = container.querySelector(selector); + expect(el, `expected to find ${selector}`).toBeTruthy(); + return el!; +} + +describe("ai-chat classNames overrides (issue #36)", () => { + it("applies message, userMessage and assistantMessage to a user message", async () => { + await render(); + + const row = query('[data-testid="chat-message"][data-role="user"]'); + expect(row.className).toContain(CLASS_NAMES.message); + expect(row.className).toContain("justify-end"); + + const bubble = query('[data-testid="chat-message-bubble"]'); + expect(bubble.className).toContain("border-orange-500"); + expect(bubble.className).not.toContain("border-green-500"); + // The role-specific base styling is preserved. + expect(bubble.className).toContain("bg-primary"); + }); + + it("applies assistantMessage (not userMessage) to an assistant message", async () => { + await render(); + + const row = query('[data-testid="chat-message"][data-role="assistant"]'); + expect(row.className).toContain(CLASS_NAMES.message); + expect(row.className).toContain("justify-start"); + + const bubble = query('[data-testid="chat-message-bubble"]'); + expect(bubble.className).toContain("border-green-500"); + expect(bubble.className).not.toContain("border-orange-500"); + expect(bubble.className).toContain("bg-muted"); + }); + + it("applies classNames.input to the composer form", async () => { + await render( + {}} + handleSubmit={() => {}} + isLoading={false} + />, + ); + + const form = query('[data-testid="chat-input-form"]'); + expect(form.tagName).toBe("FORM"); + expect(form.className).toContain(CLASS_NAMES.input); + expect(form.className).toContain("space-y-2"); + }); + + it("applies container, sidebar and messageList in the full layout", async () => { + await render(); + + const layout = query('[data-testid="chat-layout"]'); + expect(layout.className).toContain(CLASS_NAMES.container); + expect(layout.className).toContain("overflow-hidden"); + + const sidebar = query('[data-testid="chat-sidebar"]'); + expect(sidebar.className).toContain(CLASS_NAMES.sidebar); + expect(sidebar.className).toContain("border-r"); + + const list = query('[data-testid="chat-message-list"]'); + expect(list.className).toContain(CLASS_NAMES.messageList); + expect(list.className).toContain("flex-col"); + }); + + it("applies classNames.container to the widget root", async () => { + await render(); + + const trigger = query('[data-testid="widget-trigger"]'); + const widgetRoot = trigger.parentElement!; + expect(widgetRoot.className).toContain(CLASS_NAMES.container); + expect(widgetRoot.className).toContain("items-end"); + }); + + it("leaves base classes untouched when no classNames override is supplied", async () => { + await act(async () => { + root.render( + + + + + , + ); + }); + + const bubble = query('[data-testid="chat-message-bubble"]'); + expect(bubble.className).toContain("bg-primary"); + expect(bubble.className).not.toContain("border-orange-500"); + }); +}); diff --git a/packages/stack/src/plugins/ai-chat/client/components/chat-input.tsx b/packages/stack/src/plugins/ai-chat/client/components/chat-input.tsx index 7ca7a3022..c9ce478a9 100644 --- a/packages/stack/src/plugins/ai-chat/client/components/chat-input.tsx +++ b/packages/stack/src/plugins/ai-chat/client/components/chat-input.tsx @@ -93,6 +93,7 @@ export function ChatInput({ uploadFile, localization: customLocalization, allowedFileTypes, + classNames, } = usePluginOverrides>( "aiChat", {}, @@ -314,7 +315,11 @@ export function ChatInput({ )} )} -
+ {/* Attached Files Preview */} {attachedFiles.length > 0 && (
diff --git a/packages/stack/src/plugins/ai-chat/client/components/chat-interface.tsx b/packages/stack/src/plugins/ai-chat/client/components/chat-interface.tsx index 11d4055fb..2a0c093ae 100644 --- a/packages/stack/src/plugins/ai-chat/client/components/chat-interface.tsx +++ b/packages/stack/src/plugins/ai-chat/client/components/chat-interface.tsx @@ -263,6 +263,7 @@ export function ChatInterface({ localization: customLocalization, showAttribution, chatSuggestions, + classNames, } = usePluginOverrides>( "aiChat", { showAttribution: true }, @@ -1139,7 +1140,9 @@ export function ChatInterface({ className={cn( "flex flex-col p-4", isWidget ? "max-w-full" : "max-w-3xl mx-auto w-full", + classNames?.messageList, )} + data-testid="chat-message-list" > {messages.length === 0 ? (
diff --git a/packages/stack/src/plugins/ai-chat/client/components/chat-layout.tsx b/packages/stack/src/plugins/ai-chat/client/components/chat-layout.tsx index 0a9522336..9a4edb42c 100644 --- a/packages/stack/src/plugins/ai-chat/client/components/chat-layout.tsx +++ b/packages/stack/src/plugins/ai-chat/client/components/chat-layout.tsx @@ -86,7 +86,7 @@ export function ChatLayout(props: ChatLayoutProps) { onMessagesChange, onClear, } = props; - const { localization } = usePluginOverrides< + const { localization, classNames } = usePluginOverrides< AiChatPluginOverrides, Partial >("aiChat", {}); @@ -133,7 +133,13 @@ export function ChatLayout(props: ChatLayoutProps) { if (layout === "widget") { return ( -
+
{/* Chat panel — always mounted to preserve conversation state, hidden when closed */}
diff --git a/packages/stack/src/plugins/ai-chat/client/components/chat-message.tsx b/packages/stack/src/plugins/ai-chat/client/components/chat-message.tsx index f4d9bfb99..632135e37 100644 --- a/packages/stack/src/plugins/ai-chat/client/components/chat-message.tsx +++ b/packages/stack/src/plugins/ai-chat/client/components/chat-message.tsx @@ -133,11 +133,14 @@ export function ChatMessage({ }: ChatMessageProps) { const isUser = message.role === "user"; - const { localization: customLocalization, toolRenderers } = - usePluginOverrides>( - "aiChat", - {}, - ); + const { + localization: customLocalization, + toolRenderers, + classNames, + } = usePluginOverrides>( + "aiChat", + {}, + ); const { router } = useStack(); const Link = router?.Link; const Image = router?.Image; @@ -265,7 +268,10 @@ export function ChatMessage({ "flex gap-3 w-full", isCompact ? "mb-3" : "mb-4", isUser ? "justify-end" : "justify-start", + classNames?.message, )} + data-testid="chat-message" + data-role={message.role} aria-label={ isUser ? tr("A11Y_USER_MESSAGE", "aiChat.a11y.userMessage", "Your message") @@ -295,7 +301,9 @@ export function ChatMessage({ "rounded-lg", isCompact ? "px-3 py-2" : "px-4 py-3", isUser ? "bg-primary text-primary-foreground" : "bg-muted", + isUser ? classNames?.userMessage : classNames?.assistantMessage, )} + data-testid="chat-message-bubble" > {isUser ? ( // User messages: files + images + text (or edit mode) diff --git a/packages/stack/src/plugins/ai-chat/client/components/chat-sidebar.tsx b/packages/stack/src/plugins/ai-chat/client/components/chat-sidebar.tsx index fb117ea9b..8ead00155 100644 --- a/packages/stack/src/plugins/ai-chat/client/components/chat-sidebar.tsx +++ b/packages/stack/src/plugins/ai-chat/client/components/chat-sidebar.tsx @@ -67,7 +67,7 @@ export function ChatSidebar({ onNewChat, className, }: ChatSidebarProps) { - const { localization: customLocalization } = usePluginOverrides< + const { localization: customLocalization, classNames } = usePluginOverrides< AiChatPluginOverrides, Partial >("aiChat", {}); @@ -208,7 +208,11 @@ export function ChatSidebar({ return (
{/* Header */}
diff --git a/packages/stack/src/plugins/ai-chat/client/overrides.ts b/packages/stack/src/plugins/ai-chat/client/overrides.ts index 660c668ee..e45512b2b 100644 --- a/packages/stack/src/plugins/ai-chat/client/overrides.ts +++ b/packages/stack/src/plugins/ai-chat/client/overrides.ts @@ -201,6 +201,27 @@ export interface AiChatPluginOverrides { */ toolRenderers?: Record; + /** + * Optional class names merged onto specific UI elements. + * Use these for targeted styling without replacing whole components. + */ + classNames?: { + /** Root of `ChatLayout` (full-page or widget mode) */ + container?: string; + /** Conversation sidebar root */ + sidebar?: string; + /** Wrapper around the list of messages */ + messageList?: string; + /** Each message row (user and assistant) */ + message?: string; + /** The bubble of a user message */ + userMessage?: string; + /** The bubble of an assistant message */ + assistantMessage?: string; + /** The composer form at the bottom of the chat */ + input?: string; + }; + // ============== Lifecycle Hooks (optional) ============== /** diff --git a/packages/stack/src/plugins/blog/__tests__/classnames-overrides.test.tsx b/packages/stack/src/plugins/blog/__tests__/classnames-overrides.test.tsx new file mode 100644 index 000000000..32654499d --- /dev/null +++ b/packages/stack/src/plugins/blog/__tests__/classnames-overrides.test.tsx @@ -0,0 +1,186 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +// Core primitives MUST be imported from the package entry (not relative src +// paths) so they share module identity — and React context — with the blog +// components, which resolve `@btst/stack/*` via package self-reference. +import { StackProvider } from "@btst/stack/context"; +import { PageWrapper } from "../client/components/shared/page-wrapper"; +import { PostsList } from "../client/components/shared/posts-list"; +import type { SerializedPost } from "../types"; +import { createTestClientStack } from "../../../__tests__/client-stack-test-utils"; +import { blogClientPlugin } from "../client/plugin"; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +// jsdom lacks these APIs used by Radix / cmdk inside SearchInput +(globalThis as any).ResizeObserver ??= class { + observe() {} + unobserve() {} + disconnect() {} +}; +Element.prototype.scrollIntoView ??= () => {}; + +const hooks = vi.hoisted(() => ({ + useSuspensePost: vi.fn(), + useDeletePost: vi.fn(), + usePostForm: vi.fn(), + usePostSearch: vi.fn(), + useSuspensePosts: vi.fn(), + useTags: vi.fn(), +})); + +vi.mock("../client/hooks/blog-hooks", () => hooks); + +const CLASS_NAMES = { + container: "bg-red-100", + postCard: "bg-blue-100", + postTitle: "text-purple-700", + postMeta: "text-green-600", + tagsList: "bg-yellow-50", + pagination: "border-4 border-orange-500", +} as const; + +const post: SerializedPost = { + id: "p1", + title: "Hello World", + content: "# Hello", + excerpt: "An excerpt", + slug: "hello-world", + published: true, + image: "", + tags: [{ id: "t1", name: "Release", slug: "release" }], + authorId: null, + publishedAt: new Date("2024-01-01").toISOString(), + createdAt: new Date("2024-01-01").toISOString(), + updatedAt: new Date("2024-01-01").toISOString(), +} as unknown as SerializedPost; + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + hooks.usePostSearch.mockReturnValue({ data: [], isLoading: false }); +}); + +afterEach(async () => { + await act(async () => { + root.unmount(); + }); + container.remove(); + vi.clearAllMocks(); +}); + +async function render(ui: React.ReactElement) { + await act(async () => { + root.render(ui); + }); +} + +function withBlogOverrides(children: React.ReactNode) { + return ( + "", + classNames: CLASS_NAMES, + }, + }} + > + {children} + + ); +} + +function query(selector: string): HTMLElement { + const el = container.querySelector(selector); + expect(el, `expected to find ${selector}`).toBeTruthy(); + return el!; +} + +describe("blog classNames overrides (issue #36)", () => { + it("applies classNames.container to the page container", async () => { + await render( + withBlogOverrides( + + content + , + ), + ); + + const el = query('[data-testid="blog-container"]'); + expect(el.className).toContain(CLASS_NAMES.container); + // The override must merge with the layout's own classes, not replace them. + expect(el.className).toContain("container"); + expect(el.className).toContain("mx-auto"); + }); + + it("applies classNames.postCard, postTitle, postMeta and tagsList inside a post card", async () => { + await render(withBlogOverrides()); + + // postCard — anchored on the Card primitive's structural slot. + const card = query('[data-slot="card"]'); + expect(card.className).toContain(CLASS_NAMES.postCard); + expect(card.className).toContain("group"); + + // postTitle — anchored on the CardTitle primitive's structural slot. + const title = query('[data-slot="card-title"]'); + expect(title.className).toContain(CLASS_NAMES.postTitle); + expect(title.className).toContain("line-clamp-3"); + expect(title.textContent).toBe(post.title); + + // postMeta — the wrapper holding the published date. + const meta = query("time").parentElement!; + expect(meta.className).toContain(CLASS_NAMES.postMeta); + // `cn` runs tailwind-merge, so the conflicting `text-muted-foreground` + // is intentionally replaced by the override; non-conflicting base + // classes must survive. + expect(meta.className).toContain("items-center"); + expect(meta.className).not.toContain("text-muted-foreground"); + + // tagsList — the wrapper immediately following the metadata block. + const tags = meta.nextElementSibling as HTMLElement; + expect(tags.className).toContain(CLASS_NAMES.tagsList); + expect(tags.className).toContain("flex-wrap"); + expect(tags.textContent).toContain("Release"); + }); + + it("applies classNames.pagination to the load-more wrapper", async () => { + await render( + withBlogOverrides( + {}} hasMore />, + ), + ); + + const button = [...container.querySelectorAll("button")].find((b) => + b.textContent?.includes("Load more posts"), + ); + expect(button, "expected a load-more button").toBeTruthy(); + + const pagination = button!.parentElement!; + expect(pagination.className).toContain("border-4"); + expect(pagination.className).toContain("border-orange-500"); + expect(pagination.className).toContain("justify-center"); + }); + + it("leaves base classes untouched when no classNames override is supplied", async () => { + await render( + "" } }} + > + {}} hasMore /> + , + ); + + const card = query('[data-slot="card"]'); + expect(card.className).toContain("group"); + expect(card.className).not.toContain(CLASS_NAMES.postCard); + }); +}); diff --git a/packages/stack/src/plugins/blog/client/components/shared/page-wrapper.tsx b/packages/stack/src/plugins/blog/client/components/shared/page-wrapper.tsx index 0a1d649ec..cb420fb3b 100644 --- a/packages/stack/src/plugins/blog/client/components/shared/page-wrapper.tsx +++ b/packages/stack/src/plugins/blog/client/components/shared/page-wrapper.tsx @@ -4,6 +4,7 @@ import { usePluginOverrides } from "@btst/stack/context"; import { PageWrapper as SharedPageWrapper } from "@workspace/ui/components/page-wrapper"; import type { BlogPluginOverrides } from "../../overrides"; import { BLOG_PLUGIN_ID } from "../../constants"; +import { cn } from "../../../utils"; export function PageWrapper({ children, @@ -14,7 +15,7 @@ export function PageWrapper({ className?: string; testId?: string; }) { - const { showAttribution } = usePluginOverrides< + const { showAttribution, classNames } = usePluginOverrides< BlogPluginOverrides, Partial >(BLOG_PLUGIN_ID, { @@ -23,7 +24,7 @@ export function PageWrapper({ return ( diff --git a/packages/stack/src/plugins/blog/client/components/shared/post-card.tsx b/packages/stack/src/plugins/blog/client/components/shared/post-card.tsx index 8f1cb6846..be37967da 100644 --- a/packages/stack/src/plugins/blog/client/components/shared/post-card.tsx +++ b/packages/stack/src/plugins/blog/client/components/shared/post-card.tsx @@ -18,7 +18,7 @@ import { CalendarIcon } from "lucide-react"; import type { BlogPluginOverrides } from "../../overrides"; import { BLOG_PLUGIN_ID } from "../../constants"; import { DefaultLink, DefaultImage } from "./defaults"; - +import { cn } from "../../../utils"; // Beautiful gradient color combinations const GRADIENT_PALETTES = [ { @@ -222,7 +222,7 @@ function getGradientFromTitle(title: string) { export function PostCard({ post }: { post: SerializedPost }) { const t = useTranslate(); - const { localization } = usePluginOverrides< + const { localization, classNames } = usePluginOverrides< BlogPluginOverrides, Partial >(BLOG_PLUGIN_ID, {}); @@ -236,7 +236,12 @@ export function PostCard({ post }: { post: SerializedPost }) { const gradient = post.image ? null : getGradientFromTitle(post.title); return ( - + {/* Image or Placeholder */} - + {post.title}
-
+
- {post.tags && - post.tags.length > 0 && - post.tags.map((tag) => ( - - {tag.name} - - ))} +
+ {post.tags && + post.tags.length > 0 && + post.tags.map((tag) => ( + + {tag.name} + + ))} +
diff --git a/packages/stack/src/plugins/blog/client/components/shared/posts-list.tsx b/packages/stack/src/plugins/blog/client/components/shared/posts-list.tsx index a25249b15..c250d0b94 100644 --- a/packages/stack/src/plugins/blog/client/components/shared/posts-list.tsx +++ b/packages/stack/src/plugins/blog/client/components/shared/posts-list.tsx @@ -6,6 +6,7 @@ import { SearchInput } from "./search-input"; import type { BlogPluginOverrides } from "../../overrides"; import { BLOG_PLUGIN_ID } from "../../constants"; import { PostCard as DefaultPostCard } from "./post-card"; +import { cn } from "../../../utils"; interface PostsListProps { posts: SerializedPost[]; @@ -21,7 +22,7 @@ export function PostsList({ isLoadingMore, }: PostsListProps) { const t = useTranslate(); - const { localization, PostCard } = + const { localization, PostCard, classNames } = usePluginOverrides(BLOG_PLUGIN_ID); const PostCardComponent = PostCard || DefaultPostCard; @@ -61,7 +62,7 @@ export function PostsList({
{onLoadMore && hasMore && ( -
+
\n\t\t\t\t)}\n\t\t\t
\n\t\t
\n\t);\n}\n", + "content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport { Plus, ArrowLeft, Pencil, Trash2, Loader2, Search } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport {\n\tTable,\n\tTableBody,\n\tTableCell,\n\tTableHead,\n\tTableHeader,\n\tTableRow,\n} from \"@/components/ui/table\";\nimport {\n\tjoinBasePath,\n\tPermissionAccess,\n\tuseNotify,\n\tusePluginOverrides,\n\tuseStack,\n\tuseTranslate,\n\ttype TranslateFn,\n} from \"@btst/stack/context\";\nimport { cmsPermissions } from \"../../../permissions\";\nimport { useListState, type ListStateSchema } from \"@btst/stack/client/hooks\";\nimport { orderCMSContentTypes, type CMSPluginOverrides } from \"../../overrides\";\nimport { CMS_PLUGIN_ID } from \"../../constants\";\nimport { cn } from \"../../../utils\";\nimport type { SerializedContentItemWithType } from \"../../../types\";\nimport {\n\tuseContent,\n\tuseSuspenseContent,\n\tuseSuspenseContentTypes,\n\tuseDeleteContent,\n} from \"@btst/stack/plugins/cms/client/hooks\";\nimport { EmptyState } from \"../shared/empty-state\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\nimport { useRouteLifecycle } from \"@/hooks/use-route-lifecycle\";\n\ninterface ContentListPageProps {\n\ttypeSlug: string;\n}\n\n// URL-synced search state: `?q=...` while typing (history: replace), clean\n// URL when the query is empty (the default is omitted from the URL).\nconst LIST_STATE_SCHEMA = {\n\tq: { type: \"string\", default: \"\", history: \"replace\" },\n} as const satisfies ListStateSchema;\n\nconst SEARCH_DEBOUNCE_MS = 300;\n\nexport function ContentListPage({ typeSlug }: ContentListPageProps) {\n\tconst t = useTranslate();\n\tconst notify = useNotify();\n\tconst overrides = usePluginOverrides(CMS_PLUGIN_ID);\n\tconst { localization, classNames } = overrides;\n\tconst { router, plugins, basePath: stackBasePath } = useStack();\n\tconst navigate = router?.navigate;\n\tconst Link = router?.Link;\n\tconst basePath = plugins?.[CMS_PLUGIN_ID]?.site.basePath ?? stackBasePath;\n\n\t// Call route lifecycle hooks for telemetry and application behavior.\n\tuseRouteLifecycle({\n\t\trouteName: \"contentList\",\n\t\tcontext: {\n\t\t\tpath: `/cms/${typeSlug}`,\n\t\t\tparams: { typeSlug },\n\t\t\tisSSR: typeof window === \"undefined\",\n\t\t},\n\t\toverrides,\n\t});\n\n\tconst limit = 20;\n\n\tconst [{ q: search }, setListState] = useListState(\n\t\t`cms-${typeSlug}`,\n\t\tLIST_STATE_SCHEMA,\n\t);\n\n\t// Local input state debounced into the URL-synced query, so the list\n\t// query (and URL) only update after the user pauses typing.\n\tconst [searchInput, setSearchInput] = useState(search);\n\n\t// External `q` changes (hydration after SSR-empty search params,\n\t// back/forward navigation) re-seed the input instead of being clobbered\n\t// by the debounced write below, which only reflects user edits.\n\tconst lastSyncedSearch = useRef(search);\n\tuseEffect(() => {\n\t\tif (search !== lastSyncedSearch.current) {\n\t\t\tlastSyncedSearch.current = search;\n\t\t\tsetSearchInput(search);\n\t\t}\n\t}, [search]);\n\n\tuseEffect(() => {\n\t\tif (searchInput === search) return;\n\t\tconst timeout = setTimeout(() => {\n\t\t\tlastSyncedSearch.current = searchInput;\n\t\t\tsetListState({ q: searchInput });\n\t\t}, SEARCH_DEBOUNCE_MS);\n\t\treturn () => clearTimeout(timeout);\n\t}, [searchInput, search, setListState]);\n\n\tconst hasSearch = search.trim().length > 0;\n\n\tconst { contentTypes: serverContentTypes } = useSuspenseContentTypes();\n\tconst contentTypes = orderCMSContentTypes(\n\t\tserverContentTypes,\n\t\tplugins?.[CMS_PLUGIN_ID]?.config,\n\t);\n\tconst contentType = contentTypes.find((ct) => ct.slug === typeSlug);\n\n\t// The default (unsearched) list stays on the suspense hook so SSR/SSG\n\t// hydration works; the searched list uses the non-suspense hook so\n\t// typing shows an inline loading state instead of suspending the page.\n\tconst defaultList = useSuspenseContent(typeSlug, { limit });\n\tconst searchedList = useContent(typeSlug, {\n\t\tlimit,\n\t\tsearch,\n\t\tenabled: hasSearch,\n\t});\n\n\tconst activeList = hasSearch ? searchedList : defaultList;\n\tconst { items, total, loadMore, hasMore, isLoadingMore } = activeList;\n\tconst isSearchLoading = hasSearch && searchedList.isLoading;\n\n\tconst deleteContent = useDeleteContent(typeSlug);\n\n\tconst LinkComponent = Link || \"a\";\n\n\tconst handleDelete = async (id: string) => {\n\t\ttry {\n\t\t\tawait deleteContent.mutateAsync(id);\n\t\t} catch {\n\t\t\tnotify.error(\n\t\t\t\tlocalization?.CMS_TOAST_ERROR ??\n\t\t\t\t\tt(\"cms.toasts.error\", \"An error occurred. Please try again.\"),\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\t\tnotify.success(\n\t\t\tlocalization?.CMS_TOAST_DELETE_SUCCESS ??\n\t\t\t\tt(\"cms.toasts.deleteSuccess\", \"Item deleted successfully\"),\n\t\t);\n\t};\n\n\tconst formatDate = (dateString: string) => {\n\t\treturn new Date(dateString).toLocaleDateString();\n\t};\n\n\tif (!contentType) {\n\t\treturn (\n\t\t\t\n\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\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 void navigate?.(joinBasePath(basePath, \"/cms\"))}\n\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\t
\n\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t{contentType.name}\n\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t{contentType.description && (\n\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t{contentType.description}\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\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\tvoid navigate?.(joinBasePath(basePath, `/cms/${typeSlug}/new`))\n\t\t\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{localization?.CMS_BUTTON_NEW_ITEM ??\n\t\t\t\t\t\t\t\tt(\"cms.common.newItem\", \"New Item\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t
\n\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t setSearchInput(e.target.value)}\n\t\t\t\t\t\tplaceholder={\n\t\t\t\t\t\t\tlocalization?.CMS_LIST_SEARCH_PLACEHOLDER ??\n\t\t\t\t\t\t\tt(\"cms.list.searchPlaceholder\", \"Search items...\")\n\t\t\t\t\t\t}\n\t\t\t\t\t\tclassName=\"pl-9\"\n\t\t\t\t\t/>\n\t\t\t\t\t{isSearchLoading && (\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t
\n\n\t\t\t\t{items.length === 0 ? (\n\t\t\t\t\tisSearchLoading ? null : hasSearch ? (\n\t\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\t\t\n\t\t\t\t\t\t\t\t\t\t\tvoid navigate?.(\n\t\t\t\t\t\t\t\t\t\t\t\tjoinBasePath(basePath, `/cms/${typeSlug}/new`),\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\t\t\n\t\t\t\t\t\t\t\t\t\t{localization?.CMS_BUTTON_CREATE ??\n\t\t\t\t\t\t\t\t\t\t\tt(\"cms.common.create\", \"Create\")}\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\t\t\t\t\t)\n\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\nfunction ContentTable({\n\titems,\n\ttotal,\n\ttypeSlug,\n\tbasePath,\n\tLinkComponent,\n\tnavigate,\n\tonDelete,\n\tisDeleting,\n\tformatDate,\n\tloadMore,\n\thasMore,\n\tisLoadingMore,\n\tlocalization,\n\tclassNames,\n\tt,\n}: {\n\titems: SerializedContentItemWithType[];\n\ttotal: number;\n\ttypeSlug: string;\n\tbasePath: string;\n\tLinkComponent: React.ElementType;\n\tnavigate?: (path: string) => void | Promise;\n\tonDelete: (id: string) => void;\n\tisDeleting: boolean;\n\tformatDate: (dateString: string) => string;\n\tloadMore: () => void;\n\thasMore: boolean;\n\tisLoadingMore: boolean;\n\tlocalization: CMSPluginOverrides[\"localization\"];\n\tclassNames: CMSPluginOverrides[\"classNames\"];\n\tt: TranslateFn;\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?.CMS_LIST_COLUMN_SLUG ??\n\t\t\t\t\t\t\t\tt(\"cms.list.columnSlug\", \"Slug\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.CMS_LIST_COLUMN_CREATED ??\n\t\t\t\t\t\t\t\tt(\"cms.list.columnCreated\", \"Created\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.CMS_LIST_COLUMN_UPDATED ??\n\t\t\t\t\t\t\t\tt(\"cms.list.columnUpdated\", \"Updated\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.CMS_LIST_COLUMN_ACTIONS ??\n\t\t\t\t\t\t\t\tt(\"cms.list.columnActions\", \"Actions\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t{items.map((item) => (\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{item.slug}\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{formatDate(item.createdAt)}\n\t\t\t\t\t\t\t{formatDate(item.updatedAt)}\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\tvoid navigate?.(\n\t\t\t\t\t\t\t\t\t\t\t\t\tjoinBasePath(basePath, `/cms/${typeSlug}/${item.id}`),\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\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\t\n\t\t\t\t\t\t\t\t\t\t onDelete(item.id)}\n\t\t\t\t\t\t\t\t\t\t\tdisabled={isDeleting}\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\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\t\t\t\t\t))}\n\t\t\t\t
\n\t\t\t
\n\t\t\t{/* Load More and pagination info */}\n\t\t\t\n\t\t\t\t

\n\t\t\t\t\t{(\n\t\t\t\t\t\tlocalization?.CMS_LIST_PAGINATION_SHOWING ??\n\t\t\t\t\t\tt(\"cms.list.paginationShowing\", \"Showing {from}-{to} of {total}\")\n\t\t\t\t\t)\n\t\t\t\t\t\t.replace(\"{from}\", \"1\")\n\t\t\t\t\t\t.replace(\"{to}\", String(items.length))\n\t\t\t\t\t\t.replace(\"{total}\", String(total))}\n\t\t\t\t

\n\t\t\t\t{hasMore && (\n\t\t\t\t\t loadMore()}\n\t\t\t\t\t\tdisabled={isLoadingMore}\n\t\t\t\t\t>\n\t\t\t\t\t\t{isLoadingMore && }\n\t\t\t\t\t\t{localization?.CMS_LIST_PAGINATION_NEXT ??\n\t\t\t\t\t\t\tt(\"cms.list.paginationNext\", \"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/cms/client/components/pages/content-list-page.internal.tsx" }, { @@ -133,7 +133,7 @@ { "path": "btst/cms/client/components/pages/dashboard-page.internal.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { FileText } from \"lucide-react\";\nimport {\n\tCard,\n\tCardContent,\n\tCardHeader,\n\tCardTitle,\n} from \"@/components/ui/card\";\nimport {\n\tjoinBasePath,\n\tusePluginOverrides,\n\tuseStack,\n\tuseTranslate,\n} from \"@btst/stack/context\";\nimport { orderCMSContentTypes, type CMSPluginOverrides } from \"../../overrides\";\nimport { CMS_PLUGIN_ID } from \"../../constants\";\nimport { useSuspenseContentTypes } from \"@btst/stack/plugins/cms/client/hooks\";\nimport { EmptyState } from \"../shared/empty-state\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\nimport { useRouteLifecycle } from \"@/hooks/use-route-lifecycle\";\n\nexport function DashboardPage() {\n\tconst t = useTranslate();\n\tconst overrides = usePluginOverrides(CMS_PLUGIN_ID);\n\tconst { localization } = overrides;\n\tconst { router, plugins, basePath: stackBasePath } = useStack();\n\tconst navigate = router?.navigate;\n\tconst basePath = plugins?.[CMS_PLUGIN_ID]?.site.basePath ?? stackBasePath;\n\n\t// Call route lifecycle hooks for telemetry and application behavior.\n\tuseRouteLifecycle({\n\t\trouteName: \"dashboard\",\n\t\tcontext: {\n\t\t\tpath: \"/cms\",\n\t\t\tisSSR: typeof window === \"undefined\",\n\t\t},\n\t\toverrides,\n\t});\n\tconst { contentTypes: serverContentTypes } = useSuspenseContentTypes();\n\tconst contentTypes = orderCMSContentTypes(\n\t\tserverContentTypes,\n\t\tplugins?.[CMS_PLUGIN_ID]?.config,\n\t);\n\n\tconst title =\n\t\tlocalization?.CMS_DASHBOARD_TITLE ?? t(\"cms.dashboard.title\", \"Content\");\n\tconst subtitle =\n\t\tlocalization?.CMS_DASHBOARD_SUBTITLE ??\n\t\tt(\"cms.dashboard.subtitle\", \"Manage your content types\");\n\n\tif (contentTypes.length === 0) {\n\t\treturn (\n\t\t\t\n\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t

{title}

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

{subtitle}

\n\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\tconst formatItemCount = (count: number) => {\n\t\tif (count === 0)\n\t\t\treturn (\n\t\t\t\tlocalization?.CMS_DASHBOARD_ITEMS_COUNT_ZERO ??\n\t\t\t\tt(\"cms.dashboard.itemsCountZero\", \"No items\")\n\t\t\t);\n\t\tif (count === 1)\n\t\t\treturn (\n\t\t\t\tlocalization?.CMS_DASHBOARD_ITEMS_COUNT_ONE ??\n\t\t\t\tt(\"cms.dashboard.itemsCountOne\", \"1 item\")\n\t\t\t);\n\t\treturn (\n\t\t\tlocalization?.CMS_DASHBOARD_ITEMS_COUNT ??\n\t\t\tt(\"cms.dashboard.itemsCount\", \"{count} items\")\n\t\t).replace(\"{count}\", String(count));\n\t};\n\n\treturn (\n\t\t\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t

{title}

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

{subtitle}

\n\t\t\t\t
\n\n\t\t\t\t
\n\t\t\t\t\t{contentTypes.map((ct) => (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\tvoid navigate?.(joinBasePath(basePath, `/cms/${ct.slug}`))\n\t\t\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{ct.name}\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\n\t\t\t\t\t\t\t\t
{ct.itemCount}
\n\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t{formatItemCount(ct.itemCount)}\n\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t{ct.description && (\n\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t\t{ct.description}\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\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 { FileText } from \"lucide-react\";\nimport {\n\tCard,\n\tCardContent,\n\tCardHeader,\n\tCardTitle,\n} from \"@/components/ui/card\";\nimport {\n\tjoinBasePath,\n\tusePluginOverrides,\n\tuseStack,\n\tuseTranslate,\n} from \"@btst/stack/context\";\nimport { orderCMSContentTypes, type CMSPluginOverrides } from \"../../overrides\";\nimport { CMS_PLUGIN_ID } from \"../../constants\";\nimport { useSuspenseContentTypes } from \"@btst/stack/plugins/cms/client/hooks\";\nimport { EmptyState } from \"../shared/empty-state\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\nimport { useRouteLifecycle } from \"@/hooks/use-route-lifecycle\";\nimport { cn } from \"../../../utils\";\n\nexport function DashboardPage() {\n\tconst t = useTranslate();\n\tconst overrides = usePluginOverrides(CMS_PLUGIN_ID);\n\tconst { localization, classNames } = overrides;\n\tconst { router, plugins, basePath: stackBasePath } = useStack();\n\tconst navigate = router?.navigate;\n\tconst basePath = plugins?.[CMS_PLUGIN_ID]?.site.basePath ?? stackBasePath;\n\n\t// Call route lifecycle hooks for telemetry and application behavior.\n\tuseRouteLifecycle({\n\t\trouteName: \"dashboard\",\n\t\tcontext: {\n\t\t\tpath: \"/cms\",\n\t\t\tisSSR: typeof window === \"undefined\",\n\t\t},\n\t\toverrides,\n\t});\n\tconst { contentTypes: serverContentTypes } = useSuspenseContentTypes();\n\tconst contentTypes = orderCMSContentTypes(\n\t\tserverContentTypes,\n\t\tplugins?.[CMS_PLUGIN_ID]?.config,\n\t);\n\n\tconst title =\n\t\tlocalization?.CMS_DASHBOARD_TITLE ?? t(\"cms.dashboard.title\", \"Content\");\n\tconst subtitle =\n\t\tlocalization?.CMS_DASHBOARD_SUBTITLE ??\n\t\tt(\"cms.dashboard.subtitle\", \"Manage your content types\");\n\n\tif (contentTypes.length === 0) {\n\t\treturn (\n\t\t\t\n\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t

{title}

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

{subtitle}

\n\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\tconst formatItemCount = (count: number) => {\n\t\tif (count === 0)\n\t\t\treturn (\n\t\t\t\tlocalization?.CMS_DASHBOARD_ITEMS_COUNT_ZERO ??\n\t\t\t\tt(\"cms.dashboard.itemsCountZero\", \"No items\")\n\t\t\t);\n\t\tif (count === 1)\n\t\t\treturn (\n\t\t\t\tlocalization?.CMS_DASHBOARD_ITEMS_COUNT_ONE ??\n\t\t\t\tt(\"cms.dashboard.itemsCountOne\", \"1 item\")\n\t\t\t);\n\t\treturn (\n\t\t\tlocalization?.CMS_DASHBOARD_ITEMS_COUNT ??\n\t\t\tt(\"cms.dashboard.itemsCount\", \"{count} items\")\n\t\t).replace(\"{count}\", String(count));\n\t};\n\n\treturn (\n\t\t\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t

{title}

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

{subtitle}

\n\t\t\t\t
\n\n\t\t\t\t
\n\t\t\t\t\t{contentTypes.map((ct) => (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\tvoid navigate?.(joinBasePath(basePath, `/cms/${ct.slug}`))\n\t\t\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{ct.name}\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\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t
{ct.itemCount}
\n\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t{formatItemCount(ct.itemCount)}\n\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t{ct.description && (\n\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t\t{ct.description}\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\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/cms/client/components/pages/dashboard-page.internal.tsx" }, { @@ -157,7 +157,7 @@ { "path": "btst/cms/client/components/shared/page-wrapper.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport { PageWrapper as SharedPageWrapper } from \"@/components/ui/page-wrapper\";\nimport type { CMSPluginOverrides } from \"../../overrides\";\nimport { CMS_PLUGIN_ID } from \"../../constants\";\n\nexport function PageWrapper({\n\tchildren,\n\tclassName,\n\ttestId,\n}: {\n\tchildren: React.ReactNode;\n\tclassName?: string;\n\ttestId?: string;\n}) {\n\tconst { showAttribution } = usePluginOverrides<\n\t\tCMSPluginOverrides,\n\t\tPartial\n\t>(CMS_PLUGIN_ID, {\n\t\tshowAttribution: true,\n\t});\n\n\treturn (\n\t\t\n\t\t\t{children}\n\t\t\n\t);\n}\n", + "content": "\"use client\";\n\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport { PageWrapper as SharedPageWrapper } from \"@/components/ui/page-wrapper\";\nimport type { CMSPluginOverrides } from \"../../overrides\";\nimport { CMS_PLUGIN_ID } from \"../../constants\";\nimport { cn } from \"../../../utils\";\n\nexport function PageWrapper({\n\tchildren,\n\tclassName,\n\ttestId,\n}: {\n\tchildren: React.ReactNode;\n\tclassName?: string;\n\ttestId?: string;\n}) {\n\tconst { showAttribution, classNames } = usePluginOverrides<\n\t\tCMSPluginOverrides,\n\t\tPartial\n\t>(CMS_PLUGIN_ID, {\n\t\tshowAttribution: true,\n\t});\n\n\treturn (\n\t\t\n\t\t\t{children}\n\t\t\n\t);\n}\n", "target": "src/components/btst/cms/client/components/shared/page-wrapper.tsx" }, { @@ -211,7 +211,7 @@ { "path": "btst/cms/client/overrides.ts", "type": "registry:lib", - "content": "import type { ComponentType } from \"react\";\nimport type { CMSLocalization } from \"./localization\";\nimport type { AutoFormInputComponentProps } from \"@/components/ui/auto-form/types\";\nimport type { ContentTypeConfig } from \"../types\";\n\n/** Browser-safe CMS factory values carried by the resolved client stack. */\nexport interface CMSProviderConfig {\n\t/** Declared content types, used to preserve application-defined UI order. */\n\treadonly contentTypes?: readonly ContentTypeConfig[];\n}\n\n/**\n * Apply the registered content-type order while keeping HTTP data authoritative.\n * Types not declared by this client are appended in their server-returned order.\n */\nexport function orderCMSContentTypes(\n\tcontentTypes: readonly T[],\n\tproviderConfig: Readonly> | undefined,\n): T[] {\n\tconst configured = providerConfig?.contentTypes;\n\tif (!Array.isArray(configured) || configured.length === 0) {\n\t\treturn [...contentTypes];\n\t}\n\tconst positions = new Map();\n\tfor (const [index, value] of configured.entries()) {\n\t\tif (\n\t\t\tvalue !== null &&\n\t\t\ttypeof value === \"object\" &&\n\t\t\t\"slug\" in value &&\n\t\t\ttypeof value.slug === \"string\"\n\t\t) {\n\t\t\tpositions.set(value.slug, index);\n\t\t}\n\t}\n\treturn contentTypes\n\t\t.map((value, index) => ({ value, index }))\n\t\t.sort((left, right) => {\n\t\t\tconst leftPosition = positions.get(left.value.slug);\n\t\t\tconst rightPosition = positions.get(right.value.slug);\n\t\t\tif (leftPosition === undefined && rightPosition === undefined) {\n\t\t\t\treturn left.index - right.index;\n\t\t\t}\n\t\t\tif (leftPosition === undefined) return 1;\n\t\t\tif (rightPosition === undefined) return -1;\n\t\t\treturn leftPosition - rightPosition;\n\t\t})\n\t\t.map(({ value }) => value);\n}\n\n/**\n * Props for the overridable CMS image input field component.\n */\nexport interface CmsImageInputFieldProps {\n\t/** Current image URL value */\n\tvalue: string;\n\t/** Called when the image URL changes */\n\tonChange: (value: string) => void;\n\t/** Whether the field is required */\n\tisRequired?: boolean;\n}\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., { typeSlug: \"product\", 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 * Overridable components and functions for the CMS plugin\n *\n * External consumers can provide their own implementations to customize\n * plugin-specific components and behavior.\n */\nexport interface CMSPluginOverrides {\n\t/**\n\t * Function used to upload a new image file and return its URL.\n\t * Used by the default \"file\" field component when not selecting an existing\n\t * asset via `imagePicker` or `imageInputField`.\n\t */\n\tuploadImage?: (file: File) => Promise;\n\n\t/**\n\t * Optional custom component for image fields (fieldType: \"file\").\n\t *\n\t * When provided it replaces the default file-upload input entirely.\n\t * The component receives `value` (current URL string) and `onChange` (setter).\n\t *\n\t * @example\n\t * ```tsx\n\t * imageInputField: ({ value, onChange }) =>\n\t * value ? (\n\t *
\n\t * \"Preview\"\n\t * Change} accept={[\"image/*\"]}\n\t * onSelect={(assets) => onChange(assets[0].url)} />\n\t *
\n\t * ) : (\n\t * Browse media} accept={[\"image/*\"]}\n\t * onSelect={(assets) => onChange(assets[0].url)} />\n\t * )\n\t * ```\n\t */\n\timageInputField?: ComponentType;\n\n\t/**\n\t * Optional trigger component for a media picker.\n\t * When provided, it is rendered inside the default \"file\" field component as a\n\t * \"Browse media\" option, letting users select a previously uploaded asset.\n\t * Receives `onSelect(url)` — the URL is set as the field value.\n\t *\n\t * @example\n\t * ```tsx\n\t * imagePicker: ({ onSelect }) => (\n\t * Browse media}\n\t * accept={[\"image/*\"]}\n\t * onSelect={(assets) => onSelect(assets[0].url)}\n\t * />\n\t * )\n\t * ```\n\t */\n\timagePicker?: ComponentType<{ onSelect: (url: string) => void }>;\n\n\t/**\n\t * Custom field components for AutoForm fields.\n\t *\n\t * These map field type names to React components. Use these to:\n\t * - Override built-in field types (checkbox, date, select, radio, switch, textarea, file, number, fallback)\n\t * - Add custom field types for your content types\n\t *\n\t * The component receives AutoFormInputComponentProps which includes:\n\t * - field: react-hook-form field controller\n\t * - label: the field label\n\t * - isRequired: whether the field is required\n\t * - fieldConfigItem: the field config (description, inputProps, etc.)\n\t * - fieldProps: additional props from fieldConfig.inputProps\n\t * - zodItem: the Zod schema for this field\n\t *\n\t * @example\n\t * ```tsx\n\t * fieldComponents: {\n\t * // Override the file type with custom S3 upload\n\t * file: ({ field, label, isRequired }) => (\n\t * \n\t * ),\n\t * // Add a custom rich text editor\n\t * richText: ({ field, label }) => (\n\t * \n\t * ),\n\t * }\n\t * ```\n\t */\n\tfieldComponents?: Record>;\n\n\t/**\n\t * Localization object for the CMS plugin\n\t */\n\tlocalization?: Partial;\n\n\t/**\n\t * Whether to show the attribution\n\t */\n\tshowAttribution?: boolean;\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., 'dashboard', 'contentList', 'contentEditor')\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 { CMSLocalization } from \"./localization\";\nimport type { AutoFormInputComponentProps } from \"@/components/ui/auto-form/types\";\nimport type { ContentTypeConfig } from \"../types\";\n\n/** Browser-safe CMS factory values carried by the resolved client stack. */\nexport interface CMSProviderConfig {\n\t/** Declared content types, used to preserve application-defined UI order. */\n\treadonly contentTypes?: readonly ContentTypeConfig[];\n}\n\n/**\n * Apply the registered content-type order while keeping HTTP data authoritative.\n * Types not declared by this client are appended in their server-returned order.\n */\nexport function orderCMSContentTypes(\n\tcontentTypes: readonly T[],\n\tproviderConfig: Readonly> | undefined,\n): T[] {\n\tconst configured = providerConfig?.contentTypes;\n\tif (!Array.isArray(configured) || configured.length === 0) {\n\t\treturn [...contentTypes];\n\t}\n\tconst positions = new Map();\n\tfor (const [index, value] of configured.entries()) {\n\t\tif (\n\t\t\tvalue !== null &&\n\t\t\ttypeof value === \"object\" &&\n\t\t\t\"slug\" in value &&\n\t\t\ttypeof value.slug === \"string\"\n\t\t) {\n\t\t\tpositions.set(value.slug, index);\n\t\t}\n\t}\n\treturn contentTypes\n\t\t.map((value, index) => ({ value, index }))\n\t\t.sort((left, right) => {\n\t\t\tconst leftPosition = positions.get(left.value.slug);\n\t\t\tconst rightPosition = positions.get(right.value.slug);\n\t\t\tif (leftPosition === undefined && rightPosition === undefined) {\n\t\t\t\treturn left.index - right.index;\n\t\t\t}\n\t\t\tif (leftPosition === undefined) return 1;\n\t\t\tif (rightPosition === undefined) return -1;\n\t\t\treturn leftPosition - rightPosition;\n\t\t})\n\t\t.map(({ value }) => value);\n}\n\n/**\n * Props for the overridable CMS image input field component.\n */\nexport interface CmsImageInputFieldProps {\n\t/** Current image URL value */\n\tvalue: string;\n\t/** Called when the image URL changes */\n\tonChange: (value: string) => void;\n\t/** Whether the field is required */\n\tisRequired?: boolean;\n}\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., { typeSlug: \"product\", 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 * Overridable components and functions for the CMS plugin\n *\n * External consumers can provide their own implementations to customize\n * plugin-specific components and behavior.\n */\nexport interface CMSPluginOverrides {\n\t/**\n\t * Function used to upload a new image file and return its URL.\n\t * Used by the default \"file\" field component when not selecting an existing\n\t * asset via `imagePicker` or `imageInputField`.\n\t */\n\tuploadImage?: (file: File) => Promise;\n\n\t/**\n\t * Optional custom component for image fields (fieldType: \"file\").\n\t *\n\t * When provided it replaces the default file-upload input entirely.\n\t * The component receives `value` (current URL string) and `onChange` (setter).\n\t *\n\t * @example\n\t * ```tsx\n\t * imageInputField: ({ value, onChange }) =>\n\t * value ? (\n\t *
\n\t * \"Preview\"\n\t * Change} accept={[\"image/*\"]}\n\t * onSelect={(assets) => onChange(assets[0].url)} />\n\t *
\n\t * ) : (\n\t * Browse media} accept={[\"image/*\"]}\n\t * onSelect={(assets) => onChange(assets[0].url)} />\n\t * )\n\t * ```\n\t */\n\timageInputField?: ComponentType;\n\n\t/**\n\t * Optional trigger component for a media picker.\n\t * When provided, it is rendered inside the default \"file\" field component as a\n\t * \"Browse media\" option, letting users select a previously uploaded asset.\n\t * Receives `onSelect(url)` — the URL is set as the field value.\n\t *\n\t * @example\n\t * ```tsx\n\t * imagePicker: ({ onSelect }) => (\n\t * Browse media}\n\t * accept={[\"image/*\"]}\n\t * onSelect={(assets) => onSelect(assets[0].url)}\n\t * />\n\t * )\n\t * ```\n\t */\n\timagePicker?: ComponentType<{ onSelect: (url: string) => void }>;\n\n\t/**\n\t * Optional class names merged onto specific UI elements.\n\t * Use these for targeted styling without replacing whole components.\n\t */\n\tclassNames?: {\n\t\t/** Page container shared by every CMS page */\n\t\tcontainer?: string;\n\t\t/** Each content-type card on the dashboard */\n\t\tcontentTypeCard?: string;\n\t\t/** Title inside a content-type card */\n\t\tcontentTypeTitle?: string;\n\t\t/** Wrapper around the content entries table */\n\t\ttable?: string;\n\t\t/** Each row in the content entries table */\n\t\ttableRow?: string;\n\t\t/** Pagination footer under the content entries table */\n\t\tpagination?: string;\n\t};\n\n\t/**\n\t * Custom field components for AutoForm fields.\n\t *\n\t * These map field type names to React components. Use these to:\n\t * - Override built-in field types (checkbox, date, select, radio, switch, textarea, file, number, fallback)\n\t * - Add custom field types for your content types\n\t *\n\t * The component receives AutoFormInputComponentProps which includes:\n\t * - field: react-hook-form field controller\n\t * - label: the field label\n\t * - isRequired: whether the field is required\n\t * - fieldConfigItem: the field config (description, inputProps, etc.)\n\t * - fieldProps: additional props from fieldConfig.inputProps\n\t * - zodItem: the Zod schema for this field\n\t *\n\t * @example\n\t * ```tsx\n\t * fieldComponents: {\n\t * // Override the file type with custom S3 upload\n\t * file: ({ field, label, isRequired }) => (\n\t * \n\t * ),\n\t * // Add a custom rich text editor\n\t * richText: ({ field, label }) => (\n\t * \n\t * ),\n\t * }\n\t * ```\n\t */\n\tfieldComponents?: Record>;\n\n\t/**\n\t * Localization object for the CMS plugin\n\t */\n\tlocalization?: Partial;\n\n\t/**\n\t * Whether to show the attribution\n\t */\n\tshowAttribution?: boolean;\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., 'dashboard', 'contentList', 'contentEditor')\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/cms/client/overrides.ts" }, { diff --git a/packages/stack/registry/btst-form-builder.json b/packages/stack/registry/btst-form-builder.json index 9d0c3a9b9..971f38eff 100644 --- a/packages/stack/registry/btst-form-builder.json +++ b/packages/stack/registry/btst-form-builder.json @@ -55,7 +55,7 @@ { "path": "btst/form-builder/client/components/forms/form-renderer.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { useState, useMemo, type ComponentType } from \"react\";\nimport {\n\tPermissionAccess,\n\tusePluginOverrides,\n\tuseTranslate,\n} from \"@btst/stack/context\";\nimport { SteppedAutoForm } from \"@/components/ui/auto-form/stepped-auto-form\";\nimport { buildFieldConfigFromJsonSchema } from \"@/components/ui/auto-form/helpers\";\nimport { formSchemaToZod } from \"@/lib/schema-converter\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\nimport { AlertCircle, CheckCircle } from \"lucide-react\";\nimport type { AutoFormInputComponentProps } from \"@/components/ui/auto-form/types\";\n\nimport { useFormBySlug, useSubmitForm } from \"@btst/stack/plugins/form-builder/client/hooks\";\nimport type { FormBuilderPluginOverrides } from \"../../overrides\";\nimport type { SerializedFormSubmission } from \"../../../types\";\nimport { formBuilderPermissions } from \"../../../permissions\";\nimport { FORM_BUILDER_PLUGIN_ID } from \"../../constants\";\n\nexport interface FormRendererProps {\n\t/** Form slug to render */\n\tslug: string;\n\t/** Callback when form submission succeeds */\n\tonSuccess?: (\n\t\tsubmission: SerializedFormSubmission & {\n\t\t\tform: { successMessage?: string; redirectUrl?: string };\n\t\t},\n\t) => void;\n\t/** Callback when form submission fails */\n\tonError?: (error: Error) => void;\n\t/** Custom field components (same as FormBuilder) */\n\tfieldComponents?: Record>;\n\t/** Override success message */\n\tsuccessMessage?: React.ReactNode;\n\t/** Override submit button text */\n\tsubmitButtonText?: string;\n\t/** Custom loading component */\n\tLoadingComponent?: ComponentType;\n\t/** Custom error component */\n\tErrorComponent?: ComponentType<{ error: Error }>;\n\t/** Class name for the form container */\n\tclassName?: string;\n}\n\nfunction DefaultLoadingComponent() {\n\treturn (\n\t\t
\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t
\n\t);\n}\n\nfunction DefaultErrorComponent({ error }: { error: Error }) {\n\tconst t = useTranslate();\n\tconst { localization } = usePluginOverrides(\n\t\tFORM_BUILDER_PLUGIN_ID,\n\t);\n\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\t{localization?.FORM_BUILDER_RENDERER_LOAD_FAILED ??\n\t\t\t\t\tt(\"formBuilder.renderer.loadFailed\", \"Failed to load form\")}\n\t\t\t

\n\t\t\t

\n\t\t\t\t{error.message ||\n\t\t\t\t\t(localization?.FORM_BUILDER_RENDERER_UNEXPECTED_ERROR ??\n\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\"formBuilder.renderer.unexpectedError\",\n\t\t\t\t\t\t\t\"An unexpected error occurred\",\n\t\t\t\t\t\t))}\n\t\t\t

\n\t\t
\n\t);\n}\n\nfunction DefaultSuccessComponent({ message }: { message: React.ReactNode }) {\n\tconst t = useTranslate();\n\tconst { localization } = usePluginOverrides(\n\t\tFORM_BUILDER_PLUGIN_ID,\n\t);\n\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\t{localization?.FORM_BUILDER_RENDERER_SUBMITTED_TITLE ??\n\t\t\t\t\tt(\"formBuilder.renderer.submittedTitle\", \"Form Submitted\")}\n\t\t\t

\n\t\t\t

{message}

\n\t\t
\n\t);\n}\n\n/**\n * FormRenderer component for rendering forms on the frontend.\n *\n * Uses SteppedAutoForm which automatically handles both single-step and multi-step forms.\n *\n * @example\n * ```tsx\n * {\n * toast.success(\"Thank you!\");\n * }}\n * onError={(error) => {\n * toast.error(\"Something went wrong\");\n * }}\n * />\n * ```\n */\nexport function FormRenderer({\n\tslug,\n\tonSuccess,\n\tonError,\n\tfieldComponents: propFieldComponents,\n\tsuccessMessage: propSuccessMessage,\n\tsubmitButtonText,\n\tLoadingComponent = DefaultLoadingComponent,\n\tErrorComponent = DefaultErrorComponent,\n\tclassName,\n}: FormRendererProps) {\n\tconst t = useTranslate();\n\tconst { fieldComponents: overrideFieldComponents, localization } =\n\t\tusePluginOverrides(FORM_BUILDER_PLUGIN_ID);\n\n\tconst { form, isLoading, error } = useFormBySlug(slug);\n\tconst submitMutation = useSubmitForm(slug);\n\n\tconst [submitted, setSubmitted] = useState(false);\n\tconst [finalSuccessMessage, setFinalSuccessMessage] = useState(\n\t\tnull,\n\t);\n\n\t// Merge field components from props and overrides\n\tconst mergedFieldComponents = useMemo(\n\t\t() => ({\n\t\t\t...overrideFieldComponents,\n\t\t\t...propFieldComponents,\n\t\t}),\n\t\t[overrideFieldComponents, propFieldComponents],\n\t);\n\n\t// Parse JSON Schema and create Zod schema\n\tconst { zodSchema, fieldConfig } = useMemo(() => {\n\t\tif (!form?.schema) {\n\t\t\treturn { zodSchema: null, fieldConfig: {} };\n\t\t}\n\n\t\ttry {\n\t\t\tconst parsedSchema = JSON.parse(form.schema);\n\t\t\tconst zod = formSchemaToZod(parsedSchema);\n\t\t\tconst config = buildFieldConfigFromJsonSchema(\n\t\t\t\tparsedSchema,\n\t\t\t\tmergedFieldComponents,\n\t\t\t);\n\n\t\t\treturn { zodSchema: zod, fieldConfig: config };\n\t\t} catch {\n\t\t\treturn { zodSchema: null, fieldConfig: {} };\n\t\t}\n\t}, [form?.schema, mergedFieldComponents]);\n\n\tconst handleSubmit = async (data: Record) => {\n\t\ttry {\n\t\t\tconst result = await submitMutation.mutateAsync({ data });\n\n\t\t\t// Set success message\n\t\t\tconst message =\n\t\t\t\tpropSuccessMessage ||\n\t\t\t\tresult.form.successMessage ||\n\t\t\t\t(localization?.FORM_BUILDER_RENDERER_THANK_YOU ??\n\t\t\t\t\tt(\"formBuilder.renderer.thankYou\", \"Thank you for your submission!\"));\n\t\t\tsetFinalSuccessMessage(message as string);\n\t\t\tsetSubmitted(true);\n\n\t\t\t// Call onSuccess callback before any redirect\n\t\t\tonSuccess?.(result);\n\n\t\t\t// Handle redirect\n\t\t\tif (result.form.redirectUrl) {\n\t\t\t\twindow.location.href = result.form.redirectUrl;\n\t\t\t\treturn;\n\t\t\t}\n\t\t} catch (err) {\n\t\t\tonError?.(err as Error);\n\t\t}\n\t};\n\n\t// Loading state\n\tif (isLoading) {\n\t\treturn (\n\t\t\t
\n\t\t\t\t\n\t\t\t
\n\t\t);\n\t}\n\n\t// Error state\n\tif (error) {\n\t\treturn (\n\t\t\t
\n\t\t\t\t\n\t\t\t
\n\t\t);\n\t}\n\n\t// Form not found\n\tif (!form) {\n\t\treturn (\n\t\t\t
\n\t\t\t\t\n\t\t\t
\n\t\t);\n\t}\n\n\t// Form not active\n\tif (form.status !== \"active\") {\n\t\treturn (\n\t\t\t
\n\t\t\t\t\n\t\t\t
\n\t\t);\n\t}\n\n\t// Schema parsing failed\n\tif (!zodSchema) {\n\t\treturn (\n\t\t\t
\n\t\t\t\t\n\t\t\t
\n\t\t);\n\t}\n\n\t// Success state\n\tif (submitted && finalSuccessMessage) {\n\t\treturn (\n\t\t\t
\n\t\t\t\t\n\t\t\t
\n\t\t);\n\t}\n\n\t// Render form using SteppedAutoForm\n\t// It automatically handles both single-step and multi-step forms\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\thandleSubmit(values as Record)\n\t\t\t\t\t\t}\n\t\t\t\t\t\tisSubmitting={submitMutation.isPending}\n\t\t\t\t\t\tsubmitButtonText={\n\t\t\t\t\t\t\tsubmitButtonText ||\n\t\t\t\t\t\t\t(localization?.FORM_BUILDER_BUTTON_SUBMIT ??\n\t\t\t\t\t\t\t\tt(\"formBuilder.common.buttonSubmit\", \"Submit\"))\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, useMemo, type ComponentType } from \"react\";\nimport {\n\tPermissionAccess,\n\tusePluginOverrides,\n\tuseTranslate,\n} from \"@btst/stack/context\";\nimport { SteppedAutoForm } from \"@/components/ui/auto-form/stepped-auto-form\";\nimport { buildFieldConfigFromJsonSchema } from \"@/components/ui/auto-form/helpers\";\nimport { formSchemaToZod } from \"@/lib/schema-converter\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\nimport { AlertCircle, CheckCircle } from \"lucide-react\";\nimport type { AutoFormInputComponentProps } from \"@/components/ui/auto-form/types\";\n\nimport { useFormBySlug, useSubmitForm } from \"@btst/stack/plugins/form-builder/client/hooks\";\nimport type { FormBuilderPluginOverrides } from \"../../overrides\";\nimport type { SerializedFormSubmission } from \"../../../types\";\nimport { formBuilderPermissions } from \"../../../permissions\";\nimport { FORM_BUILDER_PLUGIN_ID } from \"../../constants\";\nimport { cn } from \"../../../utils\";\n\nexport interface FormRendererProps {\n\t/** Form slug to render */\n\tslug: string;\n\t/** Callback when form submission succeeds */\n\tonSuccess?: (\n\t\tsubmission: SerializedFormSubmission & {\n\t\t\tform: { successMessage?: string; redirectUrl?: string };\n\t\t},\n\t) => void;\n\t/** Callback when form submission fails */\n\tonError?: (error: Error) => void;\n\t/** Custom field components (same as FormBuilder) */\n\tfieldComponents?: Record>;\n\t/** Override success message */\n\tsuccessMessage?: React.ReactNode;\n\t/** Override submit button text */\n\tsubmitButtonText?: string;\n\t/** Custom loading component */\n\tLoadingComponent?: ComponentType;\n\t/** Custom error component */\n\tErrorComponent?: ComponentType<{ error: Error }>;\n\t/** Class name for the form container */\n\tclassName?: string;\n}\n\nfunction DefaultLoadingComponent() {\n\treturn (\n\t\t
\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t
\n\t);\n}\n\nfunction DefaultErrorComponent({ error }: { error: Error }) {\n\tconst t = useTranslate();\n\tconst { localization } = usePluginOverrides(\n\t\tFORM_BUILDER_PLUGIN_ID,\n\t);\n\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\t{localization?.FORM_BUILDER_RENDERER_LOAD_FAILED ??\n\t\t\t\t\tt(\"formBuilder.renderer.loadFailed\", \"Failed to load form\")}\n\t\t\t

\n\t\t\t

\n\t\t\t\t{error.message ||\n\t\t\t\t\t(localization?.FORM_BUILDER_RENDERER_UNEXPECTED_ERROR ??\n\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\"formBuilder.renderer.unexpectedError\",\n\t\t\t\t\t\t\t\"An unexpected error occurred\",\n\t\t\t\t\t\t))}\n\t\t\t

\n\t\t
\n\t);\n}\n\nfunction DefaultSuccessComponent({ message }: { message: React.ReactNode }) {\n\tconst t = useTranslate();\n\tconst { localization } = usePluginOverrides(\n\t\tFORM_BUILDER_PLUGIN_ID,\n\t);\n\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\t{localization?.FORM_BUILDER_RENDERER_SUBMITTED_TITLE ??\n\t\t\t\t\tt(\"formBuilder.renderer.submittedTitle\", \"Form Submitted\")}\n\t\t\t

\n\t\t\t

{message}

\n\t\t
\n\t);\n}\n\n/**\n * FormRenderer component for rendering forms on the frontend.\n *\n * Uses SteppedAutoForm which automatically handles both single-step and multi-step forms.\n *\n * @example\n * ```tsx\n * {\n * toast.success(\"Thank you!\");\n * }}\n * onError={(error) => {\n * toast.error(\"Something went wrong\");\n * }}\n * />\n * ```\n */\nexport function FormRenderer({\n\tslug,\n\tonSuccess,\n\tonError,\n\tfieldComponents: propFieldComponents,\n\tsuccessMessage: propSuccessMessage,\n\tsubmitButtonText,\n\tLoadingComponent = DefaultLoadingComponent,\n\tErrorComponent = DefaultErrorComponent,\n\tclassName,\n}: FormRendererProps) {\n\tconst t = useTranslate();\n\tconst {\n\t\tfieldComponents: overrideFieldComponents,\n\t\tlocalization,\n\t\tclassNames,\n\t} = usePluginOverrides(FORM_BUILDER_PLUGIN_ID);\n\n\tconst formClassName = cn(className, classNames?.form);\n\tconst { form, isLoading, error } = useFormBySlug(slug);\n\tconst submitMutation = useSubmitForm(slug);\n\n\tconst [submitted, setSubmitted] = useState(false);\n\tconst [finalSuccessMessage, setFinalSuccessMessage] = useState(\n\t\tnull,\n\t);\n\n\t// Merge field components from props and overrides\n\tconst mergedFieldComponents = useMemo(\n\t\t() => ({\n\t\t\t...overrideFieldComponents,\n\t\t\t...propFieldComponents,\n\t\t}),\n\t\t[overrideFieldComponents, propFieldComponents],\n\t);\n\n\t// Parse JSON Schema and create Zod schema\n\tconst { zodSchema, fieldConfig } = useMemo(() => {\n\t\tif (!form?.schema) {\n\t\t\treturn { zodSchema: null, fieldConfig: {} };\n\t\t}\n\n\t\ttry {\n\t\t\tconst parsedSchema = JSON.parse(form.schema);\n\t\t\tconst zod = formSchemaToZod(parsedSchema);\n\t\t\tconst config = buildFieldConfigFromJsonSchema(\n\t\t\t\tparsedSchema,\n\t\t\t\tmergedFieldComponents,\n\t\t\t);\n\n\t\t\treturn { zodSchema: zod, fieldConfig: config };\n\t\t} catch {\n\t\t\treturn { zodSchema: null, fieldConfig: {} };\n\t\t}\n\t}, [form?.schema, mergedFieldComponents]);\n\n\tconst handleSubmit = async (data: Record) => {\n\t\ttry {\n\t\t\tconst result = await submitMutation.mutateAsync({ data });\n\n\t\t\t// Set success message\n\t\t\tconst message =\n\t\t\t\tpropSuccessMessage ||\n\t\t\t\tresult.form.successMessage ||\n\t\t\t\t(localization?.FORM_BUILDER_RENDERER_THANK_YOU ??\n\t\t\t\t\tt(\"formBuilder.renderer.thankYou\", \"Thank you for your submission!\"));\n\t\t\tsetFinalSuccessMessage(message as string);\n\t\t\tsetSubmitted(true);\n\n\t\t\t// Call onSuccess callback before any redirect\n\t\t\tonSuccess?.(result);\n\n\t\t\t// Handle redirect\n\t\t\tif (result.form.redirectUrl) {\n\t\t\t\twindow.location.href = result.form.redirectUrl;\n\t\t\t\treturn;\n\t\t\t}\n\t\t} catch (err) {\n\t\t\tonError?.(err as Error);\n\t\t}\n\t};\n\n\t// Loading state\n\tif (isLoading) {\n\t\treturn (\n\t\t\t
\n\t\t\t\t\n\t\t\t
\n\t\t);\n\t}\n\n\t// Error state\n\tif (error) {\n\t\treturn (\n\t\t\t
\n\t\t\t\t\n\t\t\t
\n\t\t);\n\t}\n\n\t// Form not found\n\tif (!form) {\n\t\treturn (\n\t\t\t
\n\t\t\t\t\n\t\t\t
\n\t\t);\n\t}\n\n\t// Form not active\n\tif (form.status !== \"active\") {\n\t\treturn (\n\t\t\t
\n\t\t\t\t\n\t\t\t
\n\t\t);\n\t}\n\n\t// Schema parsing failed\n\tif (!zodSchema) {\n\t\treturn (\n\t\t\t
\n\t\t\t\t\n\t\t\t
\n\t\t);\n\t}\n\n\t// Success state\n\tif (submitted && finalSuccessMessage) {\n\t\treturn (\n\t\t\t
\n\t\t\t\t\n\t\t\t
\n\t\t);\n\t}\n\n\t// Render form using SteppedAutoForm\n\t// It automatically handles both single-step and multi-step forms\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\thandleSubmit(values as Record)\n\t\t\t\t\t\t}\n\t\t\t\t\t\tisSubmitting={submitMutation.isPending}\n\t\t\t\t\t\tsubmitButtonText={\n\t\t\t\t\t\t\tsubmitButtonText ||\n\t\t\t\t\t\t\t(localization?.FORM_BUILDER_BUTTON_SUBMIT ??\n\t\t\t\t\t\t\t\tt(\"formBuilder.common.buttonSubmit\", \"Submit\"))\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/form-builder/client/components/forms/form-renderer.tsx" }, { @@ -103,7 +103,7 @@ { "path": "btst/form-builder/client/components/pages/form-list-page.internal.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport {\n\tuseNotify,\n\tPermissionAccess,\n\tusePluginOverrides,\n\tusePluginSiteNavigation,\n\tuseTranslate,\n} from \"@btst/stack/context\";\nimport { useListState, type ListStateSchema } from \"@btst/stack/client/hooks\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\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 {\n\tMoreHorizontal,\n\tPlus,\n\tPencil,\n\tTrash2,\n\tFileText,\n\tLoader2,\n\tSearch,\n} from \"lucide-react\";\n\nimport { useForms, useSuspenseForms, useDeleteForm } from \"@btst/stack/plugins/form-builder/client/hooks\";\nimport type { FormBuilderPluginOverrides } from \"../../overrides\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\nimport { EmptyState } from \"../shared/empty-state\";\nimport { Pagination } from \"../shared/pagination\";\nimport { formBuilderPermissions } from \"../../../permissions\";\nimport { FORM_BUILDER_PLUGIN_ID } from \"../../constants\";\n\n// URL-synced search state: `?q=...` while typing (history: replace), clean\n// URL when the query is empty (the default is omitted from the URL).\nconst LIST_STATE_SCHEMA = {\n\tq: { type: \"string\", default: \"\", history: \"replace\" },\n} as const satisfies ListStateSchema;\n\nconst SEARCH_DEBOUNCE_MS = 300;\n\nexport function FormListPage() {\n\tconst t = useTranslate();\n\tconst notify = useNotify();\n\tconst { localization } = usePluginOverrides(\n\t\tFORM_BUILDER_PLUGIN_ID,\n\t);\n\tconst { Link, navigate, resolve } = usePluginSiteNavigation(\n\t\tFORM_BUILDER_PLUGIN_ID,\n\t);\n\n\tconst [{ q: search }, setListState] = useListState(\n\t\t\"form-builder-forms\",\n\t\tLIST_STATE_SCHEMA,\n\t);\n\n\t// Local input state debounced into the URL-synced query, so the list\n\t// query (and URL) only update after the user pauses typing.\n\tconst [searchInput, setSearchInput] = useState(search);\n\n\t// External `q` changes (hydration after SSR-empty search params,\n\t// back/forward navigation) re-seed the input instead of being clobbered\n\t// by the debounced write below, which only reflects user edits.\n\tconst lastSyncedSearch = useRef(search);\n\tuseEffect(() => {\n\t\tif (search !== lastSyncedSearch.current) {\n\t\t\tlastSyncedSearch.current = search;\n\t\t\tsetSearchInput(search);\n\t\t}\n\t}, [search]);\n\n\tuseEffect(() => {\n\t\tif (searchInput === search) return;\n\t\tconst timeout = setTimeout(() => {\n\t\t\tlastSyncedSearch.current = searchInput;\n\t\t\tsetListState({ q: searchInput });\n\t\t}, SEARCH_DEBOUNCE_MS);\n\t\treturn () => clearTimeout(timeout);\n\t}, [searchInput, search, setListState]);\n\n\tconst hasSearch = search.trim().length > 0;\n\n\t// The default (unsearched) list stays on the suspense hook so SSR/SSG\n\t// hydration works; the searched list uses the non-suspense hook so\n\t// typing shows an inline loading state instead of suspending the page.\n\tconst defaultList = useSuspenseForms();\n\tconst searchedList = useForms({ search, enabled: hasSearch });\n\n\tconst activeList = hasSearch ? searchedList : defaultList;\n\tconst { forms, total, hasMore, isLoadingMore, loadMore } = activeList;\n\tconst isSearchLoading = hasSearch && searchedList.isLoading;\n\n\tconst deleteMutation = useDeleteForm();\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?.FORM_BUILDER_TOAST_ERROR ??\n\t\t\t\t\tt(\"formBuilder.toasts.error\", \"An error occurred. Please try again.\"),\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\t\tnotify.success(\n\t\t\tlocalization?.FORM_BUILDER_TOAST_DELETE_SUCCESS ??\n\t\t\t\tt(\"formBuilder.toasts.deleteSuccess\", \"Form deleted successfully\"),\n\t\t);\n\t\tsetDeleteId(null);\n\t};\n\n\tconst getStatusBadge = (status: string) => {\n\t\tconst colors: Record = {\n\t\t\tactive:\n\t\t\t\t\"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200\",\n\t\t\tinactive:\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{status}\n\t\t\t\n\t\t);\n\t};\n\n\tconst newFormButton = (\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?.FORM_BUILDER_LIST_TITLE ??\n\t\t\t\t\t\t\t\tt(\"formBuilder.list.title\", \"Forms\")}\n\t\t\t\t\t\t

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

\n\t\t\t\t\t\t\t{localization?.FORM_BUILDER_LIST_SUBTITLE ??\n\t\t\t\t\t\t\t\tt(\"formBuilder.list.subtitle\", \"Manage your forms\")}\n\t\t\t\t\t\t

\n\t\t\t\t\t
\n\t\t\t\t\t{newFormButton}\n\t\t\t\t
\n\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t setSearchInput(e.target.value)}\n\t\t\t\t\t\tplaceholder={\n\t\t\t\t\t\t\tlocalization?.FORM_BUILDER_LIST_SEARCH_PLACEHOLDER ??\n\t\t\t\t\t\t\tt(\"formBuilder.list.searchPlaceholder\", \"Search forms...\")\n\t\t\t\t\t\t}\n\t\t\t\t\t\tclassName=\"pl-9\"\n\t\t\t\t\t/>\n\t\t\t\t\t{isSearchLoading && (\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t
\n\n\t\t\t\t{forms.length === 0 ? (\n\t\t\t\t\tisSearchLoading ? null : hasSearch ? (\n\t\t\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) : (\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?.FORM_BUILDER_LIST_COLUMN_NAME ??\n\t\t\t\t\t\t\t\t\t\t\t\tt(\"formBuilder.list.columnName\", \"Name\")}\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?.FORM_BUILDER_LIST_COLUMN_SLUG ??\n\t\t\t\t\t\t\t\t\t\t\t\tt(\"formBuilder.list.columnSlug\", \"Slug\")}\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?.FORM_BUILDER_LIST_COLUMN_STATUS ??\n\t\t\t\t\t\t\t\t\t\t\t\tt(\"formBuilder.list.columnStatus\", \"Status\")}\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?.FORM_BUILDER_LIST_COLUMN_CREATED ??\n\t\t\t\t\t\t\t\t\t\t\t\tt(\"formBuilder.list.columnCreated\", \"Created\")}\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?.FORM_BUILDER_LIST_COLUMN_ACTIONS ??\n\t\t\t\t\t\t\t\t\t\t\t\tt(\"formBuilder.list.columnActions\", \"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{forms.map((form) => (\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t{form.name}\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{form.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{getStatusBadge(form.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\t{new Date(form.createdAt).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(\"forms\", form.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\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{localization?.FORM_BUILDER_LIST_ACTION_EDIT ??\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tt(\"formBuilder.list.actionEdit\", \"Edit\")}\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\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnavigate(\"forms\", form.id, \"submissions\")\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?.FORM_BUILDER_LIST_ACTION_SUBMISSIONS ??\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\"formBuilder.list.actionSubmissions\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"Submissions\",\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(form.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?.FORM_BUILDER_LIST_ACTION_DELETE ??\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tt(\"formBuilder.list.actionDelete\", \"Delete\")}\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?.FORM_BUILDER_LIST_DELETE_TITLE ??\n\t\t\t\t\t\t\t\tt(\"formBuilder.list.deleteTitle\", \"Delete Form\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.FORM_BUILDER_EDITOR_DELETE_CONFIRM ??\n\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\"formBuilder.editor.deleteConfirm\",\n\t\t\t\t\t\t\t\t\t\"Are you sure you want to delete this form? All submissions will also be deleted.\",\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?.FORM_BUILDER_BUTTON_CANCEL ??\n\t\t\t\t\t\t\t\tt(\"formBuilder.common.buttonCancel\", \"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? (localization?.FORM_BUILDER_STATUS_DELETING ??\n\t\t\t\t\t\t\t\t\tt(\"formBuilder.common.statusDeleting\", \"Deleting...\"))\n\t\t\t\t\t\t\t\t: (localization?.FORM_BUILDER_BUTTON_DELETE ??\n\t\t\t\t\t\t\t\t\tt(\"formBuilder.common.buttonDelete\", \"Delete\"))}\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 { useEffect, useRef, useState } from \"react\";\nimport {\n\tuseNotify,\n\tPermissionAccess,\n\tusePluginOverrides,\n\tusePluginSiteNavigation,\n\tuseTranslate,\n} from \"@btst/stack/context\";\nimport { useListState, type ListStateSchema } from \"@btst/stack/client/hooks\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\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 {\n\tMoreHorizontal,\n\tPlus,\n\tPencil,\n\tTrash2,\n\tFileText,\n\tLoader2,\n\tSearch,\n} from \"lucide-react\";\n\nimport { useForms, useSuspenseForms, useDeleteForm } from \"@btst/stack/plugins/form-builder/client/hooks\";\nimport type { FormBuilderPluginOverrides } from \"../../overrides\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\nimport { EmptyState } from \"../shared/empty-state\";\nimport { Pagination } from \"../shared/pagination\";\nimport { formBuilderPermissions } from \"../../../permissions\";\nimport { FORM_BUILDER_PLUGIN_ID } from \"../../constants\";\nimport { cn } from \"../../../utils\";\n\n// URL-synced search state: `?q=...` while typing (history: replace), clean\n// URL when the query is empty (the default is omitted from the URL).\nconst LIST_STATE_SCHEMA = {\n\tq: { type: \"string\", default: \"\", history: \"replace\" },\n} as const satisfies ListStateSchema;\n\nconst SEARCH_DEBOUNCE_MS = 300;\n\nexport function FormListPage() {\n\tconst t = useTranslate();\n\tconst notify = useNotify();\n\tconst { localization, classNames } =\n\t\tusePluginOverrides(FORM_BUILDER_PLUGIN_ID);\n\tconst { Link, navigate, resolve } = usePluginSiteNavigation(\n\t\tFORM_BUILDER_PLUGIN_ID,\n\t);\n\n\tconst [{ q: search }, setListState] = useListState(\n\t\t\"form-builder-forms\",\n\t\tLIST_STATE_SCHEMA,\n\t);\n\n\t// Local input state debounced into the URL-synced query, so the list\n\t// query (and URL) only update after the user pauses typing.\n\tconst [searchInput, setSearchInput] = useState(search);\n\n\t// External `q` changes (hydration after SSR-empty search params,\n\t// back/forward navigation) re-seed the input instead of being clobbered\n\t// by the debounced write below, which only reflects user edits.\n\tconst lastSyncedSearch = useRef(search);\n\tuseEffect(() => {\n\t\tif (search !== lastSyncedSearch.current) {\n\t\t\tlastSyncedSearch.current = search;\n\t\t\tsetSearchInput(search);\n\t\t}\n\t}, [search]);\n\n\tuseEffect(() => {\n\t\tif (searchInput === search) return;\n\t\tconst timeout = setTimeout(() => {\n\t\t\tlastSyncedSearch.current = searchInput;\n\t\t\tsetListState({ q: searchInput });\n\t\t}, SEARCH_DEBOUNCE_MS);\n\t\treturn () => clearTimeout(timeout);\n\t}, [searchInput, search, setListState]);\n\n\tconst hasSearch = search.trim().length > 0;\n\n\t// The default (unsearched) list stays on the suspense hook so SSR/SSG\n\t// hydration works; the searched list uses the non-suspense hook so\n\t// typing shows an inline loading state instead of suspending the page.\n\tconst defaultList = useSuspenseForms();\n\tconst searchedList = useForms({ search, enabled: hasSearch });\n\n\tconst activeList = hasSearch ? searchedList : defaultList;\n\tconst { forms, total, hasMore, isLoadingMore, loadMore } = activeList;\n\tconst isSearchLoading = hasSearch && searchedList.isLoading;\n\n\tconst deleteMutation = useDeleteForm();\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?.FORM_BUILDER_TOAST_ERROR ??\n\t\t\t\t\tt(\"formBuilder.toasts.error\", \"An error occurred. Please try again.\"),\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\t\tnotify.success(\n\t\t\tlocalization?.FORM_BUILDER_TOAST_DELETE_SUCCESS ??\n\t\t\t\tt(\"formBuilder.toasts.deleteSuccess\", \"Form deleted successfully\"),\n\t\t);\n\t\tsetDeleteId(null);\n\t};\n\n\tconst getStatusBadge = (status: string) => {\n\t\tconst colors: Record = {\n\t\t\tactive:\n\t\t\t\t\"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200\",\n\t\t\tinactive:\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{status}\n\t\t\t\n\t\t);\n\t};\n\n\tconst newFormButton = (\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?.FORM_BUILDER_LIST_TITLE ??\n\t\t\t\t\t\t\t\tt(\"formBuilder.list.title\", \"Forms\")}\n\t\t\t\t\t\t

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

\n\t\t\t\t\t\t\t{localization?.FORM_BUILDER_LIST_SUBTITLE ??\n\t\t\t\t\t\t\t\tt(\"formBuilder.list.subtitle\", \"Manage your forms\")}\n\t\t\t\t\t\t

\n\t\t\t\t\t
\n\t\t\t\t\t{newFormButton}\n\t\t\t\t
\n\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t setSearchInput(e.target.value)}\n\t\t\t\t\t\tplaceholder={\n\t\t\t\t\t\t\tlocalization?.FORM_BUILDER_LIST_SEARCH_PLACEHOLDER ??\n\t\t\t\t\t\t\tt(\"formBuilder.list.searchPlaceholder\", \"Search forms...\")\n\t\t\t\t\t\t}\n\t\t\t\t\t\tclassName=\"pl-9\"\n\t\t\t\t\t/>\n\t\t\t\t\t{isSearchLoading && (\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t
\n\n\t\t\t\t{forms.length === 0 ? (\n\t\t\t\t\tisSearchLoading ? null : hasSearch ? (\n\t\t\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) : (\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?.FORM_BUILDER_LIST_COLUMN_NAME ??\n\t\t\t\t\t\t\t\t\t\t\t\tt(\"formBuilder.list.columnName\", \"Name\")}\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?.FORM_BUILDER_LIST_COLUMN_SLUG ??\n\t\t\t\t\t\t\t\t\t\t\t\tt(\"formBuilder.list.columnSlug\", \"Slug\")}\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?.FORM_BUILDER_LIST_COLUMN_STATUS ??\n\t\t\t\t\t\t\t\t\t\t\t\tt(\"formBuilder.list.columnStatus\", \"Status\")}\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?.FORM_BUILDER_LIST_COLUMN_CREATED ??\n\t\t\t\t\t\t\t\t\t\t\t\tt(\"formBuilder.list.columnCreated\", \"Created\")}\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?.FORM_BUILDER_LIST_COLUMN_ACTIONS ??\n\t\t\t\t\t\t\t\t\t\t\t\tt(\"formBuilder.list.columnActions\", \"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{forms.map((form) => (\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t{form.name}\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{form.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{getStatusBadge(form.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\t{new Date(form.createdAt).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(\"forms\", form.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\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{localization?.FORM_BUILDER_LIST_ACTION_EDIT ??\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tt(\"formBuilder.list.actionEdit\", \"Edit\")}\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\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnavigate(\"forms\", form.id, \"submissions\")\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?.FORM_BUILDER_LIST_ACTION_SUBMISSIONS ??\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\"formBuilder.list.actionSubmissions\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"Submissions\",\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(form.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?.FORM_BUILDER_LIST_ACTION_DELETE ??\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tt(\"formBuilder.list.actionDelete\", \"Delete\")}\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?.FORM_BUILDER_LIST_DELETE_TITLE ??\n\t\t\t\t\t\t\t\tt(\"formBuilder.list.deleteTitle\", \"Delete Form\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.FORM_BUILDER_EDITOR_DELETE_CONFIRM ??\n\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\"formBuilder.editor.deleteConfirm\",\n\t\t\t\t\t\t\t\t\t\"Are you sure you want to delete this form? All submissions will also be deleted.\",\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?.FORM_BUILDER_BUTTON_CANCEL ??\n\t\t\t\t\t\t\t\tt(\"formBuilder.common.buttonCancel\", \"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? (localization?.FORM_BUILDER_STATUS_DELETING ??\n\t\t\t\t\t\t\t\t\tt(\"formBuilder.common.statusDeleting\", \"Deleting...\"))\n\t\t\t\t\t\t\t\t: (localization?.FORM_BUILDER_BUTTON_DELETE ??\n\t\t\t\t\t\t\t\t\tt(\"formBuilder.common.buttonDelete\", \"Delete\"))}\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/form-builder/client/components/pages/form-list-page.internal.tsx" }, { @@ -115,7 +115,7 @@ { "path": "btst/form-builder/client/components/pages/submissions-page.internal.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport {\n\tuseNotify,\n\tPermissionAccess,\n\tusePluginOverrides,\n\tusePluginSiteNavigation,\n\tuseTranslate,\n\tuseIdentity,\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\tAlertDialog,\n\tAlertDialogAction,\n\tAlertDialogCancel,\n\tAlertDialogContent,\n\tAlertDialogDescription,\n\tAlertDialogFooter,\n\tAlertDialogHeader,\n\tAlertDialogTitle,\n} from \"@/components/ui/alert-dialog\";\nimport {\n\tDialog,\n\tDialogContent,\n\tDialogDescription,\n\tDialogHeader,\n\tDialogTitle,\n} from \"@/components/ui/dialog\";\nimport { ArrowLeft, Trash2, Eye } from \"lucide-react\";\n\nimport {\n\tuseSuspenseSubmissions,\n\tuseSubmission,\n\tuseDeleteSubmission,\n} from \"@btst/stack/plugins/form-builder/client/hooks\";\nimport type { FormBuilderPluginOverrides } from \"../../overrides\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\nimport { EmptyState } from \"../shared/empty-state\";\nimport { Pagination } from \"../shared/pagination\";\nimport { formBuilderPermissions } from \"../../../permissions\";\nimport { FORM_BUILDER_PLUGIN_ID } from \"../../constants\";\n\nexport interface SubmissionsPageProps {\n\tformId: string;\n}\n\nexport function SubmissionsPage({ formId }: SubmissionsPageProps) {\n\tconst t = useTranslate();\n\tconst notify = useNotify();\n\tconst { localization } = usePluginOverrides(\n\t\tFORM_BUILDER_PLUGIN_ID,\n\t);\n\tconst { identity } = useIdentity();\n\tconst { Link: LinkComponent, resolve } = usePluginSiteNavigation(\n\t\tFORM_BUILDER_PLUGIN_ID,\n\t);\n\n\tconst { form, submissions, total, hasMore, isLoadingMore, loadMore } =\n\t\tuseSuspenseSubmissions(formId);\n\tconst deleteMutation = useDeleteSubmission(formId);\n\n\tconst [deleteId, setDeleteId] = useState(null);\n\tconst [viewSubmissionId, setViewSubmissionId] = useState(null);\n\tconst {\n\t\tsubmission: viewSubmission,\n\t\tisLoading: isViewLoading,\n\t\terror: viewError,\n\t} = useSubmission(formId, viewSubmissionId ?? undefined);\n\n\t// A detail response was authorized for the identity that opened it. Close\n\t// the dialog immediately when that identity snapshot changes; the hook also\n\t// partitions its sensitive cache by identity before any subsequent fetch.\n\tuseEffect(() => {\n\t\tsetViewSubmissionId(null);\n\t}, [identity]);\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?.FORM_BUILDER_TOAST_ERROR ??\n\t\t\t\t\tt(\"formBuilder.toasts.error\", \"An error occurred. Please try again.\"),\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\t\tnotify.success(\n\t\t\tlocalization?.FORM_BUILDER_TOAST_SUBMISSION_DELETED ??\n\t\t\t\tt(\n\t\t\t\t\t\"formBuilder.toasts.submissionDeleted\",\n\t\t\t\t\t\"Submission deleted successfully\",\n\t\t\t\t),\n\t\t);\n\t\tsetDeleteId(null);\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
\n\t\t\t\t\t\t

\n\t\t\t\t\t\t\t{form?.name ||\n\t\t\t\t\t\t\t\t(localization?.FORM_BUILDER_SUBMISSIONS_TITLE ??\n\t\t\t\t\t\t\t\t\tt(\"formBuilder.submissions.title\", \"Submissions\"))}\n\t\t\t\t\t\t

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

\n\t\t\t\t\t\t\t{localization?.FORM_BUILDER_SUBMISSIONS_SUBTITLE ??\n\t\t\t\t\t\t\t\tt(\"formBuilder.submissions.subtitle\", \"View form submissions\")}\n\t\t\t\t\t\t

\n\t\t\t\t\t
\n\t\t\t\t
\n\n\t\t\t\t{submissions.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?.FORM_BUILDER_SUBMISSIONS_COLUMN_ID ??\n\t\t\t\t\t\t\t\t\t\t\t\tt(\"formBuilder.submissions.columnId\", \"ID\")}\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?.FORM_BUILDER_SUBMISSIONS_COLUMN_SUBMITTED_AT ??\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\"formBuilder.submissions.columnSubmittedAt\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\"Submitted\",\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?.FORM_BUILDER_SUBMISSIONS_COLUMN_ACTIONS ??\n\t\t\t\t\t\t\t\t\t\t\t\tt(\"formBuilder.submissions.columnActions\", \"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{submissions.map((sub) => (\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{sub.id.slice(0, 8)}...\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(sub.submittedAt).toLocaleString()}\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 setViewSubmissionId(sub.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\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{localization?.FORM_BUILDER_SUBMISSIONS_ACTION_VIEW ??\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\"formBuilder.submissions.actionView\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"View\",\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\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t setDeleteId(sub.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\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{localization?.FORM_BUILDER_SUBMISSIONS_ACTION_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\"formBuilder.submissions.actionDelete\",\n\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{/* View submission dialog */}\n\t\t\t {\n\t\t\t\t\tif (!open) setViewSubmissionId(null);\n\t\t\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?.FORM_BUILDER_SUBMISSIONS_DETAILS_TITLE ??\n\t\t\t\t\t\t\t\tt(\"formBuilder.submissions.detailsTitle\", \"Submission Details\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{t(\n\t\t\t\t\t\t\t\t\"formBuilder.submissions.detailsDescription\",\n\t\t\t\t\t\t\t\t\"Submitted form data and request metadata.\",\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{isViewLoading && (\n\t\t\t\t\t\t

\n\t\t\t\t\t\t\t{localization?.FORM_BUILDER_STATUS_LOADING ??\n\t\t\t\t\t\t\t\tt(\"formBuilder.common.statusLoading\", \"Loading...\")}\n\t\t\t\t\t\t

\n\t\t\t\t\t)}\n\t\t\t\t\t{viewError && (\n\t\t\t\t\t\t

\n\t\t\t\t\t\t\t{localization?.FORM_BUILDER_TOAST_ERROR ??\n\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\"formBuilder.toasts.error\",\n\t\t\t\t\t\t\t\t\t\"An error occurred. Please try again.\",\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{!isViewLoading && !viewError && viewSubmission && (\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{localization?.FORM_BUILDER_SUBMISSIONS_FIELD_ID ??\n\t\t\t\t\t\t\t\t\t\t\tt(\"formBuilder.submissions.fieldId\", \"ID:\")}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t

{viewSubmission.id}

\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\n\t\t\t\t\t\t\t\t\t\t{localization?.FORM_BUILDER_SUBMISSIONS_FIELD_SUBMITTED ??\n\t\t\t\t\t\t\t\t\t\t\tt(\"formBuilder.submissions.fieldSubmitted\", \"Submitted:\")}\n\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\t\t{new Date(viewSubmission.submittedAt).toLocaleString()}\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\n\t\t\t\t\t\t\t\t\t\t{localization?.FORM_BUILDER_SUBMISSIONS_FIELD_IP ??\n\t\t\t\t\t\t\t\t\t\t\tt(\"formBuilder.submissions.fieldIp\", \"IP Address:\")}\n\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\t\t{viewSubmission.ipAddress || \"-\"}\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\n\t\t\t\t\t\t\t\t\t\t{localization?.FORM_BUILDER_SUBMISSIONS_FIELD_USER_AGENT ??\n\t\t\t\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\t\t\t\"formBuilder.submissions.fieldUserAgent\",\n\t\t\t\t\t\t\t\t\t\t\t\t\"User Agent:\",\n\t\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\t

\n\t\t\t\t\t\t\t\t\t\t{viewSubmission.userAgent || \"-\"}\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\t
\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization?.FORM_BUILDER_SUBMISSIONS_FIELD_DATA ??\n\t\t\t\t\t\t\t\t\t\tt(\"formBuilder.submissions.fieldData\", \"Data:\")}\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{JSON.stringify(viewSubmission.parsedData, null, 2)}\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\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?.FORM_BUILDER_SUBMISSIONS_DELETE_TITLE ??\n\t\t\t\t\t\t\t\tt(\"formBuilder.submissions.deleteTitle\", \"Delete Submission\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.FORM_BUILDER_SUBMISSIONS_DELETE_CONFIRM ??\n\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\"formBuilder.submissions.deleteConfirm\",\n\t\t\t\t\t\t\t\t\t\"Are you sure you want to delete this submission?\",\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?.FORM_BUILDER_BUTTON_CANCEL ??\n\t\t\t\t\t\t\t\tt(\"formBuilder.common.buttonCancel\", \"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? (localization?.FORM_BUILDER_STATUS_DELETING ??\n\t\t\t\t\t\t\t\t\tt(\"formBuilder.common.statusDeleting\", \"Deleting...\"))\n\t\t\t\t\t\t\t\t: (localization?.FORM_BUILDER_BUTTON_DELETE ??\n\t\t\t\t\t\t\t\t\tt(\"formBuilder.common.buttonDelete\", \"Delete\"))}\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 { useEffect, useState } from \"react\";\nimport {\n\tuseNotify,\n\tPermissionAccess,\n\tusePluginOverrides,\n\tusePluginSiteNavigation,\n\tuseTranslate,\n\tuseIdentity,\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\tAlertDialog,\n\tAlertDialogAction,\n\tAlertDialogCancel,\n\tAlertDialogContent,\n\tAlertDialogDescription,\n\tAlertDialogFooter,\n\tAlertDialogHeader,\n\tAlertDialogTitle,\n} from \"@/components/ui/alert-dialog\";\nimport {\n\tDialog,\n\tDialogContent,\n\tDialogDescription,\n\tDialogHeader,\n\tDialogTitle,\n} from \"@/components/ui/dialog\";\nimport { ArrowLeft, Trash2, Eye } from \"lucide-react\";\n\nimport {\n\tuseSuspenseSubmissions,\n\tuseSubmission,\n\tuseDeleteSubmission,\n} from \"@btst/stack/plugins/form-builder/client/hooks\";\nimport type { FormBuilderPluginOverrides } from \"../../overrides\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\nimport { EmptyState } from \"../shared/empty-state\";\nimport { Pagination } from \"../shared/pagination\";\nimport { formBuilderPermissions } from \"../../../permissions\";\nimport { FORM_BUILDER_PLUGIN_ID } from \"../../constants\";\nimport { cn } from \"../../../utils\";\n\nexport interface SubmissionsPageProps {\n\tformId: string;\n}\n\nexport function SubmissionsPage({ formId }: SubmissionsPageProps) {\n\tconst t = useTranslate();\n\tconst notify = useNotify();\n\tconst { localization, classNames } =\n\t\tusePluginOverrides(FORM_BUILDER_PLUGIN_ID);\n\tconst { identity } = useIdentity();\n\tconst { Link: LinkComponent, resolve } = usePluginSiteNavigation(\n\t\tFORM_BUILDER_PLUGIN_ID,\n\t);\n\n\tconst { form, submissions, total, hasMore, isLoadingMore, loadMore } =\n\t\tuseSuspenseSubmissions(formId);\n\tconst deleteMutation = useDeleteSubmission(formId);\n\n\tconst [deleteId, setDeleteId] = useState(null);\n\tconst [viewSubmissionId, setViewSubmissionId] = useState(null);\n\tconst {\n\t\tsubmission: viewSubmission,\n\t\tisLoading: isViewLoading,\n\t\terror: viewError,\n\t} = useSubmission(formId, viewSubmissionId ?? undefined);\n\n\t// A detail response was authorized for the identity that opened it. Close\n\t// the dialog immediately when that identity snapshot changes; the hook also\n\t// partitions its sensitive cache by identity before any subsequent fetch.\n\tuseEffect(() => {\n\t\tsetViewSubmissionId(null);\n\t}, [identity]);\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?.FORM_BUILDER_TOAST_ERROR ??\n\t\t\t\t\tt(\"formBuilder.toasts.error\", \"An error occurred. Please try again.\"),\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\t\tnotify.success(\n\t\t\tlocalization?.FORM_BUILDER_TOAST_SUBMISSION_DELETED ??\n\t\t\t\tt(\n\t\t\t\t\t\"formBuilder.toasts.submissionDeleted\",\n\t\t\t\t\t\"Submission deleted successfully\",\n\t\t\t\t),\n\t\t);\n\t\tsetDeleteId(null);\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
\n\t\t\t\t\t\t

\n\t\t\t\t\t\t\t{form?.name ||\n\t\t\t\t\t\t\t\t(localization?.FORM_BUILDER_SUBMISSIONS_TITLE ??\n\t\t\t\t\t\t\t\t\tt(\"formBuilder.submissions.title\", \"Submissions\"))}\n\t\t\t\t\t\t

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

\n\t\t\t\t\t\t\t{localization?.FORM_BUILDER_SUBMISSIONS_SUBTITLE ??\n\t\t\t\t\t\t\t\tt(\"formBuilder.submissions.subtitle\", \"View form submissions\")}\n\t\t\t\t\t\t

\n\t\t\t\t\t
\n\t\t\t\t
\n\n\t\t\t\t{submissions.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?.FORM_BUILDER_SUBMISSIONS_COLUMN_ID ??\n\t\t\t\t\t\t\t\t\t\t\t\tt(\"formBuilder.submissions.columnId\", \"ID\")}\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?.FORM_BUILDER_SUBMISSIONS_COLUMN_SUBMITTED_AT ??\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\"formBuilder.submissions.columnSubmittedAt\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\"Submitted\",\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?.FORM_BUILDER_SUBMISSIONS_COLUMN_ACTIONS ??\n\t\t\t\t\t\t\t\t\t\t\t\tt(\"formBuilder.submissions.columnActions\", \"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{submissions.map((sub) => (\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{sub.id.slice(0, 8)}...\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(sub.submittedAt).toLocaleString()}\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 setViewSubmissionId(sub.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\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{localization?.FORM_BUILDER_SUBMISSIONS_ACTION_VIEW ??\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\"formBuilder.submissions.actionView\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"View\",\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\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t setDeleteId(sub.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\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{localization?.FORM_BUILDER_SUBMISSIONS_ACTION_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\"formBuilder.submissions.actionDelete\",\n\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{/* View submission dialog */}\n\t\t\t {\n\t\t\t\t\tif (!open) setViewSubmissionId(null);\n\t\t\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?.FORM_BUILDER_SUBMISSIONS_DETAILS_TITLE ??\n\t\t\t\t\t\t\t\tt(\"formBuilder.submissions.detailsTitle\", \"Submission Details\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{t(\n\t\t\t\t\t\t\t\t\"formBuilder.submissions.detailsDescription\",\n\t\t\t\t\t\t\t\t\"Submitted form data and request metadata.\",\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{isViewLoading && (\n\t\t\t\t\t\t

\n\t\t\t\t\t\t\t{localization?.FORM_BUILDER_STATUS_LOADING ??\n\t\t\t\t\t\t\t\tt(\"formBuilder.common.statusLoading\", \"Loading...\")}\n\t\t\t\t\t\t

\n\t\t\t\t\t)}\n\t\t\t\t\t{viewError && (\n\t\t\t\t\t\t

\n\t\t\t\t\t\t\t{localization?.FORM_BUILDER_TOAST_ERROR ??\n\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\"formBuilder.toasts.error\",\n\t\t\t\t\t\t\t\t\t\"An error occurred. Please try again.\",\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{!isViewLoading && !viewError && viewSubmission && (\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{localization?.FORM_BUILDER_SUBMISSIONS_FIELD_ID ??\n\t\t\t\t\t\t\t\t\t\t\tt(\"formBuilder.submissions.fieldId\", \"ID:\")}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t

{viewSubmission.id}

\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\n\t\t\t\t\t\t\t\t\t\t{localization?.FORM_BUILDER_SUBMISSIONS_FIELD_SUBMITTED ??\n\t\t\t\t\t\t\t\t\t\t\tt(\"formBuilder.submissions.fieldSubmitted\", \"Submitted:\")}\n\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\t\t{new Date(viewSubmission.submittedAt).toLocaleString()}\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\n\t\t\t\t\t\t\t\t\t\t{localization?.FORM_BUILDER_SUBMISSIONS_FIELD_IP ??\n\t\t\t\t\t\t\t\t\t\t\tt(\"formBuilder.submissions.fieldIp\", \"IP Address:\")}\n\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\t\t{viewSubmission.ipAddress || \"-\"}\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\n\t\t\t\t\t\t\t\t\t\t{localization?.FORM_BUILDER_SUBMISSIONS_FIELD_USER_AGENT ??\n\t\t\t\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\t\t\t\"formBuilder.submissions.fieldUserAgent\",\n\t\t\t\t\t\t\t\t\t\t\t\t\"User Agent:\",\n\t\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\t

\n\t\t\t\t\t\t\t\t\t\t{viewSubmission.userAgent || \"-\"}\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\t
\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization?.FORM_BUILDER_SUBMISSIONS_FIELD_DATA ??\n\t\t\t\t\t\t\t\t\t\tt(\"formBuilder.submissions.fieldData\", \"Data:\")}\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{JSON.stringify(viewSubmission.parsedData, null, 2)}\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\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?.FORM_BUILDER_SUBMISSIONS_DELETE_TITLE ??\n\t\t\t\t\t\t\t\tt(\"formBuilder.submissions.deleteTitle\", \"Delete Submission\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.FORM_BUILDER_SUBMISSIONS_DELETE_CONFIRM ??\n\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\"formBuilder.submissions.deleteConfirm\",\n\t\t\t\t\t\t\t\t\t\"Are you sure you want to delete this submission?\",\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?.FORM_BUILDER_BUTTON_CANCEL ??\n\t\t\t\t\t\t\t\tt(\"formBuilder.common.buttonCancel\", \"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? (localization?.FORM_BUILDER_STATUS_DELETING ??\n\t\t\t\t\t\t\t\t\tt(\"formBuilder.common.statusDeleting\", \"Deleting...\"))\n\t\t\t\t\t\t\t\t: (localization?.FORM_BUILDER_BUTTON_DELETE ??\n\t\t\t\t\t\t\t\t\tt(\"formBuilder.common.buttonDelete\", \"Delete\"))}\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/form-builder/client/components/pages/submissions-page.internal.tsx" }, { @@ -139,13 +139,13 @@ { "path": "btst/form-builder/client/components/shared/page-wrapper.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport { PageWrapper as SharedPageWrapper } from \"@/components/ui/page-wrapper\";\nimport type { FormBuilderPluginOverrides } from \"../../overrides\";\nimport { FORM_BUILDER_PLUGIN_ID } from \"../../constants\";\n\nexport function PageWrapper({\n\tchildren,\n\tclassName,\n\ttestId,\n}: {\n\tchildren: React.ReactNode;\n\tclassName?: string;\n\ttestId?: string;\n}) {\n\tconst { showAttribution } = usePluginOverrides<\n\t\tFormBuilderPluginOverrides,\n\t\tPartial\n\t>(FORM_BUILDER_PLUGIN_ID, {\n\t\tshowAttribution: true,\n\t});\n\n\treturn (\n\t\t\n\t\t\t{children}\n\t\t\n\t);\n}\n", + "content": "\"use client\";\n\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport { PageWrapper as SharedPageWrapper } from \"@/components/ui/page-wrapper\";\nimport type { FormBuilderPluginOverrides } from \"../../overrides\";\nimport { FORM_BUILDER_PLUGIN_ID } from \"../../constants\";\nimport { cn } from \"../../../utils\";\n\nexport function PageWrapper({\n\tchildren,\n\tclassName,\n\ttestId,\n}: {\n\tchildren: React.ReactNode;\n\tclassName?: string;\n\ttestId?: string;\n}) {\n\tconst { showAttribution, classNames } = usePluginOverrides<\n\t\tFormBuilderPluginOverrides,\n\t\tPartial\n\t>(FORM_BUILDER_PLUGIN_ID, {\n\t\tshowAttribution: true,\n\t});\n\n\treturn (\n\t\t\n\t\t\t{children}\n\t\t\n\t);\n}\n", "target": "src/components/btst/form-builder/client/components/shared/page-wrapper.tsx" }, { "path": "btst/form-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\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", + "content": "\"use client\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { ChevronRight } from \"lucide-react\";\nimport { cn } from \"../../../utils\";\n\ninterface PaginationProps {\n\ttotal: number;\n\tshowing: number;\n\thasMore: boolean;\n\tisLoadingMore: boolean;\n\tonLoadMore: () => void;\n\tclassName?: string;\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\tclassName,\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/form-builder/client/components/shared/pagination.tsx" }, { @@ -199,7 +199,7 @@ { "path": "btst/form-builder/client/overrides.ts", "type": "registry:lib", - "content": "import type { ComponentType } from \"react\";\nimport type { FormBuilderLocalization } from \"./localization\";\nimport type { AutoFormInputComponentProps } from \"@/components/ui/auto-form/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 * Overridable components and functions for the Form Builder plugin\n *\n * External consumers can provide their own implementations to customize\n * plugin-specific components and behavior.\n */\nexport interface FormBuilderPluginOverrides {\n\t/**\n\t * Custom field components for AutoForm fields.\n\t *\n\t * These map field type names to React components. Use these to:\n\t * - Override built-in field types (checkbox, date, select, radio, switch, textarea, file, number, fallback)\n\t * - Add custom field types for your forms\n\t *\n\t * @example\n\t * ```tsx\n\t * fieldComponents: {\n\t * // Override the file type with custom S3 upload\n\t * file: ({ field, label, isRequired }) => (\n\t * \n\t * ),\n\t * // Add a custom rich text editor\n\t * richText: ({ field, label }) => (\n\t * \n\t * ),\n\t * }\n\t * ```\n\t */\n\tfieldComponents?: Record>;\n\n\t/**\n\t * Localization object for the Form Builder plugin\n\t */\n\tlocalization?: Partial;\n\n\t/**\n\t * Whether to show the attribution\n\t */\n\tshowAttribution?: boolean;\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., 'formList', 'formBuilder', 'submissions')\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 { FormBuilderLocalization } from \"./localization\";\nimport type { AutoFormInputComponentProps } from \"@/components/ui/auto-form/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 * Overridable components and functions for the Form Builder plugin\n *\n * External consumers can provide their own implementations to customize\n * plugin-specific components and behavior.\n */\nexport interface FormBuilderPluginOverrides {\n\t/**\n\t * Custom field components for AutoForm fields.\n\t *\n\t * These map field type names to React components. Use these to:\n\t * - Override built-in field types (checkbox, date, select, radio, switch, textarea, file, number, fallback)\n\t * - Add custom field types for your forms\n\t *\n\t * @example\n\t * ```tsx\n\t * fieldComponents: {\n\t * // Override the file type with custom S3 upload\n\t * file: ({ field, label, isRequired }) => (\n\t * \n\t * ),\n\t * // Add a custom rich text editor\n\t * richText: ({ field, label }) => (\n\t * \n\t * ),\n\t * }\n\t * ```\n\t */\n\tfieldComponents?: Record>;\n\n\t/**\n\t * Optional class names merged onto specific UI elements.\n\t * Use these for targeted styling without replacing whole components.\n\t */\n\tclassNames?: {\n\t\t/** Page container shared by every Form Builder page */\n\t\tcontainer?: string;\n\t\t/** Wrapper around the forms or submissions table */\n\t\ttable?: string;\n\t\t/** Each body row in the forms or submissions table */\n\t\ttableRow?: string;\n\t\t/** Pagination footer under the forms or submissions list */\n\t\tpagination?: string;\n\t\t/** Root of `FormRenderer`, including loading, error, and success states */\n\t\tform?: string;\n\t};\n\n\t/**\n\t * Localization object for the Form Builder plugin\n\t */\n\tlocalization?: Partial;\n\n\t/**\n\t * Whether to show the attribution\n\t */\n\tshowAttribution?: boolean;\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., 'formList', 'formBuilder', 'submissions')\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/form-builder/client/overrides.ts" }, { diff --git a/packages/stack/src/plugins/blog/__tests__/classnames-overrides.test.tsx b/packages/stack/src/plugins/blog/__tests__/classnames-overrides.test.tsx index 32654499d..ad9d981e9 100644 --- a/packages/stack/src/plugins/blog/__tests__/classnames-overrides.test.tsx +++ b/packages/stack/src/plugins/blog/__tests__/classnames-overrides.test.tsx @@ -175,12 +175,32 @@ describe("blog classNames overrides (issue #36)", () => { stack={createTestClientStack({ blog: blogClientPlugin() })} overrides={{ blog: { uploadImage: async () => "" } }} > - {}} hasMore /> + {}} + hasMore + /> , ); const card = query('[data-slot="card"]'); expect(card.className).toContain("group"); expect(card.className).not.toContain(CLASS_NAMES.postCard); + const metadata = query("time").parentElement!; + const tags = metadata.parentElement!.querySelectorAll( + '[data-slot="badge"]', + ); + expect(tags).toHaveLength(2); + for (const tag of tags) { + expect(tag.parentElement).toBe(metadata.parentElement); + } }); }); diff --git a/packages/stack/src/plugins/blog/client/components/shared/post-card.tsx b/packages/stack/src/plugins/blog/client/components/shared/post-card.tsx index be37967da..586efa999 100644 --- a/packages/stack/src/plugins/blog/client/components/shared/post-card.tsx +++ b/packages/stack/src/plugins/blog/client/components/shared/post-card.tsx @@ -234,6 +234,11 @@ export function PostCard({ post }: { post: SerializedPost }) { const timestamp = post.publishedAt || post.createdAt; const postDate = formatPostDate(timestamp); const gradient = post.image ? null : getGradientFromTitle(post.title); + const tags = post.tags?.map((tag) => ( + + {tag.name} + + )); return (
-
- {post.tags && - post.tags.length > 0 && - post.tags.map((tag) => ( - - {tag.name} - - ))} -
+ {classNames?.tagsList ? ( +
+ {tags} +
+ ) : ( + tags + )}
diff --git a/packages/stack/src/plugins/blog/client/overrides.ts b/packages/stack/src/plugins/blog/client/overrides.ts index 38261f657..ee410e048 100644 --- a/packages/stack/src/plugins/blog/client/overrides.ts +++ b/packages/stack/src/plugins/blog/client/overrides.ts @@ -46,11 +46,17 @@ export interface BlogPluginOverrides { * allowing styling without replacing whole components. */ classNames?: { + /** Page container shared by blog pages. */ container?: string; + /** Each built-in post card. */ postCard?: string; + /** Title inside a post card. */ postTitle?: string; + /** Date metadata inside a post card. */ postMeta?: string; + /** Optional flex wrapper around post-card tags; omitted when unset. */ tagsList?: string; + /** Wrapper around the load-more control. */ pagination?: string; }; /** diff --git a/packages/stack/src/plugins/form-builder/__tests__/classnames-overrides.test.tsx b/packages/stack/src/plugins/form-builder/__tests__/classnames-overrides.test.tsx index 438223fb4..68dc1cb0a 100644 --- a/packages/stack/src/plugins/form-builder/__tests__/classnames-overrides.test.tsx +++ b/packages/stack/src/plugins/form-builder/__tests__/classnames-overrides.test.tsx @@ -49,7 +49,19 @@ vi.mock("../client/hooks/form-builder-hooks", () => rendererHooks); // The auto-form internals are unrelated to class wiring — stub them. vi.mock("@workspace/ui/components/auto-form/stepped-auto-form", () => ({ - SteppedAutoForm: () =>
, + SteppedAutoForm: ({ + onSubmit, + }: { + onSubmit: (values: Record) => void; + }) => ( + + ), })); const CLASS_NAMES = { @@ -248,6 +260,48 @@ describe("form-builder classNames overrides (issue #36)", () => { ).toBeTruthy(); }); + it.each([ + ["loading", { form: null, isLoading: true, error: null }], + [ + "error", + { form: null, isLoading: false, error: new Error("Unavailable") }, + ], + ["not found", { form: null, isLoading: false, error: null }], + [ + "inactive", + { form: { ...form, status: "inactive" }, isLoading: false, error: null }, + ], + [ + "invalid schema", + { form: { ...form, schema: "invalid" }, isLoading: false, error: null }, + ], + ])("preserves form classes in the %s state", async (_state, result) => { + rendererHooks.useFormBySlug.mockReturnValue(result); + await render(); + const wrapper = container.firstElementChild!; + expect(wrapper.className).toContain(CLASS_NAMES.form); + expect(wrapper.className).toContain("p-6"); + }); + + it("preserves form classes after successful submission", async () => { + const submit = vi + .fn() + .mockResolvedValue({ form: { successMessage: "Thank you!" } }); + rendererHooks.useSubmitForm.mockReturnValue({ + mutateAsync: submit, + isPending: false, + }); + await render(); + await act(async () => { + query('[data-testid="stepped-auto-form"]').click(); + }); + expect(submit).toHaveBeenCalled(); + expect(container.textContent).toContain("Thank you!"); + const wrapper = container.firstElementChild!; + expect(wrapper.className).toContain(CLASS_NAMES.form); + expect(wrapper.className).toContain("p-6"); + }); + it("leaves base classes untouched when no classNames override is supplied", async () => { await render(, false); diff --git a/packages/stack/src/plugins/form-builder/client/components/forms/form-renderer.tsx b/packages/stack/src/plugins/form-builder/client/components/forms/form-renderer.tsx index 5d5545d3d..7fba166a0 100644 --- a/packages/stack/src/plugins/form-builder/client/components/forms/form-renderer.tsx +++ b/packages/stack/src/plugins/form-builder/client/components/forms/form-renderer.tsx @@ -139,6 +139,7 @@ export function FormRenderer({ classNames, } = usePluginOverrides(FORM_BUILDER_PLUGIN_ID); + const formClassName = cn(className, classNames?.form); const { form, isLoading, error } = useFormBySlug(slug); const submitMutation = useSubmitForm(slug); @@ -205,7 +206,7 @@ export function FormRenderer({ // Loading state if (isLoading) { return ( -
+
); @@ -214,7 +215,7 @@ export function FormRenderer({ // Error state if (error) { return ( -
+
); @@ -223,7 +224,7 @@ export function FormRenderer({ // Form not found if (!form) { return ( -
+
+
+
+
); @@ -304,10 +305,7 @@ export function FormRenderer({ status: form.status, })} > -
+
`@import "${c}";`).join("\n"); const baseDependencies: Record = { - "@btst/stack": "4.0.0", + "@btst/stack": "4.1.0", "@btst/adapter-memory": "3.0.0", "@btst/yar": "1.3.2", "@tanstack/react-query": "5.102.0", @@ -456,7 +456,7 @@ function buildReactRouterProjectFiles( ); const baseDependencies: Record = { "@btst/adapter-memory": "3.0.0", - "@btst/stack": "4.0.0", + "@btst/stack": "4.1.0", "@btst/yar": "1.3.2", "@react-router/node": "^7.0.0", "@react-router/serve": "^7.0.0", @@ -810,7 +810,7 @@ function buildTanstackProjectFiles( ); const baseDependencies: Record = { "@btst/adapter-memory": "3.0.0", - "@btst/stack": "4.0.0", + "@btst/stack": "4.1.0", "@btst/yar": "1.3.2", "@tailwindcss/postcss": "^4.3.2", "@tanstack/react-query": "5.102.0",