From e5798cd243a6ea246f368ff39ddd8592f9fdac25 Mon Sep 17 00:00:00 2001 From: olliethedev <5933733+olliethedev@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:44:47 +0000 Subject: [PATCH 1/2] feat(form-builder): phase-2 sweep onto core primitives Migrate the form-builder plugin to the v3 core primitives, mirroring the CMS sweep: - Declare formBuilderResources and generate all hooks via createResource; form-builder-hooks.tsx is now a thin public wrapper layer - Move the editor save flow onto the resource useForm (server field errors inline, success/error toasts, create->edit redirect) - Add a bounded search param to the forms list API (schema max(200), DEFAULT_MAX_PAGE_SIZE scan cap) and a URL-synced search box on the list page via useListState (debounce + external re-seed) - Replace sonner with useNotify and route all UI strings through useTranslate with localization overrides (new renderer catalog) - Wrap pages in ComposedRoute with permission props and CanAccess around New/Edit/Delete/Submissions controls - Simplify SSR loaders to prefetch(Infinite)Query on factory entries - Tests: query-key parity guard, getters search coverage, client-sweep jsdom suite; E2E search spec; registry regenerated Co-authored-by: Cursor --- e2e/tests/smoke.form-builder.spec.ts | 58 ++ .../stack/registry/btst-form-builder.json | 36 +- .../__tests__/form-builder-query-keys.test.ts | 72 ++ .../__tests__/client-sweep.test.tsx | 568 ++++++++++++++++ .../form-builder/__tests__/getters.test.ts | 49 +- .../src/plugins/form-builder/api/getters.ts | 69 +- .../src/plugins/form-builder/api/plugin.ts | 4 +- .../form-builder/api/query-key-defs.ts | 11 +- .../client/components/forms/form-renderer.tsx | 74 ++- .../client/components/pages/404-page.tsx | 23 +- .../pages/form-builder-page.internal.tsx | 182 +++-- .../components/pages/form-builder-page.tsx | 42 +- .../pages/form-list-page.internal.tsx | 323 ++++++--- .../components/pages/form-list-page.tsx | 32 +- .../pages/submissions-page.internal.tsx | 185 ++++-- .../components/pages/submissions-page.tsx | 41 +- .../client/components/shared/pagination.tsx | 4 +- .../client/hooks/form-builder-hooks.tsx | 621 +++--------------- .../client/hooks/form-builder-resource.ts | 14 + .../localization/form-builder-common.ts | 6 + .../client/localization/form-builder-list.ts | 8 +- .../localization/form-builder-renderer.ts | 10 + .../localization/form-builder-submissions.ts | 7 + .../localization/form-builder-toasts.ts | 4 + .../form-builder/client/localization/index.ts | 2 + .../plugins/form-builder/client/plugin.tsx | 50 +- .../src/plugins/form-builder/query-keys.ts | 357 +++++----- .../stack/src/plugins/form-builder/schemas.ts | 9 +- 28 files changed, 1853 insertions(+), 1008 deletions(-) create mode 100644 packages/stack/src/__tests__/form-builder-query-keys.test.ts create mode 100644 packages/stack/src/plugins/form-builder/__tests__/client-sweep.test.tsx create mode 100644 packages/stack/src/plugins/form-builder/client/hooks/form-builder-resource.ts create mode 100644 packages/stack/src/plugins/form-builder/client/localization/form-builder-renderer.ts diff --git a/e2e/tests/smoke.form-builder.spec.ts b/e2e/tests/smoke.form-builder.spec.ts index e854d663..b1d08ef9 100644 --- a/e2e/tests/smoke.form-builder.spec.ts +++ b/e2e/tests/smoke.form-builder.spec.ts @@ -44,6 +44,64 @@ test.describe("Form Builder Plugin - Admin Pages", () => { ); }); + test("search filters the forms list and syncs the URL", async ({ + page, + request, + }) => { + const errors: string[] = []; + page.on("console", (msg) => { + if (msg.type() === "error") errors.push(msg.text()); + }); + + // Create one form that matches the search and one that doesn't + const targetSlug = `search-target-form-${testRunId}`; + const otherSlug = `search-other-form-${testRunId}`; + const schema = JSON.stringify({ + type: "object", + properties: { name: { type: "string" } }, + }); + for (const [slug, name] of [ + [targetSlug, `Searchable Form ${testRunId}`], + [otherSlug, `Unrelated Form ${testRunId}`], + ]) { + const response = await request.post("/api/data/forms", { + headers: { "content-type": "application/json" }, + data: { name, slug, schema, status: "active" }, + }); + expect( + response.ok(), + `Form creation failed with status ${response.status()}`, + ).toBe(true); + } + + await page.goto("/pages/forms", { waitUntil: "networkidle" }); + await expect(page.locator('[data-testid="form-list-page"]')).toBeVisible(); + + // Type into the search box; the query is debounced into the URL + await page + .locator('[data-testid="form-builder-list-search"]') + .fill(targetSlug); + await expect(page).toHaveURL(new RegExp(`q=${targetSlug}`), { + timeout: 10000, + }); + + // Only the matching form remains in the table + await expect(page.locator(`tr:has-text("${targetSlug}")`)).toBeVisible({ + timeout: 30000, + }); + await expect(page.locator(`tr:has-text("${otherSlug}")`)).not.toBeVisible(); + + // Clearing the search restores the full list + await page.locator('[data-testid="form-builder-list-search"]').fill(""); + await expect(page.locator(`tr:has-text("${otherSlug}")`)).toBeVisible({ + timeout: 30000, + }); + + expect(errors, `Console errors detected: \n${errors.join("\n")}`).toEqual( + [], + ); + }); + test("new form page renders with form builder", async ({ page }) => { const errors: string[] = []; page.on("console", (msg) => { diff --git a/packages/stack/registry/btst-form-builder.json b/packages/stack/registry/btst-form-builder.json index 2a55fff9..50ea6708 100644 --- a/packages/stack/registry/btst-form-builder.json +++ b/packages/stack/registry/btst-form-builder.json @@ -37,7 +37,7 @@ { "path": "btst/form-builder/schemas.ts", "type": "registry:lib", - "content": "import { z } from \"zod\";\n\n/**\n * Schema for listing forms with pagination\n */\nexport const listFormsQuerySchema = z.object({\n\tstatus: z.enum([\"active\", \"inactive\", \"archived\"]).optional(),\n\tlimit: z.coerce.number().min(1).max(100).optional().default(20),\n\toffset: z.coerce.number().min(0).optional().default(0),\n});\n\n/**\n * Schema for creating a form\n */\nexport const createFormSchema = z.object({\n\tname: z.string().min(1, \"Name is required\"),\n\tslug: z.string().min(1, \"Slug is required\"),\n\tdescription: z.string().optional(),\n\tschema: z.string().min(1, \"Schema is required\"),\n\tsuccessMessage: z.string().optional(),\n\tredirectUrl: z.string().url().optional().or(z.literal(\"\")),\n\tstatus: z\n\t\t.enum([\"active\", \"inactive\", \"archived\"])\n\t\t.optional()\n\t\t.default(\"active\"),\n});\n\n/**\n * Schema for updating a form\n */\nexport const updateFormSchema = z.object({\n\tname: z.string().min(1, \"Name is required\").optional(),\n\tslug: z.string().min(1, \"Slug is required\").optional(),\n\tdescription: z.string().optional(),\n\tschema: z.string().min(1, \"Schema is required\").optional(),\n\tsuccessMessage: z.string().optional(),\n\tredirectUrl: z.string().url().optional().or(z.literal(\"\")),\n\tstatus: z.enum([\"active\", \"inactive\", \"archived\"]).optional(),\n});\n\n/**\n * Schema for form response\n */\nexport const formResponseSchema = z.object({\n\tid: z.string(),\n\tname: z.string(),\n\tslug: z.string(),\n\tdescription: z.string().nullable().optional(),\n\tschema: z.string(),\n\tsuccessMessage: z.string().nullable().optional(),\n\tredirectUrl: z.string().nullable().optional(),\n\tstatus: z.string(),\n\tcreatedBy: z.string().nullable().optional(),\n\tcreatedAt: z.string(),\n\tupdatedAt: z.string(),\n});\n\n/**\n * Schema for paginated forms response\n */\nexport const paginatedFormsResponseSchema = z.object({\n\titems: z.array(formResponseSchema),\n\ttotal: z.number(),\n\tlimit: z.number(),\n\toffset: z.number(),\n});\n\n/**\n * Schema for listing form submissions with pagination\n */\nexport const listSubmissionsQuerySchema = z.object({\n\tlimit: z.coerce.number().min(1).max(100).optional().default(20),\n\toffset: z.coerce.number().min(0).optional().default(0),\n});\n\n/**\n * Schema for submitting a form (public)\n */\nexport const submitFormSchema = z.object({\n\t// Use passthrough object for dynamic form data validation\n\tdata: z.object({}).passthrough(),\n});\n\n/**\n * Schema for form submission response\n */\nexport const formSubmissionResponseSchema = z.object({\n\tid: z.string(),\n\tformId: z.string(),\n\tdata: z.string(),\n\tsubmittedAt: z.string(),\n\tsubmittedBy: z.string().nullable().optional(),\n\tipAddress: z.string().nullable().optional(),\n\tuserAgent: z.string().nullable().optional(),\n});\n\n/**\n * Schema for form submission with parsed data response\n */\nexport const formSubmissionWithDataResponseSchema =\n\tformSubmissionResponseSchema.extend({\n\t\t// Use passthrough object for dynamic parsed data\n\t\tparsedData: z.object({}).passthrough(),\n\t\tform: formResponseSchema.optional(),\n\t});\n\n/**\n * Schema for paginated submissions response\n */\nexport const paginatedSubmissionsResponseSchema = z.object({\n\titems: z.array(formSubmissionWithDataResponseSchema),\n\ttotal: z.number(),\n\tlimit: z.number(),\n\toffset: z.number(),\n});\n\n// Export inferred types\nexport type ListFormsQuery = z.infer;\nexport type CreateFormInput = z.infer;\nexport type UpdateFormInput = z.infer;\nexport type ListSubmissionsQuery = z.infer;\nexport type SubmitFormInput = z.infer;\n", + "content": "import { z } from \"zod\";\n\n/**\n * Cap on the DB scan when free-text search forces the in-memory filter in\n * `getAllForms`, bounding server memory use.\n */\nexport const DEFAULT_MAX_PAGE_SIZE = 1000;\n\n/**\n * Schema for listing forms with pagination and free-text search\n */\nexport const listFormsQuerySchema = z.object({\n\tstatus: z.enum([\"active\", \"inactive\", \"archived\"]).optional(),\n\tlimit: z.coerce.number().min(1).max(100).optional().default(20),\n\toffset: z.coerce.number().min(0).optional().default(0),\n\tsearch: z.string().max(200).optional(),\n});\n\n/**\n * Schema for creating a form\n */\nexport const createFormSchema = z.object({\n\tname: z.string().min(1, \"Name is required\"),\n\tslug: z.string().min(1, \"Slug is required\"),\n\tdescription: z.string().optional(),\n\tschema: z.string().min(1, \"Schema is required\"),\n\tsuccessMessage: z.string().optional(),\n\tredirectUrl: z.string().url().optional().or(z.literal(\"\")),\n\tstatus: z\n\t\t.enum([\"active\", \"inactive\", \"archived\"])\n\t\t.optional()\n\t\t.default(\"active\"),\n});\n\n/**\n * Schema for updating a form\n */\nexport const updateFormSchema = z.object({\n\tname: z.string().min(1, \"Name is required\").optional(),\n\tslug: z.string().min(1, \"Slug is required\").optional(),\n\tdescription: z.string().optional(),\n\tschema: z.string().min(1, \"Schema is required\").optional(),\n\tsuccessMessage: z.string().optional(),\n\tredirectUrl: z.string().url().optional().or(z.literal(\"\")),\n\tstatus: z.enum([\"active\", \"inactive\", \"archived\"]).optional(),\n});\n\n/**\n * Schema for form response\n */\nexport const formResponseSchema = z.object({\n\tid: z.string(),\n\tname: z.string(),\n\tslug: z.string(),\n\tdescription: z.string().nullable().optional(),\n\tschema: z.string(),\n\tsuccessMessage: z.string().nullable().optional(),\n\tredirectUrl: z.string().nullable().optional(),\n\tstatus: z.string(),\n\tcreatedBy: z.string().nullable().optional(),\n\tcreatedAt: z.string(),\n\tupdatedAt: z.string(),\n});\n\n/**\n * Schema for paginated forms response\n */\nexport const paginatedFormsResponseSchema = z.object({\n\titems: z.array(formResponseSchema),\n\ttotal: z.number(),\n\tlimit: z.number(),\n\toffset: z.number(),\n});\n\n/**\n * Schema for listing form submissions with pagination\n */\nexport const listSubmissionsQuerySchema = z.object({\n\tlimit: z.coerce.number().min(1).max(100).optional().default(20),\n\toffset: z.coerce.number().min(0).optional().default(0),\n});\n\n/**\n * Schema for submitting a form (public)\n */\nexport const submitFormSchema = z.object({\n\t// Use passthrough object for dynamic form data validation\n\tdata: z.object({}).passthrough(),\n});\n\n/**\n * Schema for form submission response\n */\nexport const formSubmissionResponseSchema = z.object({\n\tid: z.string(),\n\tformId: z.string(),\n\tdata: z.string(),\n\tsubmittedAt: z.string(),\n\tsubmittedBy: z.string().nullable().optional(),\n\tipAddress: z.string().nullable().optional(),\n\tuserAgent: z.string().nullable().optional(),\n});\n\n/**\n * Schema for form submission with parsed data response\n */\nexport const formSubmissionWithDataResponseSchema =\n\tformSubmissionResponseSchema.extend({\n\t\t// Use passthrough object for dynamic parsed data\n\t\tparsedData: z.object({}).passthrough(),\n\t\tform: formResponseSchema.optional(),\n\t});\n\n/**\n * Schema for paginated submissions response\n */\nexport const paginatedSubmissionsResponseSchema = z.object({\n\titems: z.array(formSubmissionWithDataResponseSchema),\n\ttotal: z.number(),\n\tlimit: z.number(),\n\toffset: z.number(),\n});\n\n// Export inferred types\nexport type ListFormsQuery = z.infer;\nexport type CreateFormInput = z.infer;\nexport type UpdateFormInput = z.infer;\nexport type ListSubmissionsQuery = z.infer;\nexport type SubmitFormInput = z.infer;\n", "target": "src/components/btst/form-builder/schemas.ts" }, { @@ -49,7 +49,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 { usePluginOverrides } 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 { FORM_BUILDER_LOCALIZATION } from \"../../localization\";\nimport type { SerializedFormSubmission } from \"../../../types\";\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\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\tFailed to load form\n\t\t\t

\n\t\t\t

\n\t\t\t\t{error.message || \"An unexpected error occurred\"}\n\t\t\t

\n\t\t
\n\t);\n}\n\nfunction DefaultSuccessComponent({ message }: { message: React.ReactNode }) {\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\tForm 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 { fieldComponents: overrideFieldComponents, localization } =\n\t\tusePluginOverrides<\n\t\t\tFormBuilderPluginOverrides,\n\t\t\tPartial\n\t\t>(\"form-builder\", {\n\t\t\tlocalization: FORM_BUILDER_LOCALIZATION,\n\t\t});\n\n\tconst loc = localization || FORM_BUILDER_LOCALIZATION;\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\"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 handleSubmit(values as Record)}\n\t\t\t\tisSubmitting={submitMutation.isPending}\n\t\t\t\tsubmitButtonText={submitButtonText || loc.FORM_BUILDER_BUTTON_SUBMIT}\n\t\t\t/>\n\t\t
\n\t);\n}\n", + "content": "\"use client\";\n\nimport { useState, useMemo, type ComponentType } from \"react\";\nimport { usePluginOverrides, useTranslate } 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\";\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 } =\n\t\tusePluginOverrides(\"form-builder\");\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 } =\n\t\tusePluginOverrides(\"form-builder\");\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\");\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 handleSubmit(values as Record)}\n\t\t\t\tisSubmitting={submitMutation.isPending}\n\t\t\t\tsubmitButtonText={\n\t\t\t\t\tsubmitButtonText ||\n\t\t\t\t\t(localization?.FORM_BUILDER_BUTTON_SUBMIT ??\n\t\t\t\t\t\tt(\"formBuilder.common.buttonSubmit\", \"Submit\"))\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" }, { @@ -79,43 +79,43 @@ { "path": "btst/form-builder/client/components/pages/404-page.tsx", "type": "registry:page", - "content": "\"use client\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { usePluginOverrides, useBasePath } from \"@btst/stack/context\";\nimport type { FormBuilderPluginOverrides } from \"../../overrides\";\n\nexport function NotFoundPage() {\n\tconst { navigate, Link } =\n\t\tusePluginOverrides(\"form-builder\");\n\tconst basePath = useBasePath();\n\n\tconst LinkComponent = Link || \"a\";\n\n\treturn (\n\t\t
\n\t\t\t

404

\n\t\t\t

\n\t\t\t\tPage not found\n\t\t\t

\n\t\t\t

\n\t\t\t\tThe page you're looking for doesn't exist or has been moved.\n\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 {\n\tusePluginOverrides,\n\tuseBasePath,\n\tuseTranslate,\n} from \"@btst/stack/context\";\nimport type { FormBuilderPluginOverrides } from \"../../overrides\";\n\nexport function NotFoundPage() {\n\tconst t = useTranslate();\n\tconst { Link, localization } =\n\t\tusePluginOverrides(\"form-builder\");\n\tconst basePath = useBasePath();\n\n\tconst LinkComponent = Link || \"a\";\n\n\treturn (\n\t\t
\n\t\t\t

404

\n\t\t\t

\n\t\t\t\t{localization?.FORM_BUILDER_404_TITLE ??\n\t\t\t\t\tt(\"formBuilder.common.404Title\", \"Page not found\")}\n\t\t\t

\n\t\t\t

\n\t\t\t\t{localization?.FORM_BUILDER_404_DESCRIPTION ??\n\t\t\t\t\tt(\n\t\t\t\t\t\t\"formBuilder.common.404Description\",\n\t\t\t\t\t\t\"The page you're looking for doesn't exist or has been moved.\",\n\t\t\t\t\t)}\n\t\t\t

\n\t\t\t\n\t\t
\n\t);\n}\n", "target": "src/components/btst/form-builder/client/components/pages/404-page.tsx" }, { "path": "btst/form-builder/client/components/pages/form-builder-page.internal.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { useState, useEffect, useCallback } from \"react\";\nimport { usePluginOverrides, useBasePath } from \"@btst/stack/context\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport {\n\tSelect,\n\tSelectContent,\n\tSelectItem,\n\tSelectTrigger,\n\tSelectValue,\n} from \"@/components/ui/select\";\nimport { ArrowLeft, Save } from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport { FormBuilder } from \"@/components/ui/form-builder\";\nimport type { JSONSchema } from \"@/components/ui/form-builder/types\";\n\nimport {\n\tuseSuspenseFormById,\n\tuseCreateForm,\n\tuseUpdateForm,\n} from \"@btst/stack/plugins/form-builder/client/hooks\";\nimport type { FormBuilderPluginOverrides } from \"../../overrides\";\nimport { FORM_BUILDER_LOCALIZATION } from \"../../localization\";\nimport { slugify } from \"../../../utils\";\nimport type { SerializedForm } from \"../../../types\";\n\nexport interface FormBuilderPageProps {\n\tid?: string;\n}\n\n/**\n * Entry point component that conditionally renders the appropriate\n * sub-component based on whether we're creating or editing a form.\n * This avoids conditional hook calls which violate React's Rules of Hooks.\n */\nexport function FormBuilderPage({ id }: FormBuilderPageProps) {\n\tif (id) {\n\t\treturn ;\n\t}\n\treturn ;\n}\n\n/**\n * Component for editing an existing form.\n * Uses useSuspenseFormById unconditionally since id is always defined.\n */\nfunction EditFormBuilderPage({ id }: { id: string }) {\n\tconst { form: existingForm } = useSuspenseFormById(id);\n\treturn ;\n}\n\n/**\n * Component for creating a new form.\n * No data fetching needed.\n */\nfunction CreateFormBuilderPage() {\n\treturn ;\n}\n\ninterface FormBuilderPageContentProps {\n\tid?: string;\n\texistingForm?: SerializedForm | null;\n}\n\nfunction FormBuilderPageContent({\n\tid,\n\texistingForm,\n}: FormBuilderPageContentProps) {\n\tconst { navigate, Link, localization } = usePluginOverrides<\n\t\tFormBuilderPluginOverrides,\n\t\tPartial\n\t>(\"form-builder\", {\n\t\tlocalization: FORM_BUILDER_LOCALIZATION,\n\t});\n\tconst basePath = useBasePath();\n\n\tconst createMutation = useCreateForm();\n\tconst updateMutation = useUpdateForm();\n\n\tconst loc = localization || FORM_BUILDER_LOCALIZATION;\n\tconst LinkComponent = Link || \"a\";\n\n\t// Form state\n\tconst [name, setName] = useState(existingForm?.name || \"\");\n\tconst [slug, setSlug] = useState(existingForm?.slug || \"\");\n\tconst [status, setStatus] = useState<\"active\" | \"inactive\" | \"archived\">(\n\t\t(existingForm?.status as \"active\" | \"inactive\" | \"archived\") || \"active\",\n\t);\n\tconst [schema, setSchema] = useState(() => {\n\t\tif (existingForm?.schema) {\n\t\t\ttry {\n\t\t\t\treturn JSON.parse(existingForm.schema) as JSONSchema;\n\t\t\t} catch {\n\t\t\t\treturn undefined;\n\t\t\t}\n\t\t}\n\t\treturn undefined;\n\t});\n\n\t// Auto-generate slug from name\n\tconst [autoSlug, setAutoSlug] = useState(!id);\n\n\tuseEffect(() => {\n\t\tif (autoSlug && name) {\n\t\t\tsetSlug(slugify(name));\n\t\t}\n\t}, [name, autoSlug]);\n\n\tconst handleSchemaChange = useCallback((newSchema: JSONSchema) => {\n\t\tsetSchema(newSchema);\n\t}, []);\n\n\tconst handleSave = async () => {\n\t\tif (!name.trim()) {\n\t\t\ttoast.error(\"Name is required\");\n\t\t\treturn;\n\t\t}\n\t\tif (!slug.trim()) {\n\t\t\ttoast.error(\"Slug is required\");\n\t\t\treturn;\n\t\t}\n\t\tif (!schema) {\n\t\t\ttoast.error(\"Please add at least one field to the form\");\n\t\t\treturn;\n\t\t}\n\n\t\ttry {\n\t\t\tconst schemaStr = JSON.stringify(schema);\n\n\t\t\tif (id) {\n\t\t\t\tawait updateMutation.mutateAsync({\n\t\t\t\t\tid,\n\t\t\t\t\tdata: {\n\t\t\t\t\t\tname,\n\t\t\t\t\t\tschema: schemaStr,\n\t\t\t\t\t\tstatus,\n\t\t\t\t\t},\n\t\t\t\t});\n\t\t\t\ttoast.success(loc.FORM_BUILDER_TOAST_UPDATE_SUCCESS);\n\t\t\t} else {\n\t\t\t\tconst newForm = await createMutation.mutateAsync({\n\t\t\t\t\tname,\n\t\t\t\t\tslug,\n\t\t\t\t\tschema: schemaStr,\n\t\t\t\t\tstatus,\n\t\t\t\t});\n\t\t\t\ttoast.success(loc.FORM_BUILDER_TOAST_CREATE_SUCCESS);\n\t\t\t\tnavigate?.(`${basePath}/forms/${newForm.id}/edit`);\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tconst message = error instanceof Error ? error.message : \"Unknown error\";\n\t\t\tif (message.includes(\"slug already exists\")) {\n\t\t\t\ttoast.error(loc.FORM_BUILDER_TOAST_DUPLICATE_SLUG);\n\t\t\t} else {\n\t\t\t\ttoast.error(loc.FORM_BUILDER_TOAST_ERROR);\n\t\t\t}\n\t\t}\n\t};\n\n\tconst isSaving = createMutation.isPending || updateMutation.isPending;\n\n\treturn (\n\t\t
\n\t\t\t{/* Header */}\n\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 setName(e.target.value)}\n\t\t\t\t\t\tplaceholder={loc.FORM_BUILDER_EDITOR_NAME_PLACEHOLDER}\n\t\t\t\t\t\tclassName=\"h-8 w-48\"\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 {\n\t\t\t\t\t\t\tsetSlug(e.target.value);\n\t\t\t\t\t\t\tsetAutoSlug(false);\n\t\t\t\t\t\t}}\n\t\t\t\t\t\tplaceholder={loc.FORM_BUILDER_EDITOR_SLUG_PLACEHOLDER}\n\t\t\t\t\t\tclassName=\"h-8 w-48 font-mono text-sm\"\n\t\t\t\t\t\tdisabled={!!id}\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\t{loc.FORM_BUILDER_LABEL_STATUS}\n\t\t\t\t\t\n\t\t\t\t\t setStatus(v as typeof status)}\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\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{loc.FORM_BUILDER_STATUS_ACTIVE}\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{loc.FORM_BUILDER_STATUS_INACTIVE}\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{loc.FORM_BUILDER_STATUS_ARCHIVED}\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\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t
\n\n\t\t\t{/* Form Builder */}\n\t\t\t\n\t\t
\n\t);\n}\n", + "content": "\"use client\";\n\nimport { useState, useEffect, useCallback } from \"react\";\nimport {\n\tuseNotify,\n\tusePluginOverrides,\n\tuseBasePath,\n\tuseTranslate,\n} from \"@btst/stack/context\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport {\n\tSelect,\n\tSelectContent,\n\tSelectItem,\n\tSelectTrigger,\n\tSelectValue,\n} from \"@/components/ui/select\";\nimport { ArrowLeft, Save } from \"lucide-react\";\nimport { FormBuilder } from \"@/components/ui/form-builder\";\nimport type { JSONSchema } from \"@/components/ui/form-builder/types\";\n\nimport { useSuspenseFormById, useFormBuilderForm } from \"@btst/stack/plugins/form-builder/client/hooks\";\nimport type { FormBuilderPluginOverrides } from \"../../overrides\";\nimport { slugify } from \"../../../utils\";\nimport type { SerializedForm } from \"../../../types\";\n\nexport interface FormBuilderPageProps {\n\tid?: string;\n}\n\n/**\n * Entry point component that conditionally renders the appropriate\n * sub-component based on whether we're creating or editing a form.\n * This avoids conditional hook calls which violate React's Rules of Hooks.\n */\nexport function FormBuilderPage({ id }: FormBuilderPageProps) {\n\tif (id) {\n\t\treturn ;\n\t}\n\treturn ;\n}\n\n/**\n * Component for editing an existing form.\n * Uses useSuspenseFormById unconditionally since id is always defined.\n */\nfunction EditFormBuilderPage({ id }: { id: string }) {\n\tconst { form: existingForm } = useSuspenseFormById(id);\n\treturn ;\n}\n\n/**\n * Component for creating a new form.\n * No data fetching needed.\n */\nfunction CreateFormBuilderPage() {\n\treturn ;\n}\n\ninterface FormBuilderPageContentProps {\n\tid?: string;\n\texistingForm?: SerializedForm | null;\n}\n\ninterface FormBuilderFormValues {\n\tname: string;\n\tslug: string;\n\tstatus: \"active\" | \"inactive\" | \"archived\";\n\tschema: string;\n}\n\nfunction FormBuilderPageContent({\n\tid,\n\texistingForm,\n}: FormBuilderPageContentProps) {\n\tconst t = useTranslate();\n\tconst notify = useNotify();\n\tconst { Link, localization } =\n\t\tusePluginOverrides(\"form-builder\");\n\tconst basePath = useBasePath();\n\n\tconst LinkComponent = Link || \"a\";\n\n\t// Form state\n\tconst [name, setName] = useState(existingForm?.name || \"\");\n\tconst [slug, setSlug] = useState(existingForm?.slug || \"\");\n\tconst [status, setStatus] = useState<\"active\" | \"inactive\" | \"archived\">(\n\t\t(existingForm?.status as \"active\" | \"inactive\" | \"archived\") || \"active\",\n\t);\n\tconst [schema, setSchema] = useState(() => {\n\t\tif (existingForm?.schema) {\n\t\t\ttry {\n\t\t\t\treturn JSON.parse(existingForm.schema) as JSONSchema;\n\t\t\t} catch {\n\t\t\t\treturn undefined;\n\t\t\t}\n\t\t}\n\t\treturn undefined;\n\t});\n\n\t// Auto-generate slug from name\n\tconst [autoSlug, setAutoSlug] = useState(!id);\n\n\tuseEffect(() => {\n\t\tif (autoSlug && name) {\n\t\t\tsetSlug(slugify(name));\n\t\t}\n\t}, [name, autoSlug]);\n\n\tconst handleSchemaChange = useCallback((newSchema: JSONSchema) => {\n\t\tsetSchema(newSchema);\n\t}, []);\n\n\t// Core resource form: submits the right mutation, awaits invalidation,\n\t// notifies success/error via useNotify(), redirects after create, and\n\t// exposes server validation issues as fieldErrors for inline display.\n\tconst resourceForm = useFormBuilderForm({\n\t\taction: id ? \"edit\" : \"create\",\n\t\trecord: id ? (existingForm ?? null) : null,\n\t\tsuccessMessage: (_result, action) =>\n\t\t\taction === \"create\"\n\t\t\t\t? (localization?.FORM_BUILDER_TOAST_CREATE_SUCCESS ??\n\t\t\t\t\tt(\"formBuilder.toasts.createSuccess\", \"Form created successfully\"))\n\t\t\t\t: (localization?.FORM_BUILDER_TOAST_UPDATE_SUCCESS ??\n\t\t\t\t\tt(\"formBuilder.toasts.updateSuccess\", \"Form updated successfully\")),\n\t\terrorMessage: (error) =>\n\t\t\terror.statusCode === 409\n\t\t\t\t? (localization?.FORM_BUILDER_TOAST_DUPLICATE_SLUG ??\n\t\t\t\t\tt(\n\t\t\t\t\t\t\"formBuilder.toasts.duplicateSlug\",\n\t\t\t\t\t\t\"A form with this slug already exists\",\n\t\t\t\t\t))\n\t\t\t\t: (localization?.FORM_BUILDER_TOAST_ERROR ??\n\t\t\t\t\tt(\n\t\t\t\t\t\t\"formBuilder.toasts.error\",\n\t\t\t\t\t\t\"An error occurred. Please try again.\",\n\t\t\t\t\t)),\n\t\ttoCreateVars: (values) => values,\n\t\ttoUpdateVars: (values) => ({\n\t\t\tid: id ?? \"\",\n\t\t\tdata: {\n\t\t\t\tname: values.name,\n\t\t\t\tschema: values.schema,\n\t\t\t\tstatus: values.status,\n\t\t\t},\n\t\t}),\n\t\tredirect: (result, action) =>\n\t\t\taction === \"create\" && result\n\t\t\t\t? `${basePath}/forms/${result.id}/edit`\n\t\t\t\t: false,\n\t});\n\n\tconst handleSave = async () => {\n\t\tif (!name.trim()) {\n\t\t\tnotify.error(\n\t\t\t\tlocalization?.FORM_BUILDER_TOAST_NAME_REQUIRED ??\n\t\t\t\t\tt(\"formBuilder.toasts.nameRequired\", \"Name is required\"),\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\t\tif (!slug.trim()) {\n\t\t\tnotify.error(\n\t\t\t\tlocalization?.FORM_BUILDER_TOAST_SLUG_REQUIRED ??\n\t\t\t\t\tt(\"formBuilder.toasts.slugRequired\", \"Slug is required\"),\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\t\tif (!schema) {\n\t\t\tnotify.error(\n\t\t\t\tlocalization?.FORM_BUILDER_TOAST_SCHEMA_REQUIRED ??\n\t\t\t\t\tt(\n\t\t\t\t\t\t\"formBuilder.toasts.schemaRequired\",\n\t\t\t\t\t\t\"Please add at least one field to the form\",\n\t\t\t\t\t),\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\n\t\t// resourceForm.submit never throws: success notifies + redirects via\n\t\t// the config above; errors land on resourceForm.fieldErrors or notify.\n\t\tawait resourceForm.submit({\n\t\t\tname,\n\t\t\tslug,\n\t\t\tstatus,\n\t\t\tschema: JSON.stringify(schema),\n\t\t});\n\t};\n\n\tconst isSaving = resourceForm.isSubmitting;\n\tconst fieldError = (field: string): string | undefined => {\n\t\tconst error = resourceForm.fieldErrors[field];\n\t\tif (!error) return undefined;\n\t\treturn Array.isArray(error) ? error[0] : error;\n\t};\n\n\treturn (\n\t\t
\n\t\t\t{/* Header */}\n\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 setName(e.target.value)}\n\t\t\t\t\t\tplaceholder={\n\t\t\t\t\t\t\tlocalization?.FORM_BUILDER_EDITOR_NAME_PLACEHOLDER ??\n\t\t\t\t\t\t\tt(\"formBuilder.editor.namePlaceholder\", \"Enter form name\")\n\t\t\t\t\t\t}\n\t\t\t\t\t\tclassName=\"h-8 w-48\"\n\t\t\t\t\t/>\n\t\t\t\t\t{fieldError(\"name\") && (\n\t\t\t\t\t\t

{fieldError(\"name\")}

\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 {\n\t\t\t\t\t\t\tsetSlug(e.target.value);\n\t\t\t\t\t\t\tsetAutoSlug(false);\n\t\t\t\t\t\t}}\n\t\t\t\t\t\tplaceholder={\n\t\t\t\t\t\t\tlocalization?.FORM_BUILDER_EDITOR_SLUG_PLACEHOLDER ??\n\t\t\t\t\t\t\tt(\"formBuilder.editor.slugPlaceholder\", \"enter-form-slug\")\n\t\t\t\t\t\t}\n\t\t\t\t\t\tclassName=\"h-8 w-48 font-mono text-sm\"\n\t\t\t\t\t\tdisabled={!!id}\n\t\t\t\t\t/>\n\t\t\t\t\t{fieldError(\"slug\") && (\n\t\t\t\t\t\t

{fieldError(\"slug\")}

\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\t{localization?.FORM_BUILDER_LABEL_STATUS ??\n\t\t\t\t\t\t\tt(\"formBuilder.common.labelStatus\", \"Status\")}\n\t\t\t\t\t\n\t\t\t\t\t setStatus(v as typeof status)}\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\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization?.FORM_BUILDER_STATUS_ACTIVE ??\n\t\t\t\t\t\t\t\t\tt(\"formBuilder.common.statusActive\", \"Active\")}\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{localization?.FORM_BUILDER_STATUS_INACTIVE ??\n\t\t\t\t\t\t\t\t\tt(\"formBuilder.common.statusInactive\", \"Inactive\")}\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{localization?.FORM_BUILDER_STATUS_ARCHIVED ??\n\t\t\t\t\t\t\t\t\tt(\"formBuilder.common.statusArchived\", \"Archived\")}\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\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t
\n\n\t\t\t{/* Form Builder */}\n\t\t\t\n\t\t
\n\t);\n}\n", "target": "src/components/btst/form-builder/client/components/pages/form-builder-page.internal.tsx" }, { "path": "btst/form-builder/client/components/pages/form-builder-page.tsx", "type": "registry:page", - "content": "\"use client\";\n\nimport { lazy, Suspense } from \"react\";\nimport { FormBuilderSkeleton } from \"../loading/form-builder-skeleton\";\nimport { ErrorBoundary } from \"react-error-boundary\";\nimport { DefaultError } from \"../shared/default-error\";\n\nconst FormBuilderPage = lazy(() =>\n\timport(\"./form-builder-page.internal\").then((m) => ({\n\t\tdefault: m.FormBuilderPage,\n\t})),\n);\n\nexport interface FormBuilderPageProps {\n\tid?: string;\n}\n\nexport function FormBuilderPageComponent({ id }: FormBuilderPageProps) {\n\treturn (\n\t\t\n\t\t\t}>\n\t\t\t\t\n\t\t\t\n\t\t\n\t);\n}\n", + "content": "\"use client\";\n\nimport { lazy } from \"react\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { FormBuilderPluginOverrides } from \"../../overrides\";\nimport { ComposedRoute } from \"@btst/stack/client/components\";\nimport { DefaultError } from \"../shared/default-error\";\nimport { FormBuilderSkeleton } from \"../loading/form-builder-skeleton\";\nimport { NotFoundPage } from \"./404-page\";\n\nconst FormBuilderPage = lazy(() =>\n\timport(\"./form-builder-page.internal\").then((m) => ({\n\t\tdefault: m.FormBuilderPage,\n\t})),\n);\n\nexport interface FormBuilderPageProps {\n\tid?: string;\n}\n\nexport function FormBuilderPageComponent({ id }: FormBuilderPageProps) {\n\tconst { onRouteError } =\n\t\tusePluginOverrides(\"form-builder\");\n\n\tconst isNew = !id;\n\tconst path = isNew ? \"/forms/new\" : `/forms/${id}/edit`;\n\n\treturn (\n\t\t {\n\t\t\t\tif (onRouteError) {\n\t\t\t\t\tonRouteError(\"formBuilder\", error, {\n\t\t\t\t\t\tpath,\n\t\t\t\t\t\tparams: id ? { id } : {},\n\t\t\t\t\t\tisSSR: typeof window === \"undefined\",\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-builder-page.tsx" }, { "path": "btst/form-builder/client/components/pages/form-list-page.internal.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { usePluginOverrides, useBasePath } from \"@btst/stack/context\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n\tTable,\n\tTableBody,\n\tTableCell,\n\tTableHead,\n\tTableHeader,\n\tTableRow,\n} from \"@/components/ui/table\";\nimport {\n\tDropdownMenu,\n\tDropdownMenuContent,\n\tDropdownMenuItem,\n\tDropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport {\n\tAlertDialog,\n\tAlertDialogAction,\n\tAlertDialogCancel,\n\tAlertDialogContent,\n\tAlertDialogDescription,\n\tAlertDialogFooter,\n\tAlertDialogHeader,\n\tAlertDialogTitle,\n} from \"@/components/ui/alert-dialog\";\nimport { MoreHorizontal, Plus, Pencil, Trash2, FileText } from \"lucide-react\";\nimport { toast } from \"sonner\";\n\nimport {\n\tuseSuspenseForms,\n\tuseDeleteForm,\n} from \"@btst/stack/plugins/form-builder/client/hooks\";\nimport type { FormBuilderPluginOverrides } from \"../../overrides\";\nimport { FORM_BUILDER_LOCALIZATION } from \"../../localization\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\nimport { EmptyState } from \"../shared/empty-state\";\nimport { Pagination } from \"../shared/pagination\";\n\nexport function FormListPage() {\n\tconst { navigate, Link, localization } = usePluginOverrides<\n\t\tFormBuilderPluginOverrides,\n\t\tPartial\n\t>(\"form-builder\", {\n\t\tlocalization: FORM_BUILDER_LOCALIZATION,\n\t});\n\tconst basePath = useBasePath();\n\tconst { forms, total, hasMore, isLoadingMore, loadMore, refetch } =\n\t\tuseSuspenseForms();\n\tconst deleteMutation = useDeleteForm();\n\n\tconst [deleteId, setDeleteId] = useState(null);\n\n\tconst loc = localization || FORM_BUILDER_LOCALIZATION;\n\tconst LinkComponent = Link || \"a\";\n\n\tconst handleDelete = async () => {\n\t\tif (!deleteId) return;\n\n\t\ttry {\n\t\t\tawait deleteMutation.mutateAsync(deleteId);\n\t\t\ttoast.success(loc.FORM_BUILDER_TOAST_DELETE_SUCCESS);\n\t\t\tsetDeleteId(null);\n\t\t\tawait refetch();\n\t\t} catch (error) {\n\t\t\ttoast.error(loc.FORM_BUILDER_TOAST_ERROR);\n\t\t}\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\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{loc.FORM_BUILDER_LIST_TITLE}\n\t\t\t\t\t\t

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

\n\t\t\t\t\t\t\t{loc.FORM_BUILDER_LIST_SUBTITLE}\n\t\t\t\t\t\t

\n\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\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{loc.FORM_BUILDER_BUTTON_NEW_FORM}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t}\n\t\t\t\t\t/>\n\t\t\t\t) : (\n\t\t\t\t\t<>\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t{loc.FORM_BUILDER_LIST_COLUMN_NAME}\n\t\t\t\t\t\t\t\t\t\t{loc.FORM_BUILDER_LIST_COLUMN_SLUG}\n\t\t\t\t\t\t\t\t\t\t{loc.FORM_BUILDER_LIST_COLUMN_STATUS}\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t{loc.FORM_BUILDER_LIST_COLUMN_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{loc.FORM_BUILDER_LIST_COLUMN_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\tnavigate?.(`${basePath}/forms/${form.id}/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\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{loc.FORM_BUILDER_LIST_ACTION_EDIT}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tnavigate?.(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t`${basePath}/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>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{loc.FORM_BUILDER_LIST_ACTION_SUBMISSIONS}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t setDeleteId(form.id)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{loc.FORM_BUILDER_LIST_ACTION_DELETE}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t
\n\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t
\n\n\t\t\t{/* Delete confirmation dialog */}\n\t\t\t setDeleteId(null)}>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\tDelete Form\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{loc.FORM_BUILDER_EDITOR_DELETE_CONFIRM}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{loc.FORM_BUILDER_BUTTON_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? loc.FORM_BUILDER_STATUS_DELETING\n\t\t\t\t\t\t\t\t: loc.FORM_BUILDER_BUTTON_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\tCanAccess,\n\tuseNotify,\n\tusePluginOverrides,\n\tuseBasePath,\n\tuseTranslate,\n} from \"@btst/stack/context\";\nimport { useListState, type ListStateSchema } from \"@btst/stack/client\";\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\";\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 { navigate, Link, localization } =\n\t\tusePluginOverrides(\"form-builder\");\n\tconst basePath = useBasePath();\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?.(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t`${basePath}/forms/${form.id}/edit`,\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{localization?.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?.(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t`${basePath}/forms/${form.id}/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\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" }, { "path": "btst/form-builder/client/components/pages/form-list-page.tsx", "type": "registry:page", - "content": "\"use client\";\n\nimport { lazy, Suspense } from \"react\";\nimport { FormListSkeleton } from \"../loading/form-list-skeleton\";\nimport { ErrorBoundary } from \"react-error-boundary\";\nimport { DefaultError } from \"../shared/default-error\";\n\nconst FormListPage = lazy(() =>\n\timport(\"./form-list-page.internal\").then((m) => ({\n\t\tdefault: m.FormListPage,\n\t})),\n);\n\nexport function FormListPageComponent() {\n\treturn (\n\t\t\n\t\t\t}>\n\t\t\t\t\n\t\t\t\n\t\t\n\t);\n}\n", + "content": "\"use client\";\n\nimport { lazy } from \"react\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { FormBuilderPluginOverrides } from \"../../overrides\";\nimport { ComposedRoute } from \"@btst/stack/client/components\";\nimport { DefaultError } from \"../shared/default-error\";\nimport { FormListSkeleton } from \"../loading/form-list-skeleton\";\nimport { NotFoundPage } from \"./404-page\";\n\nconst FormListPage = lazy(() =>\n\timport(\"./form-list-page.internal\").then((m) => ({\n\t\tdefault: m.FormListPage,\n\t})),\n);\n\nexport function FormListPageComponent() {\n\tconst { onRouteError } =\n\t\tusePluginOverrides(\"form-builder\");\n\n\treturn (\n\t\t {\n\t\t\t\tif (onRouteError) {\n\t\t\t\t\tonRouteError(\"formList\", error, {\n\t\t\t\t\t\tpath: \"/forms\",\n\t\t\t\t\t\tisSSR: typeof window === \"undefined\",\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.tsx" }, { "path": "btst/form-builder/client/components/pages/submissions-page.internal.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { usePluginOverrides, useBasePath } from \"@btst/stack/context\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n\tTable,\n\tTableBody,\n\tTableCell,\n\tTableHead,\n\tTableHeader,\n\tTableRow,\n} from \"@/components/ui/table\";\nimport {\n\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\tDialogHeader,\n\tDialogTitle,\n} from \"@/components/ui/dialog\";\nimport { ArrowLeft, Trash2, Eye } from \"lucide-react\";\nimport { toast } from \"sonner\";\n\nimport {\n\tuseSuspenseFormById,\n\tuseSuspenseSubmissions,\n\tuseDeleteSubmission,\n} from \"@btst/stack/plugins/form-builder/client/hooks\";\nimport type { FormBuilderPluginOverrides } from \"../../overrides\";\nimport { FORM_BUILDER_LOCALIZATION } from \"../../localization\";\nimport type { SerializedFormSubmissionWithData } from \"../../../types\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\nimport { EmptyState } from \"../shared/empty-state\";\nimport { Pagination } from \"../shared/pagination\";\n\nexport interface SubmissionsPageProps {\n\tformId: string;\n}\n\nexport function SubmissionsPage({ formId }: SubmissionsPageProps) {\n\tconst { navigate, Link, localization } = usePluginOverrides<\n\t\tFormBuilderPluginOverrides,\n\t\tPartial\n\t>(\"form-builder\", {\n\t\tlocalization: FORM_BUILDER_LOCALIZATION,\n\t});\n\tconst basePath = useBasePath();\n\n\tconst { form } = useSuspenseFormById(formId);\n\tconst { submissions, total, hasMore, isLoadingMore, loadMore, refetch } =\n\t\tuseSuspenseSubmissions(formId);\n\tconst deleteMutation = useDeleteSubmission(formId);\n\n\tconst [deleteId, setDeleteId] = useState(null);\n\tconst [viewSubmission, setViewSubmission] =\n\t\tuseState(null);\n\n\tconst loc = localization || FORM_BUILDER_LOCALIZATION;\n\tconst LinkComponent = Link || \"a\";\n\n\tconst handleDelete = async () => {\n\t\tif (!deleteId) return;\n\n\t\ttry {\n\t\t\tawait deleteMutation.mutateAsync(deleteId);\n\t\t\ttoast.success(loc.FORM_BUILDER_TOAST_SUBMISSION_DELETED);\n\t\t\tsetDeleteId(null);\n\t\t\tawait refetch();\n\t\t} catch (error) {\n\t\t\ttoast.error(loc.FORM_BUILDER_TOAST_ERROR);\n\t\t}\n\t};\n\n\tconst formatSubmissionData = (data: Record) => {\n\t\tconst entries = Object.entries(data).slice(0, 3);\n\t\treturn entries\n\t\t\t.map(([key, value]) => {\n\t\t\t\tconst strValue =\n\t\t\t\t\ttypeof value === \"string\" ? value : JSON.stringify(value);\n\t\t\t\tconst truncated =\n\t\t\t\t\tstrValue.length > 30 ? `${strValue.slice(0, 30)}...` : strValue;\n\t\t\t\treturn `${key}: ${truncated}`;\n\t\t\t})\n\t\t\t.join(\", \");\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 || loc.FORM_BUILDER_SUBMISSIONS_TITLE}\n\t\t\t\t\t\t

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

\n\t\t\t\t\t\t\t{loc.FORM_BUILDER_SUBMISSIONS_SUBTITLE}\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{loc.FORM_BUILDER_SUBMISSIONS_COLUMN_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{loc.FORM_BUILDER_SUBMISSIONS_COLUMN_DATA}\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{loc.FORM_BUILDER_SUBMISSIONS_COLUMN_SUBMITTED_AT}\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{loc.FORM_BUILDER_SUBMISSIONS_COLUMN_IP_ADDRESS}\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{loc.FORM_BUILDER_SUBMISSIONS_COLUMN_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{formatSubmissionData(sub.parsedData ?? {})}\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{sub.ipAddress || \"-\"}\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 setViewSubmission(sub)}\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\tView\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 setDeleteId(sub.id)}\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\tDelete\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 setViewSubmission(null)}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\tSubmission Details\n\t\t\t\t\t\n\t\t\t\t\t{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\tID:\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\tSubmitted:\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\tIP Address:\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\tUser Agent:\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\tData:\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\tDelete Submission\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{loc.FORM_BUILDER_SUBMISSIONS_DELETE_CONFIRM}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{loc.FORM_BUILDER_BUTTON_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? loc.FORM_BUILDER_STATUS_DELETING\n\t\t\t\t\t\t\t\t: loc.FORM_BUILDER_BUTTON_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 { useState } from \"react\";\nimport {\n\tCanAccess,\n\tuseNotify,\n\tusePluginOverrides,\n\tuseBasePath,\n\tuseTranslate,\n} from \"@btst/stack/context\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n\tTable,\n\tTableBody,\n\tTableCell,\n\tTableHead,\n\tTableHeader,\n\tTableRow,\n} from \"@/components/ui/table\";\nimport {\n\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\tDialogHeader,\n\tDialogTitle,\n} from \"@/components/ui/dialog\";\nimport { ArrowLeft, Trash2, Eye } from \"lucide-react\";\n\nimport {\n\tuseSuspenseFormById,\n\tuseSuspenseSubmissions,\n\tuseDeleteSubmission,\n} from \"@btst/stack/plugins/form-builder/client/hooks\";\nimport type { FormBuilderPluginOverrides } from \"../../overrides\";\nimport type { SerializedFormSubmissionWithData } from \"../../../types\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\nimport { EmptyState } from \"../shared/empty-state\";\nimport { Pagination } from \"../shared/pagination\";\n\nexport interface SubmissionsPageProps {\n\tformId: string;\n}\n\nexport function SubmissionsPage({ formId }: SubmissionsPageProps) {\n\tconst t = useTranslate();\n\tconst notify = useNotify();\n\tconst { Link, localization } =\n\t\tusePluginOverrides(\"form-builder\");\n\tconst basePath = useBasePath();\n\n\tconst { form } = useSuspenseFormById(formId);\n\tconst { submissions, total, hasMore, isLoadingMore, loadMore } =\n\t\tuseSuspenseSubmissions(formId);\n\tconst deleteMutation = useDeleteSubmission(formId);\n\n\tconst [deleteId, setDeleteId] = useState(null);\n\tconst [viewSubmission, setViewSubmission] =\n\t\tuseState(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_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\tconst formatSubmissionData = (data: Record) => {\n\t\tconst entries = Object.entries(data).slice(0, 3);\n\t\treturn entries\n\t\t\t.map(([key, value]) => {\n\t\t\t\tconst strValue =\n\t\t\t\t\ttypeof value === \"string\" ? value : JSON.stringify(value);\n\t\t\t\tconst truncated =\n\t\t\t\t\tstrValue.length > 30 ? `${strValue.slice(0, 30)}...` : strValue;\n\t\t\t\treturn `${key}: ${truncated}`;\n\t\t\t})\n\t\t\t.join(\", \");\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_DATA ??\n\t\t\t\t\t\t\t\t\t\t\t\tt(\"formBuilder.submissions.columnData\", \"Data\")}\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_IP_ADDRESS ??\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.columnIpAddress\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\"IP Address\",\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{formatSubmissionData(sub.parsedData ?? {})}\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{sub.ipAddress || \"-\"}\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 setViewSubmission(sub)}\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\n\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\tt(\"formBuilder.submissions.actionView\", \"View\")}\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 setViewSubmission(null)}\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\n\t\t\t\t\t{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" }, { "path": "btst/form-builder/client/components/pages/submissions-page.tsx", "type": "registry:page", - "content": "\"use client\";\n\nimport { lazy, Suspense } from \"react\";\nimport { SubmissionsSkeleton } from \"../loading/submissions-skeleton\";\nimport { ErrorBoundary } from \"react-error-boundary\";\nimport { DefaultError } from \"../shared/default-error\";\n\nconst SubmissionsPage = lazy(() =>\n\timport(\"./submissions-page.internal\").then((m) => ({\n\t\tdefault: m.SubmissionsPage,\n\t})),\n);\n\nexport interface SubmissionsPageProps {\n\tformId: string;\n}\n\nexport function SubmissionsPageComponent({ formId }: SubmissionsPageProps) {\n\treturn (\n\t\t\n\t\t\t}>\n\t\t\t\t\n\t\t\t\n\t\t\n\t);\n}\n", + "content": "\"use client\";\n\nimport { lazy } from \"react\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { FormBuilderPluginOverrides } from \"../../overrides\";\nimport { ComposedRoute } from \"@btst/stack/client/components\";\nimport { DefaultError } from \"../shared/default-error\";\nimport { SubmissionsSkeleton } from \"../loading/submissions-skeleton\";\nimport { NotFoundPage } from \"./404-page\";\n\nconst SubmissionsPage = lazy(() =>\n\timport(\"./submissions-page.internal\").then((m) => ({\n\t\tdefault: m.SubmissionsPage,\n\t})),\n);\n\nexport interface SubmissionsPageProps {\n\tformId: string;\n}\n\nexport function SubmissionsPageComponent({ formId }: SubmissionsPageProps) {\n\tconst { onRouteError } =\n\t\tusePluginOverrides(\"form-builder\");\n\n\tconst path = `/forms/${formId}/submissions`;\n\n\treturn (\n\t\t {\n\t\t\t\tif (onRouteError) {\n\t\t\t\t\tonRouteError(\"submissions\", error, {\n\t\t\t\t\t\tpath,\n\t\t\t\t\t\tparams: { formId },\n\t\t\t\t\t\tisSSR: typeof window === \"undefined\",\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.tsx" }, { @@ -139,13 +139,13 @@ { "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};\n}\n\nexport function Pagination({\n\ttotal,\n\tshowing,\n\thasMore,\n\tisLoadingMore,\n\tonLoadMore,\n\tlabels = {},\n}: PaginationProps) {\n\tconst {\n\t\tshowing: showingLabel = \"Showing {count} of {total}\",\n\t\tnext = \"Load More\",\n\t} = labels;\n\n\tconst showingText = showingLabel\n\t\t.replace(\"{count}\", String(showing))\n\t\t.replace(\"{total}\", String(total));\n\n\treturn (\n\t\t
\n\t\t\t

{showingText}

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

{showingText}

\n\t\t\t{hasMore && (\n\t\t\t\t\n\t\t\t\t\t{isLoadingMore ? loading : next}\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t)}\n\t\t
\n\t);\n}\n", "target": "src/components/btst/form-builder/client/components/shared/pagination.tsx" }, { "path": "btst/form-builder/client/localization/form-builder-common.ts", "type": "registry:lib", - "content": "export const FORM_BUILDER_COMMON = {\n\t// Buttons\n\tFORM_BUILDER_BUTTON_SAVE: \"Save\",\n\tFORM_BUILDER_BUTTON_CANCEL: \"Cancel\",\n\tFORM_BUILDER_BUTTON_DELETE: \"Delete\",\n\tFORM_BUILDER_BUTTON_CREATE: \"Create\",\n\tFORM_BUILDER_BUTTON_BACK: \"Back\",\n\tFORM_BUILDER_BUTTON_NEW_FORM: \"New Form\",\n\tFORM_BUILDER_BUTTON_SUBMIT: \"Submit\",\n\n\t// Labels\n\tFORM_BUILDER_LABEL_NAME: \"Name\",\n\tFORM_BUILDER_LABEL_SLUG: \"Slug\",\n\tFORM_BUILDER_LABEL_SLUG_DESCRIPTION: \"URL-friendly identifier for this form\",\n\tFORM_BUILDER_LABEL_DESCRIPTION: \"Description\",\n\tFORM_BUILDER_LABEL_STATUS: \"Status\",\n\tFORM_BUILDER_LABEL_CREATED_AT: \"Created\",\n\tFORM_BUILDER_LABEL_UPDATED_AT: \"Last Updated\",\n\tFORM_BUILDER_LABEL_ACTIONS: \"Actions\",\n\n\t// Status\n\tFORM_BUILDER_STATUS_LOADING: \"Loading...\",\n\tFORM_BUILDER_STATUS_SAVING: \"Saving...\",\n\tFORM_BUILDER_STATUS_DELETING: \"Deleting...\",\n\tFORM_BUILDER_STATUS_ACTIVE: \"Active\",\n\tFORM_BUILDER_STATUS_INACTIVE: \"Inactive\",\n\tFORM_BUILDER_STATUS_ARCHIVED: \"Archived\",\n\n\t// Errors\n\tFORM_BUILDER_ERROR_GENERIC: \"Something went wrong\",\n\tFORM_BUILDER_ERROR_NOT_FOUND: \"Not found\",\n\tFORM_BUILDER_ERROR_VALIDATION: \"Please fix the errors above\",\n\n\t// Attribution\n\tFORM_BUILDER_ATTRIBUTION: \"Powered by BTST\",\n};\n", + "content": "export const FORM_BUILDER_COMMON = {\n\t// Buttons\n\tFORM_BUILDER_BUTTON_SAVE: \"Save\",\n\tFORM_BUILDER_BUTTON_CANCEL: \"Cancel\",\n\tFORM_BUILDER_BUTTON_DELETE: \"Delete\",\n\tFORM_BUILDER_BUTTON_CREATE: \"Create\",\n\tFORM_BUILDER_BUTTON_BACK: \"Back\",\n\tFORM_BUILDER_BUTTON_NEW_FORM: \"New Form\",\n\tFORM_BUILDER_BUTTON_SUBMIT: \"Submit\",\n\n\t// Labels\n\tFORM_BUILDER_LABEL_NAME: \"Name\",\n\tFORM_BUILDER_LABEL_SLUG: \"Slug\",\n\tFORM_BUILDER_LABEL_SLUG_DESCRIPTION: \"URL-friendly identifier for this form\",\n\tFORM_BUILDER_LABEL_DESCRIPTION: \"Description\",\n\tFORM_BUILDER_LABEL_STATUS: \"Status\",\n\tFORM_BUILDER_LABEL_CREATED_AT: \"Created\",\n\tFORM_BUILDER_LABEL_UPDATED_AT: \"Last Updated\",\n\tFORM_BUILDER_LABEL_ACTIONS: \"Actions\",\n\n\t// Status\n\tFORM_BUILDER_STATUS_LOADING: \"Loading...\",\n\tFORM_BUILDER_STATUS_SAVING: \"Saving...\",\n\tFORM_BUILDER_STATUS_DELETING: \"Deleting...\",\n\tFORM_BUILDER_STATUS_ACTIVE: \"Active\",\n\tFORM_BUILDER_STATUS_INACTIVE: \"Inactive\",\n\tFORM_BUILDER_STATUS_ARCHIVED: \"Archived\",\n\n\t// Errors\n\tFORM_BUILDER_ERROR_GENERIC: \"Something went wrong\",\n\tFORM_BUILDER_ERROR_NOT_FOUND: \"Not found\",\n\tFORM_BUILDER_ERROR_VALIDATION: \"Please fix the errors above\",\n\n\t// 404 page\n\tFORM_BUILDER_404_TITLE: \"Page not found\",\n\tFORM_BUILDER_404_DESCRIPTION:\n\t\t\"The page you're looking for doesn't exist or has been moved.\",\n\tFORM_BUILDER_404_BACK: \"Back to Forms\",\n\n\t// Attribution\n\tFORM_BUILDER_ATTRIBUTION: \"Powered by BTST\",\n};\n", "target": "src/components/btst/form-builder/client/localization/form-builder-common.ts" }, { @@ -157,25 +157,31 @@ { "path": "btst/form-builder/client/localization/form-builder-list.ts", "type": "registry:lib", - "content": "export const FORM_BUILDER_LIST = {\n\tFORM_BUILDER_LIST_TITLE: \"Forms\",\n\tFORM_BUILDER_LIST_SUBTITLE: \"Manage your forms\",\n\tFORM_BUILDER_LIST_EMPTY: \"No forms yet\",\n\tFORM_BUILDER_LIST_EMPTY_DESCRIPTION: \"Create your first form to get started.\",\n\tFORM_BUILDER_LIST_COLUMN_NAME: \"Name\",\n\tFORM_BUILDER_LIST_COLUMN_SLUG: \"Slug\",\n\tFORM_BUILDER_LIST_COLUMN_STATUS: \"Status\",\n\tFORM_BUILDER_LIST_COLUMN_CREATED: \"Created\",\n\tFORM_BUILDER_LIST_COLUMN_ACTIONS: \"Actions\",\n\tFORM_BUILDER_LIST_ACTION_EDIT: \"Edit\",\n\tFORM_BUILDER_LIST_ACTION_DELETE: \"Delete\",\n\tFORM_BUILDER_LIST_ACTION_SUBMISSIONS: \"Submissions\",\n\tFORM_BUILDER_LIST_PAGINATION_SHOWING: \"Showing {from}-{to} of {total}\",\n\tFORM_BUILDER_LIST_PAGINATION_PREVIOUS: \"Previous\",\n\tFORM_BUILDER_LIST_PAGINATION_NEXT: \"Next\",\n};\n", + "content": "export const FORM_BUILDER_LIST = {\n\tFORM_BUILDER_LIST_TITLE: \"Forms\",\n\tFORM_BUILDER_LIST_SUBTITLE: \"Manage your forms\",\n\tFORM_BUILDER_LIST_EMPTY: \"No forms yet\",\n\tFORM_BUILDER_LIST_EMPTY_DESCRIPTION: \"Create your first form to get started.\",\n\tFORM_BUILDER_LIST_COLUMN_NAME: \"Name\",\n\tFORM_BUILDER_LIST_COLUMN_SLUG: \"Slug\",\n\tFORM_BUILDER_LIST_COLUMN_STATUS: \"Status\",\n\tFORM_BUILDER_LIST_COLUMN_CREATED: \"Created\",\n\tFORM_BUILDER_LIST_COLUMN_ACTIONS: \"Actions\",\n\tFORM_BUILDER_LIST_ACTION_EDIT: \"Edit\",\n\tFORM_BUILDER_LIST_ACTION_DELETE: \"Delete\",\n\tFORM_BUILDER_LIST_ACTION_SUBMISSIONS: \"Submissions\",\n\tFORM_BUILDER_LIST_DELETE_TITLE: \"Delete Form\",\n\tFORM_BUILDER_LIST_SEARCH_PLACEHOLDER: \"Search forms...\",\n\tFORM_BUILDER_LIST_SEARCH_EMPTY: \"No forms match your search\",\n\tFORM_BUILDER_LIST_SEARCH_EMPTY_DESCRIPTION: \"Try a different search term.\",\n\tFORM_BUILDER_LIST_PAGINATION_SHOWING: \"Showing {count} of {total}\",\n\tFORM_BUILDER_LIST_PAGINATION_PREVIOUS: \"Previous\",\n\tFORM_BUILDER_LIST_PAGINATION_NEXT: \"Load More\",\n};\n", "target": "src/components/btst/form-builder/client/localization/form-builder-list.ts" }, + { + "path": "btst/form-builder/client/localization/form-builder-renderer.ts", + "type": "registry:lib", + "content": "export const FORM_BUILDER_RENDERER = {\n\tFORM_BUILDER_RENDERER_LOAD_FAILED: \"Failed to load form\",\n\tFORM_BUILDER_RENDERER_UNEXPECTED_ERROR: \"An unexpected error occurred\",\n\tFORM_BUILDER_RENDERER_SUBMITTED_TITLE: \"Form Submitted\",\n\tFORM_BUILDER_RENDERER_NOT_FOUND: \"Form not found\",\n\tFORM_BUILDER_RENDERER_INACTIVE:\n\t\t\"This form is not currently accepting submissions\",\n\tFORM_BUILDER_RENDERER_SCHEMA_ERROR: \"Failed to parse form schema\",\n\tFORM_BUILDER_RENDERER_THANK_YOU: \"Thank you for your submission!\",\n};\n", + "target": "src/components/btst/form-builder/client/localization/form-builder-renderer.ts" + }, { "path": "btst/form-builder/client/localization/form-builder-submissions.ts", "type": "registry:lib", - "content": "export const FORM_BUILDER_SUBMISSIONS = {\n\tFORM_BUILDER_SUBMISSIONS_TITLE: \"Submissions\",\n\tFORM_BUILDER_SUBMISSIONS_SUBTITLE: \"View form submissions\",\n\tFORM_BUILDER_SUBMISSIONS_EMPTY: \"No submissions yet\",\n\tFORM_BUILDER_SUBMISSIONS_EMPTY_DESCRIPTION:\n\t\t\"Submissions will appear here when users submit this form.\",\n\tFORM_BUILDER_SUBMISSIONS_COLUMN_ID: \"ID\",\n\tFORM_BUILDER_SUBMISSIONS_COLUMN_DATA: \"Data\",\n\tFORM_BUILDER_SUBMISSIONS_COLUMN_SUBMITTED_AT: \"Submitted\",\n\tFORM_BUILDER_SUBMISSIONS_COLUMN_IP_ADDRESS: \"IP Address\",\n\tFORM_BUILDER_SUBMISSIONS_COLUMN_ACTIONS: \"Actions\",\n\tFORM_BUILDER_SUBMISSIONS_ACTION_VIEW: \"View\",\n\tFORM_BUILDER_SUBMISSIONS_ACTION_DELETE: \"Delete\",\n\tFORM_BUILDER_SUBMISSIONS_DELETE_CONFIRM:\n\t\t\"Are you sure you want to delete this submission?\",\n\tFORM_BUILDER_SUBMISSIONS_BACK_TO_FORM: \"Back to Form\",\n};\n", + "content": "export const FORM_BUILDER_SUBMISSIONS = {\n\tFORM_BUILDER_SUBMISSIONS_TITLE: \"Submissions\",\n\tFORM_BUILDER_SUBMISSIONS_SUBTITLE: \"View form submissions\",\n\tFORM_BUILDER_SUBMISSIONS_EMPTY: \"No submissions yet\",\n\tFORM_BUILDER_SUBMISSIONS_EMPTY_DESCRIPTION:\n\t\t\"Submissions will appear here when users submit this form.\",\n\tFORM_BUILDER_SUBMISSIONS_COLUMN_ID: \"ID\",\n\tFORM_BUILDER_SUBMISSIONS_COLUMN_DATA: \"Data\",\n\tFORM_BUILDER_SUBMISSIONS_COLUMN_SUBMITTED_AT: \"Submitted\",\n\tFORM_BUILDER_SUBMISSIONS_COLUMN_IP_ADDRESS: \"IP Address\",\n\tFORM_BUILDER_SUBMISSIONS_COLUMN_ACTIONS: \"Actions\",\n\tFORM_BUILDER_SUBMISSIONS_ACTION_VIEW: \"View\",\n\tFORM_BUILDER_SUBMISSIONS_ACTION_DELETE: \"Delete\",\n\tFORM_BUILDER_SUBMISSIONS_DELETE_TITLE: \"Delete Submission\",\n\tFORM_BUILDER_SUBMISSIONS_DELETE_CONFIRM:\n\t\t\"Are you sure you want to delete this submission?\",\n\tFORM_BUILDER_SUBMISSIONS_BACK_TO_FORM: \"Back to Form\",\n\tFORM_BUILDER_SUBMISSIONS_DETAILS_TITLE: \"Submission Details\",\n\tFORM_BUILDER_SUBMISSIONS_FIELD_ID: \"ID:\",\n\tFORM_BUILDER_SUBMISSIONS_FIELD_SUBMITTED: \"Submitted:\",\n\tFORM_BUILDER_SUBMISSIONS_FIELD_IP: \"IP Address:\",\n\tFORM_BUILDER_SUBMISSIONS_FIELD_USER_AGENT: \"User Agent:\",\n\tFORM_BUILDER_SUBMISSIONS_FIELD_DATA: \"Data:\",\n};\n", "target": "src/components/btst/form-builder/client/localization/form-builder-submissions.ts" }, { "path": "btst/form-builder/client/localization/form-builder-toasts.ts", "type": "registry:lib", - "content": "export const FORM_BUILDER_TOASTS = {\n\tFORM_BUILDER_TOAST_CREATE_SUCCESS: \"Form created successfully\",\n\tFORM_BUILDER_TOAST_UPDATE_SUCCESS: \"Form updated successfully\",\n\tFORM_BUILDER_TOAST_DELETE_SUCCESS: \"Form deleted successfully\",\n\tFORM_BUILDER_TOAST_SUBMIT_SUCCESS: \"Form submitted successfully\",\n\tFORM_BUILDER_TOAST_ERROR: \"An error occurred. Please try again.\",\n\tFORM_BUILDER_TOAST_VALIDATION_ERROR: \"Please fix the validation errors\",\n\tFORM_BUILDER_TOAST_DUPLICATE_SLUG: \"A form with this slug already exists\",\n\tFORM_BUILDER_TOAST_SUBMISSION_DELETED: \"Submission deleted successfully\",\n};\n", + "content": "export const FORM_BUILDER_TOASTS = {\n\tFORM_BUILDER_TOAST_CREATE_SUCCESS: \"Form created successfully\",\n\tFORM_BUILDER_TOAST_UPDATE_SUCCESS: \"Form updated successfully\",\n\tFORM_BUILDER_TOAST_DELETE_SUCCESS: \"Form deleted successfully\",\n\tFORM_BUILDER_TOAST_SUBMIT_SUCCESS: \"Form submitted successfully\",\n\tFORM_BUILDER_TOAST_ERROR: \"An error occurred. Please try again.\",\n\tFORM_BUILDER_TOAST_VALIDATION_ERROR: \"Please fix the validation errors\",\n\tFORM_BUILDER_TOAST_DUPLICATE_SLUG: \"A form with this slug already exists\",\n\tFORM_BUILDER_TOAST_SUBMISSION_DELETED: \"Submission deleted successfully\",\n\tFORM_BUILDER_TOAST_NAME_REQUIRED: \"Name is required\",\n\tFORM_BUILDER_TOAST_SLUG_REQUIRED: \"Slug is required\",\n\tFORM_BUILDER_TOAST_SCHEMA_REQUIRED:\n\t\t\"Please add at least one field to the form\",\n};\n", "target": "src/components/btst/form-builder/client/localization/form-builder-toasts.ts" }, { "path": "btst/form-builder/client/localization/index.ts", "type": "registry:lib", - "content": "import { FORM_BUILDER_COMMON } from \"./form-builder-common\";\nimport { FORM_BUILDER_TOASTS } from \"./form-builder-toasts\";\nimport { FORM_BUILDER_LIST } from \"./form-builder-list\";\nimport { FORM_BUILDER_EDITOR } from \"./form-builder-editor\";\nimport { FORM_BUILDER_SUBMISSIONS } from \"./form-builder-submissions\";\n\nexport const FORM_BUILDER_LOCALIZATION = {\n\t...FORM_BUILDER_COMMON,\n\t...FORM_BUILDER_TOASTS,\n\t...FORM_BUILDER_LIST,\n\t...FORM_BUILDER_EDITOR,\n\t...FORM_BUILDER_SUBMISSIONS,\n};\n\nexport type FormBuilderLocalization = typeof FORM_BUILDER_LOCALIZATION;\n", + "content": "import { FORM_BUILDER_COMMON } from \"./form-builder-common\";\nimport { FORM_BUILDER_TOASTS } from \"./form-builder-toasts\";\nimport { FORM_BUILDER_LIST } from \"./form-builder-list\";\nimport { FORM_BUILDER_EDITOR } from \"./form-builder-editor\";\nimport { FORM_BUILDER_SUBMISSIONS } from \"./form-builder-submissions\";\nimport { FORM_BUILDER_RENDERER } from \"./form-builder-renderer\";\n\nexport const FORM_BUILDER_LOCALIZATION = {\n\t...FORM_BUILDER_COMMON,\n\t...FORM_BUILDER_TOASTS,\n\t...FORM_BUILDER_LIST,\n\t...FORM_BUILDER_EDITOR,\n\t...FORM_BUILDER_SUBMISSIONS,\n\t...FORM_BUILDER_RENDERER,\n};\n\nexport type FormBuilderLocalization = typeof FORM_BUILDER_LOCALIZATION;\n", "target": "src/components/btst/form-builder/client/localization/index.ts" }, { diff --git a/packages/stack/src/__tests__/form-builder-query-keys.test.ts b/packages/stack/src/__tests__/form-builder-query-keys.test.ts new file mode 100644 index 00000000..3a444186 --- /dev/null +++ b/packages/stack/src/__tests__/form-builder-query-keys.test.ts @@ -0,0 +1,72 @@ +/** + * SSG guard: the factory-generated Form Builder query keys must stay + * deep-equal to the `FORM_QUERY_KEYS` builders used by `prefetchForRoute` + * (DB path). Key drift breaks React Query cache hydration silently during + * `next build`. + */ +import { describe, expect, it, vi } from "vitest"; +import { FORM_QUERY_KEYS } from "../plugins/form-builder/api/query-key-defs"; +import { createFormBuilderQueryKeys } from "../plugins/form-builder/query-keys"; + +const client = vi.fn() as any; + +describe("form-builder query keys match SSG prefetch keys", () => { + const queries = createFormBuilderQueryKeys(client); + + it("forms list keys match for default params", () => { + expect([...queries.forms.list({}).queryKey]).toEqual([ + ...FORM_QUERY_KEYS.formsList(), + ]); + }); + + it("forms list keys match for custom limits, offsets and statuses", () => { + expect([ + ...queries.forms.list({ status: "active", limit: 5, offset: 10 }) + .queryKey, + ]).toEqual([ + ...FORM_QUERY_KEYS.formsList({ status: "active", limit: 5, offset: 10 }), + ]); + }); + + it("forms list keys match for search terms", () => { + expect([...queries.forms.list({ search: "contact" }).queryKey]).toEqual([ + ...FORM_QUERY_KEYS.formsList({ search: "contact" }), + ]); + }); + + it("normalizes a whitespace-only search the same way", () => { + expect([...queries.forms.list({ search: " " }).queryKey]).toEqual([ + ...FORM_QUERY_KEYS.formsList(), + ]); + }); + + it("form byId keys match", () => { + expect([...queries.forms.byId("abc").queryKey]).toEqual([ + ...FORM_QUERY_KEYS.formById("abc"), + ]); + }); + + it("submissions list keys match", () => { + expect([ + ...queries.formSubmissions.list({ formId: "f1", limit: 20, offset: 0 }) + .queryKey, + ]).toEqual([ + ...FORM_QUERY_KEYS.submissionsList({ + formId: "f1", + limit: 20, + offset: 0, + }), + ]); + }); + + it("exposes the same _def prefixes as the previous factory", () => { + expect([...queries.forms._def]).toEqual(["forms"]); + expect([...queries.forms.list._def]).toEqual(["forms", "list"]); + expect([...queries.forms.byId._def]).toEqual(["forms", "byId"]); + expect([...queries.formSubmissions._def]).toEqual(["formSubmissions"]); + expect([...queries.formSubmissions.list._def]).toEqual([ + "formSubmissions", + "list", + ]); + }); +}); diff --git a/packages/stack/src/plugins/form-builder/__tests__/client-sweep.test.tsx b/packages/stack/src/plugins/form-builder/__tests__/client-sweep.test.tsx new file mode 100644 index 00000000..ba645d0c --- /dev/null +++ b/packages/stack/src/plugins/form-builder/__tests__/client-sweep.test.tsx @@ -0,0 +1,568 @@ +// @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 +// form-builder components, which resolve `@btst/stack/*` via package +// self-reference. +import { + StackProvider, + type StackAuthProvider, + type StackI18nProvider, +} from "@btst/stack/context"; +import { FormListPage } from "../client/components/pages/form-list-page.internal"; +import { SubmissionsPage } from "../client/components/pages/submissions-page.internal"; +import { FormBuilderPage } from "../client/components/pages/form-builder-page.internal"; +import type { + SerializedForm, + SerializedFormSubmissionWithData, +} from "../types"; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +// jsdom lacks these APIs used by Radix +(globalThis as any).ResizeObserver ??= class { + observe() {} + unobserve() {} + disconnect() {} +}; +Element.prototype.scrollIntoView ??= () => {}; + +const hooks = vi.hoisted(() => ({ + useForms: vi.fn(), + useSuspenseForms: vi.fn(), + useDeleteForm: vi.fn(), + useSuspenseFormById: vi.fn(), + useSuspenseSubmissions: vi.fn(), + useDeleteSubmission: vi.fn(), + useFormBuilderForm: vi.fn(), +})); + +vi.mock("../client/hooks", () => hooks); + +// The form-builder canvas is drag-and-drop heavy and irrelevant to these +// tests — stub it out. +vi.mock("@workspace/ui/components/form-builder", () => ({ + FormBuilder: () =>
, +})); + +const form: SerializedForm = { + id: "f1", + name: "Contact Form", + slug: "contact-form", + description: null, + schema: JSON.stringify({ type: "object", properties: {} }), + successMessage: null, + redirectUrl: null, + status: "active", + createdBy: null, + createdAt: new Date("2024-01-01").toISOString(), + updatedAt: new Date("2024-01-01").toISOString(), +} as unknown as SerializedForm; + +const submission: SerializedFormSubmissionWithData = { + id: "sub-11111111", + formId: "f1", + data: JSON.stringify({ name: "Alice" }), + parsedData: { name: "Alice" }, + submittedAt: new Date("2024-01-02").toISOString(), + ipAddress: null, + userAgent: null, +} as unknown as SerializedFormSubmissionWithData; + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + hooks.useSuspenseForms.mockReturnValue({ + forms: [form], + total: 1, + loadMore: vi.fn(), + hasMore: false, + isLoadingMore: false, + refetch: vi.fn(), + }); + hooks.useForms.mockReturnValue({ + forms: [], + total: 0, + isLoading: false, + error: null, + loadMore: vi.fn(), + hasMore: false, + isLoadingMore: false, + refetch: vi.fn(), + }); + hooks.useDeleteForm.mockReturnValue({ + mutateAsync: vi.fn().mockResolvedValue({ success: true }), + isPending: false, + }); + hooks.useSuspenseFormById.mockReturnValue({ + form, + refetch: vi.fn(), + }); + hooks.useSuspenseSubmissions.mockReturnValue({ + submissions: [submission], + total: 1, + loadMore: vi.fn(), + hasMore: false, + isLoadingMore: false, + refetch: vi.fn(), + }); + hooks.useDeleteSubmission.mockReturnValue({ + mutateAsync: vi.fn().mockResolvedValue({ success: true }), + isPending: false, + }); + hooks.useFormBuilderForm.mockReturnValue({ + action: "create", + record: null, + isLoadingRecord: false, + recordError: null, + defaultValues: undefined, + submit: vi.fn().mockResolvedValue(form), + isSubmitting: false, + error: null, + fieldErrors: {}, + clearErrors: vi.fn(), + }); +}); + +afterEach(async () => { + await act(async () => { + root.unmount(); + }); + container.remove(); + document.body.innerHTML = ""; + vi.clearAllMocks(); + vi.restoreAllMocks(); +}); + +async function render(ui: React.ReactElement) { + await act(async () => { + root.render(ui); + }); +} + +function texts(): string { + return document.body.textContent ?? ""; +} + +function createMockRouter(initial = "") { + let params = new URLSearchParams(initial); + const setSearchParams = vi.fn( + (next: URLSearchParams, _opts?: { replace?: boolean }) => { + params = new URLSearchParams(next.toString()); + }, + ); + return { + navigate: vi.fn(), + getSearchParams: () => new URLSearchParams(params.toString()), + setSearchParams, + }; +} + +const formBuilderOverrides = { + navigate: vi.fn(), + apiBaseURL: "http://test.local", + apiBasePath: "/api/data", +}; + +function typeInto(input: HTMLInputElement, value: string) { + const setValue = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + "value", + )!.set!; + setValue.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); +} + +describe("FormListPage New Form button (CanAccess)", () => { + function renderListPage( + auth?: StackAuthProvider, + router = createMockRouter(), + ) { + return render( + + + , + ); + } + + it("shows the New Form button without an auth provider", async () => { + await renderListPage(); + + expect(texts()).toContain("New Form"); + expect(texts()).toContain("Contact Form"); + }); + + it("hides the New Form button when can() denies form-builder:form/create", async () => { + const can = vi.fn( + ({ resource, action }: { resource: string; action: string }) => + !(resource === "form-builder:form" && action === "create"), + ); + const auth: StackAuthProvider = { + getIdentity: () => ({ id: "user-1" }), + can, + }; + + await renderListPage(auth); + + expect(texts()).not.toContain("New Form"); + // The list itself still renders + expect(texts()).toContain("Contact Form"); + }); +}); + +describe("FormListPage search (useListState)", () => { + it("seeds the search from an initial ?q= URL param", async () => { + const router = createMockRouter("q=hello"); + + await render( + + + , + ); + + const input = container.querySelector( + '[data-testid="form-builder-list-search"]', + ) as HTMLInputElement; + expect(input.value).toBe("hello"); + expect(hooks.useForms).toHaveBeenLastCalledWith( + expect.objectContaining({ search: "hello", enabled: true }), + ); + // Nothing is written back for a read-only render + expect(router.setSearchParams).not.toHaveBeenCalled(); + }); + + it("writes typed queries to the URL with replace history after the debounce", async () => { + const router = createMockRouter(); + + await render( + + + , + ); + + const input = container.querySelector( + '[data-testid="form-builder-list-search"]', + ) as HTMLInputElement; + expect(input).toBeTruthy(); + await act(async () => { + typeInto(input, "survey"); + }); + + // Not written before the debounce elapses + expect(router.setSearchParams).not.toHaveBeenCalled(); + + // Wait out the debounce, then the microtask URL flush + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 400)); + }); + + expect(router.setSearchParams).toHaveBeenCalled(); + const [written, opts] = router.setSearchParams.mock.calls.at(-1)!; + expect(written.get("q")).toBe("survey"); + expect(opts).toEqual({ replace: true }); + expect(hooks.useForms).toHaveBeenLastCalledWith( + expect.objectContaining({ search: "survey", enabled: true }), + ); + }); + + it("re-seeds the input from external URL changes instead of clobbering them", async () => { + const router = createMockRouter(); + + await render( + + + , + ); + + const input = container.querySelector( + '[data-testid="form-builder-list-search"]', + ) as HTMLInputElement; + expect(input.value).toBe(""); + + // Simulate back/forward: `?q=ext` appears without this component + // writing it (popstate is how useListState observes such changes) + await act(async () => { + router.setSearchParams(new URLSearchParams("q=ext")); + window.dispatchEvent(new PopStateEvent("popstate")); + }); + router.setSearchParams.mockClear(); + + expect(input.value).toBe("ext"); + expect(hooks.useForms).toHaveBeenLastCalledWith( + expect.objectContaining({ search: "ext", enabled: true }), + ); + + // Wait out the debounce window: the stale (empty) input must not be + // written back over the externally-set query + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 400)); + }); + expect(router.setSearchParams).not.toHaveBeenCalled(); + }); +}); + +describe("SubmissionsPage row actions (CanAccess + useNotify)", () => { + function renderSubmissionsPage( + auth?: StackAuthProvider, + notify?: { + success: ReturnType; + error: ReturnType; + }, + ) { + return render( + + + , + ); + } + + it("shows view and delete buttons without an auth provider", async () => { + await renderSubmissionsPage(); + + const actionButtons = container.querySelectorAll("table tbody tr button"); + expect(actionButtons).toHaveLength(2); + }); + + it("hides the delete button when can() denies form-builder:submission/delete", async () => { + const can = vi.fn( + ({ resource, action }: { resource: string; action: string }) => + !(resource === "form-builder:submission" && action === "delete"), + ); + const auth: StackAuthProvider = { + getIdentity: () => ({ id: "user-1" }), + can, + }; + + await renderSubmissionsPage(auth); + + const actionButtons = container.querySelectorAll("table tbody tr button"); + expect(actionButtons).toHaveLength(1); + expect(can).toHaveBeenCalledWith( + expect.objectContaining({ + resource: "form-builder:submission", + action: "delete", + params: { formId: "f1", id: submission.id }, + }), + ); + }); + + it("notifies success through the notify provider after deleting", async () => { + const notify = { success: vi.fn(), error: vi.fn() }; + + await renderSubmissionsPage(undefined, notify); + + const actionButtons = container.querySelectorAll( + "table tbody tr button", + ); + const deleteButton = actionButtons[actionButtons.length - 1]!; + await act(async () => { + deleteButton.click(); + }); + + // Confirm in the AlertDialog (rendered in a portal on document.body). + // The row's sr-only label is also "Delete", so take the last match — + // the portal is appended after the page container. + const confirmButton = Array.from( + document.querySelectorAll("button"), + ) + .filter((button) => button.textContent === "Delete") + .at(-1); + expect(confirmButton).toBeTruthy(); + await act(async () => { + confirmButton!.click(); + }); + + expect( + hooks.useDeleteSubmission.mock.results[0]!.value.mutateAsync, + ).toHaveBeenCalledWith(submission.id); + expect(notify.success).toHaveBeenCalledWith( + "Submission deleted successfully", + ); + expect(notify.error).not.toHaveBeenCalled(); + }); +}); + +describe("FormBuilderPage editor (resource useForm)", () => { + function renderEditorPage(notify?: { + success: ReturnType; + error: ReturnType; + }) { + return render( + + + , + ); + } + + it("notifies a validation error when saving without a name", async () => { + const notify = { success: vi.fn(), error: vi.fn() }; + + await renderEditorPage(notify); + + const saveButton = Array.from( + container.querySelectorAll("button"), + ).find((button) => button.textContent?.includes("Create")); + expect(saveButton).toBeTruthy(); + await act(async () => { + saveButton!.click(); + }); + + expect(notify.error).toHaveBeenCalledWith("Name is required"); + const resourceForm = hooks.useFormBuilderForm.mock.results.at(-1)!.value; + expect(resourceForm.submit).not.toHaveBeenCalled(); + }); + + it("notifies when the schema has no fields yet", async () => { + const notify = { success: vi.fn(), error: vi.fn() }; + + await renderEditorPage(notify); + + const nameInput = container.querySelector( + "input#form-name", + ) as HTMLInputElement; + await act(async () => { + typeInto(nameInput, "My Form"); + }); + + const saveButton = Array.from( + container.querySelectorAll("button"), + ).find((button) => button.textContent?.includes("Create")); + await act(async () => { + saveButton!.click(); + }); + + expect(notify.error).toHaveBeenCalledWith( + "Please add at least one field to the form", + ); + }); + + it("renders server field errors inline under the inputs", async () => { + hooks.useFormBuilderForm.mockReturnValue({ + action: "create", + record: null, + isLoadingRecord: false, + recordError: null, + defaultValues: undefined, + submit: vi.fn(), + isSubmitting: false, + error: new Error("Validation failed"), + fieldErrors: { slug: "Slug is invalid" }, + clearErrors: vi.fn(), + }); + + await renderEditorPage(); + + expect(texts()).toContain("Slug is invalid"); + }); +}); + +describe("form-builder i18n precedence (useTranslate + overrides.localization)", () => { + beforeEach(() => { + hooks.useSuspenseForms.mockReturnValue({ + forms: [], + total: 0, + loadMore: vi.fn(), + hasMore: false, + isLoadingMore: false, + refetch: vi.fn(), + }); + }); + + it("renders the English default without providers", async () => { + await render( + + + , + ); + + expect(texts()).toContain("No forms yet"); + }); + + it("routes strings through the i18n provider when configured", async () => { + const i18n: StackI18nProvider = { + translate: (key, defaultValue) => + key === "formBuilder.list.empty" + ? "Noch keine Formulare" + : defaultValue, + }; + + await render( + + + , + ); + + expect(texts()).toContain("Noch keine Formulare"); + }); + + it("lets overrides.localization win over the i18n provider", async () => { + const translate = vi.fn( + (key: string, _defaultValue: string) => `translated:${key}`, + ); + + await render( + + + , + ); + + expect(texts()).toContain("Custom empty state"); + expect(texts()).not.toMatch( + /translated:formBuilder\.list\.empty(?!Description)/, + ); + }); +}); diff --git a/packages/stack/src/plugins/form-builder/__tests__/getters.test.ts b/packages/stack/src/plugins/form-builder/__tests__/getters.test.ts index ac4d92e4..d853ace3 100644 --- a/packages/stack/src/plugins/form-builder/__tests__/getters.test.ts +++ b/packages/stack/src/plugins/form-builder/__tests__/getters.test.ts @@ -19,11 +19,12 @@ async function createForm( adapter: Adapter, slug: string, status = "active", + name?: string, ): Promise { return adapter.create({ model: "form", data: { - name: `Form ${slug}`, + name: name ?? `Form ${slug}`, slug, schema: SIMPLE_SCHEMA, status, @@ -82,6 +83,52 @@ describe("form-builder getters", () => { const page2 = await getAllForms(adapter, { limit: 2, offset: 2 }); expect(page2.items).toHaveLength(2); }); + + describe("search", () => { + it("matches forms by name and slug, case-insensitively", async () => { + await createForm(adapter, "contact-us", "active", "Contact Form"); + await createForm(adapter, "newsletter", "active", "Newsletter Signup"); + + const byName = await getAllForms(adapter, { search: "CONTACT" }); + expect(byName.items.map((f) => f.slug)).toEqual(["contact-us"]); + expect(byName.total).toBe(1); + + const bySlug = await getAllForms(adapter, { search: "newslet" }); + expect(bySlug.items.map((f) => f.slug)).toEqual(["newsletter"]); + }); + + it("returns the filtered total and paginates search results", async () => { + for (let i = 1; i <= 5; i++) { + await createForm(adapter, `survey-${i}`, "active", `Survey ${i}`); + } + await createForm(adapter, "unrelated", "active", "Other Form"); + + const page1 = await getAllForms(adapter, { + search: "survey", + limit: 2, + offset: 0, + }); + expect(page1.items).toHaveLength(2); + expect(page1.total).toBe(5); + + const page3 = await getAllForms(adapter, { + search: "survey", + limit: 2, + offset: 4, + }); + expect(page3.items).toHaveLength(1); + expect(page3.total).toBe(5); + }); + + it("ignores a whitespace-only search", async () => { + await createForm(adapter, "contact"); + await createForm(adapter, "feedback"); + + const result = await getAllForms(adapter, { search: " " }); + expect(result.items).toHaveLength(2); + expect(result.total).toBe(2); + }); + }); }); describe("getFormBySlug", () => { diff --git a/packages/stack/src/plugins/form-builder/api/getters.ts b/packages/stack/src/plugins/form-builder/api/getters.ts index 4ff3ccbc..e479f419 100644 --- a/packages/stack/src/plugins/form-builder/api/getters.ts +++ b/packages/stack/src/plugins/form-builder/api/getters.ts @@ -1,4 +1,5 @@ import type { DBAdapter as Adapter } from "@btst/db"; +import { DEFAULT_MAX_PAGE_SIZE } from "../schemas"; import type { Form, FormSubmission, @@ -60,8 +61,17 @@ export function serializeFormSubmissionWithData( }; } +/** Case-insensitive match of a search term against a form's name and slug. */ +function formMatchesSearch(form: SerializedForm, searchLower: string): boolean { + return ( + form.name.toLowerCase().includes(searchLower) || + form.slug.toLowerCase().includes(searchLower) + ); +} + /** - * Retrieve all forms with optional status filter and pagination. + * Retrieve all forms with optional status filter, pagination, and free-text + * search. * Pure DB function — no hooks, no HTTP context. Safe for SSG and server-side use. * * @remarks **Security:** Authorization hooks (e.g. `onBeforeListForms`) are NOT @@ -69,11 +79,17 @@ export function serializeFormSubmissionWithData( * invoking this function. * * @param adapter - The database adapter - * @param params - Optional filter/pagination parameters + * @param params - Optional filter/pagination parameters. `search` matches + * case-insensitively against form names and slugs. */ export async function getAllForms( adapter: Adapter, - params?: { status?: string; limit?: number; offset?: number }, + params?: { + status?: string; + limit?: number; + offset?: number; + search?: string; + }, ): Promise<{ items: SerializedForm[]; total: number; @@ -94,23 +110,54 @@ export async function getAllForms( }); } + // Free-text search stays in-memory (the adapter contract only exposes + // equality filters here); when searching, pagination happens after the + // in-memory pass so `total` reflects the filtered set. The DB scan is + // capped at DEFAULT_MAX_PAGE_SIZE to bound memory use; forms beyond the + // cap are not searched. + const search = params?.search?.trim(); + const needsInMemoryFilter = !!search; + // TODO: remove cast once @btst/db types expose adapter.count() - const total: number = await adapter.count({ - model: "form", - where: whereConditions.length > 0 ? whereConditions : undefined, - }); + const dbTotal: number | undefined = !needsInMemoryFilter + ? await adapter.count({ + model: "form", + where: whereConditions.length > 0 ? whereConditions : undefined, + }) + : undefined; const forms = await adapter.findMany
({ model: "form", where: whereConditions.length > 0 ? whereConditions : undefined, - limit: params?.limit, - offset: params?.offset, + limit: !needsInMemoryFilter ? params?.limit : DEFAULT_MAX_PAGE_SIZE, + offset: !needsInMemoryFilter ? params?.offset : undefined, sortBy: { field: "createdAt", direction: "desc" }, }); + let result = forms.map(serializeForm); + + if (needsInMemoryFilter) { + const searchLower = search.toLowerCase(); + result = result.filter((form) => formMatchesSearch(form, searchLower)); + + const total = result.length; + const offset = params?.offset ?? 0; + const limit = params?.limit; + result = result.slice( + offset, + limit !== undefined ? offset + limit : undefined, + ); + return { + items: result, + total, + limit: params?.limit, + offset: params?.offset, + }; + } + return { - items: forms.map(serializeForm), - total, + items: result, + total: dbTotal ?? result.length, limit: params?.limit, offset: params?.offset, }; diff --git a/packages/stack/src/plugins/form-builder/api/plugin.ts b/packages/stack/src/plugins/form-builder/api/plugin.ts index 91c03e73..13e37a16 100644 --- a/packages/stack/src/plugins/form-builder/api/plugin.ts +++ b/packages/stack/src/plugins/form-builder/api/plugin.ts @@ -174,7 +174,7 @@ export const formBuilderBackendPlugin = ( query: listFormsQuerySchema, }, async (ctx) => { - const { status, limit, offset } = ctx.query; + const { status, limit, offset, search } = ctx.query; const context = createContext(ctx.headers); if (config.hooks?.onBeforeListForms) { @@ -185,7 +185,7 @@ export const formBuilderBackendPlugin = ( ); } - return getAllForms(adapter, { status, limit, offset }); + return getAllForms(adapter, { status, limit, offset, search }); }, ); diff --git a/packages/stack/src/plugins/form-builder/api/query-key-defs.ts b/packages/stack/src/plugins/form-builder/api/query-key-defs.ts index 0e6feed7..4c752e96 100644 --- a/packages/stack/src/plugins/form-builder/api/query-key-defs.ts +++ b/packages/stack/src/plugins/form-builder/api/query-key-defs.ts @@ -8,6 +8,7 @@ export interface FormsListDiscriminator { status?: "active" | "inactive" | "archived"; limit: number; offset: number; + search: string | undefined; } export interface SubmissionsListDiscriminator { @@ -18,17 +19,24 @@ export interface SubmissionsListDiscriminator { /** * Builds the discriminator object for the forms list query key. - * Mirrors the params object used in createFormsQueries.list. + * Mirrors the params object used in the forms.list resource declaration + * so both paths stay in sync. An empty/whitespace search term is normalized + * to `undefined` so it hashes identically to "no search". */ export function formsListDiscriminator(params?: { status?: "active" | "inactive" | "archived"; limit?: number; offset?: number; + search?: string; }): FormsListDiscriminator { return { status: params?.status, limit: params?.limit ?? 20, offset: params?.offset ?? 0, + search: + params?.search !== undefined && params.search.trim() === "" + ? undefined + : params?.search, }; } @@ -58,6 +66,7 @@ export const FORM_QUERY_KEYS = { status?: "active" | "inactive" | "archived"; limit?: number; offset?: number; + search?: string; }) => ["forms", "list", "list", formsListDiscriminator(params)] as const, /** 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 1999ef92..04681aea 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 @@ -1,7 +1,7 @@ "use client"; import { useState, useMemo, type ComponentType } from "react"; -import { usePluginOverrides } from "@btst/stack/context"; +import { usePluginOverrides, useTranslate } from "@btst/stack/context"; import { SteppedAutoForm } from "@workspace/ui/components/auto-form/stepped-auto-form"; import { buildFieldConfigFromJsonSchema } from "@workspace/ui/components/auto-form/helpers"; import { formSchemaToZod } from "@workspace/ui/lib/schema-converter"; @@ -11,7 +11,6 @@ import type { AutoFormInputComponentProps } from "@workspace/ui/components/auto- import { useFormBySlug, useSubmitForm } from "../../hooks/form-builder-hooks"; import type { FormBuilderPluginOverrides } from "../../overrides"; -import { FORM_BUILDER_LOCALIZATION } from "../../localization"; import type { SerializedFormSubmission } from "../../../types"; export interface FormRendererProps { @@ -51,29 +50,44 @@ function DefaultLoadingComponent() { } function DefaultErrorComponent({ error }: { error: Error }) { + const t = useTranslate(); + const { localization } = + usePluginOverrides("form-builder"); + return (

- Failed to load form + {localization?.FORM_BUILDER_RENDERER_LOAD_FAILED ?? + t("formBuilder.renderer.loadFailed", "Failed to load form")}

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

); } function DefaultSuccessComponent({ message }: { message: React.ReactNode }) { + const t = useTranslate(); + const { localization } = + usePluginOverrides("form-builder"); + return (

- Form Submitted + {localization?.FORM_BUILDER_RENDERER_SUBMITTED_TITLE ?? + t("formBuilder.renderer.submittedTitle", "Form Submitted")}

{message}

@@ -109,15 +123,9 @@ export function FormRenderer({ ErrorComponent = DefaultErrorComponent, className, }: FormRendererProps) { + const t = useTranslate(); const { fieldComponents: overrideFieldComponents, localization } = - usePluginOverrides< - FormBuilderPluginOverrides, - Partial - >("form-builder", { - localization: FORM_BUILDER_LOCALIZATION, - }); - - const loc = localization || FORM_BUILDER_LOCALIZATION; + usePluginOverrides("form-builder"); const { form, isLoading, error } = useFormBySlug(slug); const submitMutation = useSubmitForm(slug); @@ -164,7 +172,8 @@ export function FormRenderer({ const message = propSuccessMessage || result.form.successMessage || - "Thank you for your submission!"; + (localization?.FORM_BUILDER_RENDERER_THANK_YOU ?? + t("formBuilder.renderer.thankYou", "Thank you for your submission!")); setFinalSuccessMessage(message as string); setSubmitted(true); @@ -203,7 +212,14 @@ export function FormRenderer({ if (!form) { return (
- +
); } @@ -213,7 +229,15 @@ export function FormRenderer({ return (
); @@ -223,7 +247,17 @@ export function FormRenderer({ if (!zodSchema) { return (
- +
); } @@ -246,7 +280,11 @@ export function FormRenderer({ fieldConfig={fieldConfig} onSubmit={(values) => handleSubmit(values as Record)} isSubmitting={submitMutation.isPending} - submitButtonText={submitButtonText || loc.FORM_BUILDER_BUTTON_SUBMIT} + submitButtonText={ + submitButtonText || + (localization?.FORM_BUILDER_BUTTON_SUBMIT ?? + t("formBuilder.common.buttonSubmit", "Submit")) + } />
); diff --git a/packages/stack/src/plugins/form-builder/client/components/pages/404-page.tsx b/packages/stack/src/plugins/form-builder/client/components/pages/404-page.tsx index 496d14ac..53c801d2 100644 --- a/packages/stack/src/plugins/form-builder/client/components/pages/404-page.tsx +++ b/packages/stack/src/plugins/form-builder/client/components/pages/404-page.tsx @@ -1,11 +1,16 @@ "use client"; import { Button } from "@workspace/ui/components/button"; -import { usePluginOverrides, useBasePath } from "@btst/stack/context"; +import { + usePluginOverrides, + useBasePath, + useTranslate, +} from "@btst/stack/context"; import type { FormBuilderPluginOverrides } from "../../overrides"; export function NotFoundPage() { - const { navigate, Link } = + const t = useTranslate(); + const { Link, localization } = usePluginOverrides("form-builder"); const basePath = useBasePath(); @@ -15,13 +20,21 @@ export function NotFoundPage() {

404

- Page not found + {localization?.FORM_BUILDER_404_TITLE ?? + t("formBuilder.common.404Title", "Page not found")}

- The page you're looking for doesn't exist or has been moved. + {localization?.FORM_BUILDER_404_DESCRIPTION ?? + t( + "formBuilder.common.404Description", + "The page you're looking for doesn't exist or has been moved.", + )}

); diff --git a/packages/stack/src/plugins/form-builder/client/components/pages/form-builder-page.internal.tsx b/packages/stack/src/plugins/form-builder/client/components/pages/form-builder-page.internal.tsx index 00cc5123..b6a30ea0 100644 --- a/packages/stack/src/plugins/form-builder/client/components/pages/form-builder-page.internal.tsx +++ b/packages/stack/src/plugins/form-builder/client/components/pages/form-builder-page.internal.tsx @@ -1,7 +1,12 @@ "use client"; import { useState, useEffect, useCallback } from "react"; -import { usePluginOverrides, useBasePath } from "@btst/stack/context"; +import { + useNotify, + usePluginOverrides, + useBasePath, + useTranslate, +} from "@btst/stack/context"; import { Button } from "@workspace/ui/components/button"; import { Input } from "@workspace/ui/components/input"; import { Label } from "@workspace/ui/components/label"; @@ -13,17 +18,11 @@ import { SelectValue, } from "@workspace/ui/components/select"; import { ArrowLeft, Save } from "lucide-react"; -import { toast } from "sonner"; import { FormBuilder } from "@workspace/ui/components/form-builder"; import type { JSONSchema } from "@workspace/ui/components/form-builder/types"; -import { - useSuspenseFormById, - useCreateForm, - useUpdateForm, -} from "../../hooks/form-builder-hooks"; +import { useSuspenseFormById, useFormBuilderForm } from "../../hooks"; import type { FormBuilderPluginOverrides } from "../../overrides"; -import { FORM_BUILDER_LOCALIZATION } from "../../localization"; import { slugify } from "../../../utils"; import type { SerializedForm } from "../../../types"; @@ -65,22 +64,23 @@ interface FormBuilderPageContentProps { existingForm?: SerializedForm | null; } +interface FormBuilderFormValues { + name: string; + slug: string; + status: "active" | "inactive" | "archived"; + schema: string; +} + function FormBuilderPageContent({ id, existingForm, }: FormBuilderPageContentProps) { - const { navigate, Link, localization } = usePluginOverrides< - FormBuilderPluginOverrides, - Partial - >("form-builder", { - localization: FORM_BUILDER_LOCALIZATION, - }); + const t = useTranslate(); + const notify = useNotify(); + const { Link, localization } = + usePluginOverrides("form-builder"); const basePath = useBasePath(); - const createMutation = useCreateForm(); - const updateMutation = useUpdateForm(); - - const loc = localization || FORM_BUILDER_LOCALIZATION; const LinkComponent = Link || "a"; // Form state @@ -113,54 +113,87 @@ function FormBuilderPageContent({ setSchema(newSchema); }, []); + // Core resource form: submits the right mutation, awaits invalidation, + // notifies success/error via useNotify(), redirects after create, and + // exposes server validation issues as fieldErrors for inline display. + const resourceForm = useFormBuilderForm({ + action: id ? "edit" : "create", + record: id ? (existingForm ?? null) : null, + successMessage: (_result, action) => + action === "create" + ? (localization?.FORM_BUILDER_TOAST_CREATE_SUCCESS ?? + t("formBuilder.toasts.createSuccess", "Form created successfully")) + : (localization?.FORM_BUILDER_TOAST_UPDATE_SUCCESS ?? + t("formBuilder.toasts.updateSuccess", "Form updated successfully")), + errorMessage: (error) => + error.statusCode === 409 + ? (localization?.FORM_BUILDER_TOAST_DUPLICATE_SLUG ?? + t( + "formBuilder.toasts.duplicateSlug", + "A form with this slug already exists", + )) + : (localization?.FORM_BUILDER_TOAST_ERROR ?? + t( + "formBuilder.toasts.error", + "An error occurred. Please try again.", + )), + toCreateVars: (values) => values, + toUpdateVars: (values) => ({ + id: id ?? "", + data: { + name: values.name, + schema: values.schema, + status: values.status, + }, + }), + redirect: (result, action) => + action === "create" && result + ? `${basePath}/forms/${result.id}/edit` + : false, + }); + const handleSave = async () => { if (!name.trim()) { - toast.error("Name is required"); + notify.error( + localization?.FORM_BUILDER_TOAST_NAME_REQUIRED ?? + t("formBuilder.toasts.nameRequired", "Name is required"), + ); return; } if (!slug.trim()) { - toast.error("Slug is required"); + notify.error( + localization?.FORM_BUILDER_TOAST_SLUG_REQUIRED ?? + t("formBuilder.toasts.slugRequired", "Slug is required"), + ); return; } if (!schema) { - toast.error("Please add at least one field to the form"); + notify.error( + localization?.FORM_BUILDER_TOAST_SCHEMA_REQUIRED ?? + t( + "formBuilder.toasts.schemaRequired", + "Please add at least one field to the form", + ), + ); return; } - try { - const schemaStr = JSON.stringify(schema); - - if (id) { - await updateMutation.mutateAsync({ - id, - data: { - name, - schema: schemaStr, - status, - }, - }); - toast.success(loc.FORM_BUILDER_TOAST_UPDATE_SUCCESS); - } else { - const newForm = await createMutation.mutateAsync({ - name, - slug, - schema: schemaStr, - status, - }); - toast.success(loc.FORM_BUILDER_TOAST_CREATE_SUCCESS); - navigate?.(`${basePath}/forms/${newForm.id}/edit`); - } - } catch (error) { - const message = error instanceof Error ? error.message : "Unknown error"; - if (message.includes("slug already exists")) { - toast.error(loc.FORM_BUILDER_TOAST_DUPLICATE_SLUG); - } else { - toast.error(loc.FORM_BUILDER_TOAST_ERROR); - } - } + // resourceForm.submit never throws: success notifies + redirects via + // the config above; errors land on resourceForm.fieldErrors or notify. + await resourceForm.submit({ + name, + slug, + status, + schema: JSON.stringify(schema), + }); }; - const isSaving = createMutation.isPending || updateMutation.isPending; + const isSaving = resourceForm.isSubmitting; + const fieldError = (field: string): string | undefined => { + const error = resourceForm.fieldErrors[field]; + if (!error) return undefined; + return Array.isArray(error) ? error[0] : error; + }; return (
@@ -174,20 +207,28 @@ function FormBuilderPageContent({
setName(e.target.value)} - placeholder={loc.FORM_BUILDER_EDITOR_NAME_PLACEHOLDER} + placeholder={ + localization?.FORM_BUILDER_EDITOR_NAME_PLACEHOLDER ?? + t("formBuilder.editor.namePlaceholder", "Enter form name") + } className="h-8 w-48" /> + {fieldError("name") && ( +

{fieldError("name")}

+ )}
+ {fieldError("slug") && ( +

{fieldError("slug")}

+ )}
@@ -207,7 +254,8 @@ function FormBuilderPageContent({ htmlFor="form-status" className="text-xs text-muted-foreground" > - {loc.FORM_BUILDER_LABEL_STATUS} + {localization?.FORM_BUILDER_LABEL_STATUS ?? + t("formBuilder.common.labelStatus", "Status")} @@ -234,10 +285,13 @@ function FormBuilderPageContent({
diff --git a/packages/stack/src/plugins/form-builder/client/components/pages/form-builder-page.tsx b/packages/stack/src/plugins/form-builder/client/components/pages/form-builder-page.tsx index df33d3aa..1661cdad 100644 --- a/packages/stack/src/plugins/form-builder/client/components/pages/form-builder-page.tsx +++ b/packages/stack/src/plugins/form-builder/client/components/pages/form-builder-page.tsx @@ -1,9 +1,12 @@ "use client"; -import { lazy, Suspense } from "react"; -import { FormBuilderSkeleton } from "../loading/form-builder-skeleton"; -import { ErrorBoundary } from "react-error-boundary"; +import { lazy } from "react"; +import { usePluginOverrides } from "@btst/stack/context"; +import type { FormBuilderPluginOverrides } from "../../overrides"; +import { ComposedRoute } from "@btst/stack/client/components"; import { DefaultError } from "../shared/default-error"; +import { FormBuilderSkeleton } from "../loading/form-builder-skeleton"; +import { NotFoundPage } from "./404-page"; const FormBuilderPage = lazy(() => import("./form-builder-page.internal").then((m) => ({ @@ -16,11 +19,34 @@ export interface FormBuilderPageProps { } export function FormBuilderPageComponent({ id }: FormBuilderPageProps) { + const { onRouteError } = + usePluginOverrides("form-builder"); + + const isNew = !id; + const path = isNew ? "/forms/new" : `/forms/${id}/edit`; + return ( - - }> - - - + { + if (onRouteError) { + onRouteError("formBuilder", error, { + path, + params: id ? { id } : {}, + isSSR: typeof window === "undefined", + }); + } + }} + /> ); } diff --git a/packages/stack/src/plugins/form-builder/client/components/pages/form-list-page.internal.tsx b/packages/stack/src/plugins/form-builder/client/components/pages/form-list-page.internal.tsx index a60c2dfb..7b4b141e 100644 --- a/packages/stack/src/plugins/form-builder/client/components/pages/form-list-page.internal.tsx +++ b/packages/stack/src/plugins/form-builder/client/components/pages/form-list-page.internal.tsx @@ -1,8 +1,16 @@ "use client"; -import { useState } from "react"; -import { usePluginOverrides, useBasePath } from "@btst/stack/context"; +import { useEffect, useRef, useState } from "react"; +import { + CanAccess, + useNotify, + usePluginOverrides, + useBasePath, + useTranslate, +} from "@btst/stack/context"; +import { useListState, type ListStateSchema } from "@btst/stack/client"; import { Button } from "@workspace/ui/components/button"; +import { Input } from "@workspace/ui/components/input"; import { Table, TableBody, @@ -27,34 +35,82 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@workspace/ui/components/alert-dialog"; -import { MoreHorizontal, Plus, Pencil, Trash2, FileText } from "lucide-react"; -import { toast } from "sonner"; - import { - useSuspenseForms, - useDeleteForm, -} from "../../hooks/form-builder-hooks"; + MoreHorizontal, + Plus, + Pencil, + Trash2, + FileText, + Loader2, + Search, +} from "lucide-react"; + +import { useForms, useSuspenseForms, useDeleteForm } from "../../hooks"; import type { FormBuilderPluginOverrides } from "../../overrides"; -import { FORM_BUILDER_LOCALIZATION } from "../../localization"; import { PageWrapper } from "../shared/page-wrapper"; import { EmptyState } from "../shared/empty-state"; import { Pagination } from "../shared/pagination"; +// URL-synced search state: `?q=...` while typing (history: replace), clean +// URL when the query is empty (the default is omitted from the URL). +const LIST_STATE_SCHEMA = { + q: { type: "string", default: "", history: "replace" }, +} as const satisfies ListStateSchema; + +const SEARCH_DEBOUNCE_MS = 300; + export function FormListPage() { - const { navigate, Link, localization } = usePluginOverrides< - FormBuilderPluginOverrides, - Partial - >("form-builder", { - localization: FORM_BUILDER_LOCALIZATION, - }); + const t = useTranslate(); + const notify = useNotify(); + const { navigate, Link, localization } = + usePluginOverrides("form-builder"); const basePath = useBasePath(); - const { forms, total, hasMore, isLoadingMore, loadMore, refetch } = - useSuspenseForms(); + + const [{ q: search }, setListState] = useListState( + "form-builder-forms", + LIST_STATE_SCHEMA, + ); + + // Local input state debounced into the URL-synced query, so the list + // query (and URL) only update after the user pauses typing. + const [searchInput, setSearchInput] = useState(search); + + // External `q` changes (hydration after SSR-empty search params, + // back/forward navigation) re-seed the input instead of being clobbered + // by the debounced write below, which only reflects user edits. + const lastSyncedSearch = useRef(search); + useEffect(() => { + if (search !== lastSyncedSearch.current) { + lastSyncedSearch.current = search; + setSearchInput(search); + } + }, [search]); + + useEffect(() => { + if (searchInput === search) return; + const timeout = setTimeout(() => { + lastSyncedSearch.current = searchInput; + setListState({ q: searchInput }); + }, SEARCH_DEBOUNCE_MS); + return () => clearTimeout(timeout); + }, [searchInput, search, setListState]); + + const hasSearch = search.trim().length > 0; + + // The default (unsearched) list stays on the suspense hook so SSR/SSG + // hydration works; the searched list uses the non-suspense hook so + // typing shows an inline loading state instead of suspending the page. + const defaultList = useSuspenseForms(); + const searchedList = useForms({ search, enabled: hasSearch }); + + const activeList = hasSearch ? searchedList : defaultList; + const { forms, total, hasMore, isLoadingMore, loadMore } = activeList; + const isSearchLoading = hasSearch && searchedList.isLoading; + const deleteMutation = useDeleteForm(); const [deleteId, setDeleteId] = useState(null); - const loc = localization || FORM_BUILDER_LOCALIZATION; const LinkComponent = Link || "a"; const handleDelete = async () => { @@ -62,12 +118,18 @@ export function FormListPage() { try { await deleteMutation.mutateAsync(deleteId); - toast.success(loc.FORM_BUILDER_TOAST_DELETE_SUCCESS); - setDeleteId(null); - await refetch(); - } catch (error) { - toast.error(loc.FORM_BUILDER_TOAST_ERROR); + } catch { + notify.error( + localization?.FORM_BUILDER_TOAST_ERROR ?? + t("formBuilder.toasts.error", "An error occurred. Please try again."), + ); + return; } + notify.success( + localization?.FORM_BUILDER_TOAST_DELETE_SUCCESS ?? + t("formBuilder.toasts.deleteSuccess", "Form deleted successfully"), + ); + setDeleteId(null); }; const getStatusBadge = (status: string) => { @@ -87,53 +149,108 @@ export function FormListPage() { ); }; + const newFormButton = ( + + + + ); + return (

- {loc.FORM_BUILDER_LIST_TITLE} + {localization?.FORM_BUILDER_LIST_TITLE ?? + t("formBuilder.list.title", "Forms")}

- {loc.FORM_BUILDER_LIST_SUBTITLE} + {localization?.FORM_BUILDER_LIST_SUBTITLE ?? + t("formBuilder.list.subtitle", "Manage your forms")}

- + {newFormButton}
- {forms.length === 0 ? ( - - - - {loc.FORM_BUILDER_BUTTON_NEW_FORM} - - +
+ + setSearchInput(e.target.value)} + placeholder={ + localization?.FORM_BUILDER_LIST_SEARCH_PLACEHOLDER ?? + t("formBuilder.list.searchPlaceholder", "Search forms...") } + className="pl-9" /> + {isSearchLoading && ( + + )} +
+ + {forms.length === 0 ? ( + isSearchLoading ? null : hasSearch ? ( + + ) : ( + + ) ) : ( <>
- {loc.FORM_BUILDER_LIST_COLUMN_NAME} - {loc.FORM_BUILDER_LIST_COLUMN_SLUG} - {loc.FORM_BUILDER_LIST_COLUMN_STATUS} - {loc.FORM_BUILDER_LIST_COLUMN_CREATED} + {localization?.FORM_BUILDER_LIST_COLUMN_NAME ?? + t("formBuilder.list.columnName", "Name")} + + + {localization?.FORM_BUILDER_LIST_COLUMN_SLUG ?? + t("formBuilder.list.columnSlug", "Slug")} + + + {localization?.FORM_BUILDER_LIST_COLUMN_STATUS ?? + t("formBuilder.list.columnStatus", "Status")} + + + {localization?.FORM_BUILDER_LIST_COLUMN_CREATED ?? + t("formBuilder.list.columnCreated", "Created")} - {loc.FORM_BUILDER_LIST_COLUMN_ACTIONS} + {localization?.FORM_BUILDER_LIST_COLUMN_ACTIONS ?? + t("formBuilder.list.columnActions", "Actions")} @@ -153,35 +270,67 @@ export function FormListPage() { - - navigate?.(`${basePath}/forms/${form.id}/edit`) - } + - - {loc.FORM_BUILDER_LIST_ACTION_EDIT} - - - navigate?.( - `${basePath}/forms/${form.id}/submissions`, - ) - } + + navigate?.( + `${basePath}/forms/${form.id}/edit`, + ) + } + > + + {localization?.FORM_BUILDER_LIST_ACTION_EDIT ?? + t("formBuilder.list.actionEdit", "Edit")} + + + - - {loc.FORM_BUILDER_LIST_ACTION_SUBMISSIONS} - - setDeleteId(form.id)} + + navigate?.( + `${basePath}/forms/${form.id}/submissions`, + ) + } + > + + {localization?.FORM_BUILDER_LIST_ACTION_SUBMISSIONS ?? + t( + "formBuilder.list.actionSubmissions", + "Submissions", + )} + + + - - {loc.FORM_BUILDER_LIST_ACTION_DELETE} - + setDeleteId(form.id)} + > + + {localization?.FORM_BUILDER_LIST_ACTION_DELETE ?? + t("formBuilder.list.actionDelete", "Delete")} + + @@ -197,6 +346,20 @@ export function FormListPage() { hasMore={hasMore} isLoadingMore={isLoadingMore} onLoadMore={loadMore} + labels={{ + showing: + localization?.FORM_BUILDER_LIST_PAGINATION_SHOWING ?? + t( + "formBuilder.list.paginationShowing", + "Showing {count} of {total}", + ), + next: + localization?.FORM_BUILDER_LIST_PAGINATION_NEXT ?? + t("formBuilder.list.paginationNext", "Load More"), + loading: + localization?.FORM_BUILDER_STATUS_LOADING ?? + t("formBuilder.common.statusLoading", "Loading..."), + }} /> )} @@ -206,22 +369,32 @@ export function FormListPage() { setDeleteId(null)}> - Delete Form + + {localization?.FORM_BUILDER_LIST_DELETE_TITLE ?? + t("formBuilder.list.deleteTitle", "Delete Form")} + - {loc.FORM_BUILDER_EDITOR_DELETE_CONFIRM} + {localization?.FORM_BUILDER_EDITOR_DELETE_CONFIRM ?? + t( + "formBuilder.editor.deleteConfirm", + "Are you sure you want to delete this form? All submissions will also be deleted.", + )} - {loc.FORM_BUILDER_BUTTON_CANCEL} + {localization?.FORM_BUILDER_BUTTON_CANCEL ?? + t("formBuilder.common.buttonCancel", "Cancel")} {deleteMutation.isPending - ? loc.FORM_BUILDER_STATUS_DELETING - : loc.FORM_BUILDER_BUTTON_DELETE} + ? (localization?.FORM_BUILDER_STATUS_DELETING ?? + t("formBuilder.common.statusDeleting", "Deleting...")) + : (localization?.FORM_BUILDER_BUTTON_DELETE ?? + t("formBuilder.common.buttonDelete", "Delete"))} diff --git a/packages/stack/src/plugins/form-builder/client/components/pages/form-list-page.tsx b/packages/stack/src/plugins/form-builder/client/components/pages/form-list-page.tsx index c95a49cf..c0fd0654 100644 --- a/packages/stack/src/plugins/form-builder/client/components/pages/form-list-page.tsx +++ b/packages/stack/src/plugins/form-builder/client/components/pages/form-list-page.tsx @@ -1,9 +1,12 @@ "use client"; -import { lazy, Suspense } from "react"; -import { FormListSkeleton } from "../loading/form-list-skeleton"; -import { ErrorBoundary } from "react-error-boundary"; +import { lazy } from "react"; +import { usePluginOverrides } from "@btst/stack/context"; +import type { FormBuilderPluginOverrides } from "../../overrides"; +import { ComposedRoute } from "@btst/stack/client/components"; import { DefaultError } from "../shared/default-error"; +import { FormListSkeleton } from "../loading/form-list-skeleton"; +import { NotFoundPage } from "./404-page"; const FormListPage = lazy(() => import("./form-list-page.internal").then((m) => ({ @@ -12,11 +15,24 @@ const FormListPage = lazy(() => ); export function FormListPageComponent() { + const { onRouteError } = + usePluginOverrides("form-builder"); + return ( - - }> - - - + { + if (onRouteError) { + onRouteError("formList", error, { + path: "/forms", + isSSR: typeof window === "undefined", + }); + } + }} + /> ); } diff --git a/packages/stack/src/plugins/form-builder/client/components/pages/submissions-page.internal.tsx b/packages/stack/src/plugins/form-builder/client/components/pages/submissions-page.internal.tsx index acf1b0d5..82a4fbf5 100644 --- a/packages/stack/src/plugins/form-builder/client/components/pages/submissions-page.internal.tsx +++ b/packages/stack/src/plugins/form-builder/client/components/pages/submissions-page.internal.tsx @@ -1,7 +1,13 @@ "use client"; import { useState } from "react"; -import { usePluginOverrides, useBasePath } from "@btst/stack/context"; +import { + CanAccess, + useNotify, + usePluginOverrides, + useBasePath, + useTranslate, +} from "@btst/stack/context"; import { Button } from "@workspace/ui/components/button"; import { Table, @@ -28,15 +34,13 @@ import { DialogTitle, } from "@workspace/ui/components/dialog"; import { ArrowLeft, Trash2, Eye } from "lucide-react"; -import { toast } from "sonner"; import { useSuspenseFormById, useSuspenseSubmissions, useDeleteSubmission, -} from "../../hooks/form-builder-hooks"; +} from "../../hooks"; import type { FormBuilderPluginOverrides } from "../../overrides"; -import { FORM_BUILDER_LOCALIZATION } from "../../localization"; import type { SerializedFormSubmissionWithData } from "../../../types"; import { PageWrapper } from "../shared/page-wrapper"; import { EmptyState } from "../shared/empty-state"; @@ -47,16 +51,14 @@ export interface SubmissionsPageProps { } export function SubmissionsPage({ formId }: SubmissionsPageProps) { - const { navigate, Link, localization } = usePluginOverrides< - FormBuilderPluginOverrides, - Partial - >("form-builder", { - localization: FORM_BUILDER_LOCALIZATION, - }); + const t = useTranslate(); + const notify = useNotify(); + const { Link, localization } = + usePluginOverrides("form-builder"); const basePath = useBasePath(); const { form } = useSuspenseFormById(formId); - const { submissions, total, hasMore, isLoadingMore, loadMore, refetch } = + const { submissions, total, hasMore, isLoadingMore, loadMore } = useSuspenseSubmissions(formId); const deleteMutation = useDeleteSubmission(formId); @@ -64,7 +66,6 @@ export function SubmissionsPage({ formId }: SubmissionsPageProps) { const [viewSubmission, setViewSubmission] = useState(null); - const loc = localization || FORM_BUILDER_LOCALIZATION; const LinkComponent = Link || "a"; const handleDelete = async () => { @@ -72,12 +73,21 @@ export function SubmissionsPage({ formId }: SubmissionsPageProps) { try { await deleteMutation.mutateAsync(deleteId); - toast.success(loc.FORM_BUILDER_TOAST_SUBMISSION_DELETED); - setDeleteId(null); - await refetch(); - } catch (error) { - toast.error(loc.FORM_BUILDER_TOAST_ERROR); + } catch { + notify.error( + localization?.FORM_BUILDER_TOAST_ERROR ?? + t("formBuilder.toasts.error", "An error occurred. Please try again."), + ); + return; } + notify.success( + localization?.FORM_BUILDER_TOAST_SUBMISSION_DELETED ?? + t( + "formBuilder.toasts.submissionDeleted", + "Submission deleted successfully", + ), + ); + setDeleteId(null); }; const formatSubmissionData = (data: Record) => { @@ -104,18 +114,30 @@ export function SubmissionsPage({ formId }: SubmissionsPageProps) {

- {form?.name || loc.FORM_BUILDER_SUBMISSIONS_TITLE} + {form?.name || + (localization?.FORM_BUILDER_SUBMISSIONS_TITLE ?? + t("formBuilder.submissions.title", "Submissions"))}

- {loc.FORM_BUILDER_SUBMISSIONS_SUBTITLE} + {localization?.FORM_BUILDER_SUBMISSIONS_SUBTITLE ?? + t("formBuilder.submissions.subtitle", "View form submissions")}

{submissions.length === 0 ? ( ) : ( <> @@ -124,19 +146,30 @@ export function SubmissionsPage({ formId }: SubmissionsPageProps) { - {loc.FORM_BUILDER_SUBMISSIONS_COLUMN_ID} + {localization?.FORM_BUILDER_SUBMISSIONS_COLUMN_ID ?? + t("formBuilder.submissions.columnId", "ID")} - {loc.FORM_BUILDER_SUBMISSIONS_COLUMN_DATA} + {localization?.FORM_BUILDER_SUBMISSIONS_COLUMN_DATA ?? + t("formBuilder.submissions.columnData", "Data")} - {loc.FORM_BUILDER_SUBMISSIONS_COLUMN_SUBMITTED_AT} + {localization?.FORM_BUILDER_SUBMISSIONS_COLUMN_SUBMITTED_AT ?? + t( + "formBuilder.submissions.columnSubmittedAt", + "Submitted", + )} - {loc.FORM_BUILDER_SUBMISSIONS_COLUMN_IP_ADDRESS} + {localization?.FORM_BUILDER_SUBMISSIONS_COLUMN_IP_ADDRESS ?? + t( + "formBuilder.submissions.columnIpAddress", + "IP Address", + )} - {loc.FORM_BUILDER_SUBMISSIONS_COLUMN_ACTIONS} + {localization?.FORM_BUILDER_SUBMISSIONS_COLUMN_ACTIONS ?? + t("formBuilder.submissions.columnActions", "Actions")} @@ -163,17 +196,32 @@ export function SubmissionsPage({ formId }: SubmissionsPageProps) { onClick={() => setViewSubmission(sub)} > - View + + {localization?.FORM_BUILDER_SUBMISSIONS_ACTION_VIEW ?? + t("formBuilder.submissions.actionView", "View")} + - + + @@ -188,6 +236,20 @@ export function SubmissionsPage({ formId }: SubmissionsPageProps) { hasMore={hasMore} isLoadingMore={isLoadingMore} onLoadMore={loadMore} + labels={{ + showing: + localization?.FORM_BUILDER_LIST_PAGINATION_SHOWING ?? + t( + "formBuilder.list.paginationShowing", + "Showing {count} of {total}", + ), + next: + localization?.FORM_BUILDER_LIST_PAGINATION_NEXT ?? + t("formBuilder.list.paginationNext", "Load More"), + loading: + localization?.FORM_BUILDER_STATUS_LOADING ?? + t("formBuilder.common.statusLoading", "Loading..."), + }} /> )} @@ -200,36 +262,57 @@ export function SubmissionsPage({ formId }: SubmissionsPageProps) { > - Submission Details + + {localization?.FORM_BUILDER_SUBMISSIONS_DETAILS_TITLE ?? + t("formBuilder.submissions.detailsTitle", "Submission Details")} + {viewSubmission && (
- ID: + + {localization?.FORM_BUILDER_SUBMISSIONS_FIELD_ID ?? + t("formBuilder.submissions.fieldId", "ID:")} +

{viewSubmission.id}

- Submitted: + + {localization?.FORM_BUILDER_SUBMISSIONS_FIELD_SUBMITTED ?? + t("formBuilder.submissions.fieldSubmitted", "Submitted:")} +

{new Date(viewSubmission.submittedAt).toLocaleString()}

- IP Address: + + {localization?.FORM_BUILDER_SUBMISSIONS_FIELD_IP ?? + t("formBuilder.submissions.fieldIp", "IP Address:")} +

{viewSubmission.ipAddress || "-"}

- User Agent: + + {localization?.FORM_BUILDER_SUBMISSIONS_FIELD_USER_AGENT ?? + t( + "formBuilder.submissions.fieldUserAgent", + "User Agent:", + )} +

{viewSubmission.userAgent || "-"}

- Data: + + {localization?.FORM_BUILDER_SUBMISSIONS_FIELD_DATA ?? + t("formBuilder.submissions.fieldData", "Data:")} +
 									{JSON.stringify(viewSubmission.parsedData, null, 2)}
 								
@@ -243,22 +326,32 @@ export function SubmissionsPage({ formId }: SubmissionsPageProps) { setDeleteId(null)}> - Delete Submission + + {localization?.FORM_BUILDER_SUBMISSIONS_DELETE_TITLE ?? + t("formBuilder.submissions.deleteTitle", "Delete Submission")} + - {loc.FORM_BUILDER_SUBMISSIONS_DELETE_CONFIRM} + {localization?.FORM_BUILDER_SUBMISSIONS_DELETE_CONFIRM ?? + t( + "formBuilder.submissions.deleteConfirm", + "Are you sure you want to delete this submission?", + )} - {loc.FORM_BUILDER_BUTTON_CANCEL} + {localization?.FORM_BUILDER_BUTTON_CANCEL ?? + t("formBuilder.common.buttonCancel", "Cancel")} {deleteMutation.isPending - ? loc.FORM_BUILDER_STATUS_DELETING - : loc.FORM_BUILDER_BUTTON_DELETE} + ? (localization?.FORM_BUILDER_STATUS_DELETING ?? + t("formBuilder.common.statusDeleting", "Deleting...")) + : (localization?.FORM_BUILDER_BUTTON_DELETE ?? + t("formBuilder.common.buttonDelete", "Delete"))} diff --git a/packages/stack/src/plugins/form-builder/client/components/pages/submissions-page.tsx b/packages/stack/src/plugins/form-builder/client/components/pages/submissions-page.tsx index f2d58b12..a6556951 100644 --- a/packages/stack/src/plugins/form-builder/client/components/pages/submissions-page.tsx +++ b/packages/stack/src/plugins/form-builder/client/components/pages/submissions-page.tsx @@ -1,9 +1,12 @@ "use client"; -import { lazy, Suspense } from "react"; -import { SubmissionsSkeleton } from "../loading/submissions-skeleton"; -import { ErrorBoundary } from "react-error-boundary"; +import { lazy } from "react"; +import { usePluginOverrides } from "@btst/stack/context"; +import type { FormBuilderPluginOverrides } from "../../overrides"; +import { ComposedRoute } from "@btst/stack/client/components"; import { DefaultError } from "../shared/default-error"; +import { SubmissionsSkeleton } from "../loading/submissions-skeleton"; +import { NotFoundPage } from "./404-page"; const SubmissionsPage = lazy(() => import("./submissions-page.internal").then((m) => ({ @@ -16,11 +19,33 @@ export interface SubmissionsPageProps { } export function SubmissionsPageComponent({ formId }: SubmissionsPageProps) { + const { onRouteError } = + usePluginOverrides("form-builder"); + + const path = `/forms/${formId}/submissions`; + return ( - - }> - - - + { + if (onRouteError) { + onRouteError("submissions", error, { + path, + params: { formId }, + isSSR: typeof window === "undefined", + }); + } + }} + /> ); } diff --git a/packages/stack/src/plugins/form-builder/client/components/shared/pagination.tsx b/packages/stack/src/plugins/form-builder/client/components/shared/pagination.tsx index 2e9da19e..ffb3613f 100644 --- a/packages/stack/src/plugins/form-builder/client/components/shared/pagination.tsx +++ b/packages/stack/src/plugins/form-builder/client/components/shared/pagination.tsx @@ -13,6 +13,7 @@ interface PaginationProps { showing?: string; previous?: string; next?: string; + loading?: string; }; } @@ -27,6 +28,7 @@ export function Pagination({ const { showing: showingLabel = "Showing {count} of {total}", next = "Load More", + loading = "Loading...", } = labels; const showingText = showingLabel @@ -43,7 +45,7 @@ export function Pagination({ onClick={onLoadMore} disabled={isLoadingMore} > - {isLoadingMore ? "Loading..." : next} + {isLoadingMore ? loading : next} )} diff --git a/packages/stack/src/plugins/form-builder/client/hooks/form-builder-hooks.tsx b/packages/stack/src/plugins/form-builder/client/hooks/form-builder-hooks.tsx index 99600cde..ead9ee48 100644 --- a/packages/stack/src/plugins/form-builder/client/hooks/form-builder-hooks.tsx +++ b/packages/stack/src/plugins/form-builder/client/hooks/form-builder-hooks.tsx @@ -1,71 +1,29 @@ "use client"; -import { - useQuery, - useMutation, - useQueryClient, - useSuspenseQuery, - useInfiniteQuery, - useSuspenseInfiniteQuery, - type InfiniteData, -} from "@tanstack/react-query"; -import { createApiClient } from "@btst/stack/plugins/client"; -import { usePluginOverrides } from "@btst/stack/context"; -import type { FormBuilderApiRouter } from "../../api"; +import type { + ResourceFormConfig, + ResourceFormResult, +} from "@btst/stack/plugins/client/hooks"; import type { SerializedForm, PaginatedForms, - SerializedFormSubmission, SerializedFormSubmissionWithData, PaginatedFormSubmissions, } from "../../types"; -import type { FormBuilderPluginOverrides } from "../overrides"; -import { createFormBuilderQueryKeys } from "../../query-keys"; - -// Type guard for better-call error responses -function isErrorResponse( - response: unknown, -): response is { error: unknown; data?: never } { - if (typeof response !== "object" || response === null) { - return false; - } - const obj = response as Record; - return "error" in obj && obj.error !== null && obj.error !== undefined; -} +import { formBuilder } from "./form-builder-resource"; -// Helper to convert error to a proper Error object with meaningful message -function toError(error: unknown): Error { - if (error instanceof Error) { - return error; - } - - if (typeof error === "object" && error !== null) { - const errorObj = error as Record; - const message = - (typeof errorObj.message === "string" ? errorObj.message : null) || - (typeof errorObj.error === "string" ? errorObj.error : null) || - JSON.stringify(error); - - const err = new Error(message); - Object.assign(err, error); - return err; - } - - return new Error(String(error)); -} +export type { CreateFormInput, UpdateFormInput } from "../../query-keys"; -/** - * Shared React Query configuration for all Form Builder queries - * Prevents automatic refetching to avoid hydration mismatches in SSR - */ -const SHARED_QUERY_CONFIG = { - retry: false, - refetchOnWindowFocus: false, - refetchOnMount: false, - refetchOnReconnect: false, - staleTime: 1000 * 60 * 5, // 5 minutes - gcTime: 1000 * 60 * 10, // 10 minutes -} as const; +/** Flattens infinite-query pages of `{ items, total }` envelopes. */ +function flattenPages( + pages: { items?: TItem[]; total?: number }[] | undefined, +): { items: TItem[]; total: number } { + const items = + pages?.flatMap((page) => (Array.isArray(page?.items) ? page.items : [])) ?? + []; + const total = pages?.[0]?.total ?? 0; + return { items, total }; +} // ========== Forms Hooks (Admin) ========== @@ -76,6 +34,8 @@ export interface UseFormsOptions { limit?: number; /** Whether to enable the query (default: true) */ enabled?: boolean; + /** Free-text search across form names and slugs */ + search?: string; } export interface UseFormsResult { @@ -93,16 +53,7 @@ export interface UseFormsResult { * Hook for fetching paginated forms (admin) */ export function useForms(options: UseFormsOptions = {}): UseFormsResult { - const { apiBaseURL, apiBasePath, headers } = - usePluginOverrides("form-builder"); - const client = createApiClient({ - baseURL: apiBaseURL, - basePath: apiBasePath, - }); - const queries = createFormBuilderQueryKeys(client, headers); - const { status, limit = 20, enabled = true } = options; - - const baseQuery = queries.forms.list({ status, limit, offset: 0 }); + const { status, limit = 20, enabled = true, search } = options; const { data, @@ -112,49 +63,16 @@ export function useForms(options: UseFormsOptions = {}): UseFormsResult { hasNextPage, isFetchingNextPage, refetch, - } = useInfiniteQuery({ - queryKey: baseQuery.queryKey, - queryFn: async ({ pageParam = 0 }) => { - const response: unknown = await client("/forms", { - method: "GET", - query: { status, limit, offset: pageParam }, - headers, - }); - if (isErrorResponse(response)) { - throw toError(response.error); - } - return (response as { data?: unknown }).data as PaginatedForms; - }, - ...SHARED_QUERY_CONFIG, - initialPageParam: 0, - getNextPageParam: (lastPage, allPages) => { - if (!lastPage || typeof lastPage !== "object") return undefined; - const items = (lastPage as PaginatedForms)?.items; - if (!Array.isArray(items) || items.length < limit) return undefined; - const loadedCount = (allPages || []).reduce( - (sum, page) => - sum + - (Array.isArray((page as PaginatedForms)?.items) - ? (page as PaginatedForms).items.length - : 0), - 0, - ); - const total = (lastPage as PaginatedForms)?.total ?? 0; - if (loadedCount >= total) return undefined; - return loadedCount; - }, + } = formBuilder.forms.list.useInfinite([{ status, limit, search }], { enabled, }); - const pages = (data as InfiniteData | undefined) - ?.pages; - const forms = (pages?.flatMap((page) => - Array.isArray(page?.items) ? page.items : [], - ) ?? []) as SerializedForm[]; - const total = pages?.[0]?.total ?? 0; + const { items, total } = flattenPages( + data?.pages as PaginatedForms[] | undefined, + ); return { - forms, + forms: items, total, isLoading, error, @@ -176,70 +94,17 @@ export function useSuspenseForms(options: UseFormsOptions = {}): { isLoadingMore: boolean; refetch: () => Promise; } { - const { apiBaseURL, apiBasePath, headers } = - usePluginOverrides("form-builder"); - const client = createApiClient({ - baseURL: apiBaseURL, - basePath: apiBasePath, - }); - const queries = createFormBuilderQueryKeys(client, headers); - const { status, limit = 20 } = options; + const { status, limit = 20, search } = options; - const baseQuery = queries.forms.list({ status, limit, offset: 0 }); + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, refetch } = + formBuilder.forms.list.useSuspenseInfinite([{ status, limit, search }]); - const { - data, - fetchNextPage, - hasNextPage, - isFetchingNextPage, - refetch, - error, - isFetching, - } = useSuspenseInfiniteQuery({ - queryKey: baseQuery.queryKey, - queryFn: async ({ pageParam = 0 }) => { - const response: unknown = await client("/forms", { - method: "GET", - query: { status, limit, offset: pageParam }, - headers, - }); - if (isErrorResponse(response)) { - throw toError(response.error); - } - return (response as { data?: unknown }).data as PaginatedForms; - }, - ...SHARED_QUERY_CONFIG, - initialPageParam: 0, - getNextPageParam: (lastPage, allPages) => { - if (!lastPage || typeof lastPage !== "object") return undefined; - const items = (lastPage as PaginatedForms)?.items; - if (!Array.isArray(items) || items.length < limit) return undefined; - const loadedCount = (allPages || []).reduce( - (sum, page) => - sum + - (Array.isArray((page as PaginatedForms)?.items) - ? (page as PaginatedForms).items.length - : 0), - 0, - ); - const total = (lastPage as PaginatedForms)?.total ?? 0; - if (loadedCount >= total) return undefined; - return loadedCount; - }, - }); - - if (error && !isFetching) { - throw error; - } - - const pages = data.pages as PaginatedForms[]; - const forms = (pages?.flatMap((page) => - Array.isArray(page?.items) ? page.items : [], - ) ?? []) as SerializedForm[]; - const total = pages?.[0]?.total ?? 0; + const { items, total } = flattenPages( + data.pages as PaginatedForms[], + ); return { - forms, + forms: items, total, loadMore: fetchNextPage, hasMore: !!hasNextPage, @@ -257,18 +122,7 @@ export function useFormById(id: string): { error: Error | null; refetch: () => void; } { - const { apiBaseURL, apiBasePath, headers } = - usePluginOverrides("form-builder"); - const client = createApiClient({ - baseURL: apiBaseURL, - basePath: apiBasePath, - }); - const queries = createFormBuilderQueryKeys(client, headers); - const baseQuery = queries.forms.byId(id); - - const { data, isLoading, error, refetch } = useQuery({ - ...baseQuery, - ...SHARED_QUERY_CONFIG, + const { data, isLoading, error, refetch } = formBuilder.forms.byId.use([id], { enabled: !!id, }); @@ -287,23 +141,7 @@ export function useSuspenseFormById(id: string): { form: SerializedForm | null; refetch: () => Promise; } { - const { apiBaseURL, apiBasePath, headers } = - usePluginOverrides("form-builder"); - const client = createApiClient({ - baseURL: apiBaseURL, - basePath: apiBasePath, - }); - const queries = createFormBuilderQueryKeys(client, headers); - const baseQuery = queries.forms.byId(id); - - const { data, refetch, error, isFetching } = useSuspenseQuery({ - ...baseQuery, - ...SHARED_QUERY_CONFIG, - }); - - if (error && !isFetching) { - throw error; - } + const { data, refetch } = formBuilder.forms.byId.useSuspense([id]); return { form: data ?? null, @@ -320,20 +158,10 @@ export function useFormBySlug(slug: string): { error: Error | null; refetch: () => void; } { - const { apiBaseURL, apiBasePath, headers } = - usePluginOverrides("form-builder"); - const client = createApiClient({ - baseURL: apiBaseURL, - basePath: apiBasePath, - }); - const queries = createFormBuilderQueryKeys(client, headers); - const baseQuery = queries.forms.bySlug(slug); - - const { data, isLoading, error, refetch } = useQuery({ - ...baseQuery, - ...SHARED_QUERY_CONFIG, - enabled: !!slug, - }); + const { data, isLoading, error, refetch } = formBuilder.forms.bySlug.use( + [slug], + { enabled: !!slug }, + ); return { form: data ?? null, @@ -350,23 +178,7 @@ export function useSuspenseFormBySlug(slug: string): { form: SerializedForm | null; refetch: () => Promise; } { - const { apiBaseURL, apiBasePath, headers } = - usePluginOverrides("form-builder"); - const client = createApiClient({ - baseURL: apiBaseURL, - basePath: apiBasePath, - }); - const queries = createFormBuilderQueryKeys(client, headers); - const baseQuery = queries.forms.bySlug(slug); - - const { data, refetch, error, isFetching } = useSuspenseQuery({ - ...baseQuery, - ...SHARED_QUERY_CONFIG, - }); - - if (error && !isFetching) { - throw error; - } + const { data, refetch } = formBuilder.forms.bySlug.useSuspense([slug]); return { form: data ?? null, @@ -376,150 +188,41 @@ export function useSuspenseFormBySlug(slug: string): { // ========== Form Mutations ========== -export interface CreateFormInput { - name: string; - slug: string; - description?: string; - schema: string; - successMessage?: string; - redirectUrl?: string; - status?: "active" | "inactive" | "archived"; -} - -export interface UpdateFormInput { - name?: string; - slug?: string; - description?: string; - schema?: string; - successMessage?: string; - redirectUrl?: string; - status?: "active" | "inactive" | "archived"; -} - /** * Hook for creating a form */ export function useCreateForm() { - const { refresh, apiBaseURL, apiBasePath, headers } = - usePluginOverrides("form-builder"); - const client = createApiClient({ - baseURL: apiBaseURL, - basePath: apiBasePath, - }); - const queryClient = useQueryClient(); - const queries = createFormBuilderQueryKeys(client, headers); - - return useMutation({ - mutationKey: [...queries.forms._def, "create"], - mutationFn: async (data) => { - const response: unknown = await client("@post/forms", { - method: "POST", - body: data, - headers, - }); - if (isErrorResponse(response)) { - throw toError(response.error); - } - return (response as { data?: unknown }).data as SerializedForm; - }, - onSuccess: async () => { - await queryClient.invalidateQueries({ - queryKey: queries.forms._def, - }); - if (refresh) { - await refresh(); - } - }, - }); + return formBuilder.forms.create.use(); } /** * Hook for updating a form */ export function useUpdateForm() { - const { refresh, apiBaseURL, apiBasePath, headers } = - usePluginOverrides("form-builder"); - const client = createApiClient({ - baseURL: apiBaseURL, - basePath: apiBasePath, - }); - const queryClient = useQueryClient(); - const queries = createFormBuilderQueryKeys(client, headers); - - return useMutation< - SerializedForm, - Error, - { id: string; data: UpdateFormInput } - >({ - mutationKey: [...queries.forms._def, "update"], - mutationFn: async ({ id, data }) => { - const response: unknown = await client("@put/forms/:id", { - method: "PUT", - params: { id }, - body: data, - headers, - }); - if (isErrorResponse(response)) { - throw toError(response.error); - } - return (response as { data?: unknown }).data as SerializedForm; - }, - onSuccess: async (updated) => { - if (updated) { - queryClient.setQueryData( - queries.forms.byId(updated.id).queryKey, - updated, - ); - queryClient.setQueryData( - queries.forms.bySlug(updated.slug).queryKey, - updated, - ); - } - await queryClient.invalidateQueries({ - queryKey: queries.forms._def, - }); - if (refresh) { - await refresh(); - } - }, - }); + return formBuilder.forms.update.use(); } /** * Hook for deleting a form */ export function useDeleteForm() { - const { refresh, apiBaseURL, apiBasePath, headers } = - usePluginOverrides("form-builder"); - const client = createApiClient({ - baseURL: apiBaseURL, - basePath: apiBasePath, - }); - const queryClient = useQueryClient(); - const queries = createFormBuilderQueryKeys(client, headers); - - return useMutation<{ success: boolean }, Error, string>({ - mutationKey: [...queries.forms._def, "delete"], - mutationFn: async (id) => { - const response: unknown = await client("@delete/forms/:id", { - method: "DELETE", - params: { id }, - headers, - }); - if (isErrorResponse(response)) { - throw toError(response.error); - } - return (response as { data?: unknown }).data as { success: boolean }; - }, - onSuccess: async () => { - await queryClient.invalidateQueries({ - queryKey: queries.forms._def, - }); - if (refresh) { - await refresh(); - } - }, - }); + return formBuilder.forms.delete.use(); +} + +/** + * Form lifecycle hook for creating/editing forms, built on the core resource + * `useForm`: submits the right mutation, awaits invalidation, notifies via + * `useNotify()`, redirects, and maps server validation issues to + * `fieldErrors`. + */ +export function useFormBuilderForm( + config: ResourceFormConfig, +): ResourceFormResult { + return formBuilder.forms.useForm< + TValues, + SerializedForm, + SerializedForm | null + >(config); } // ========== Form Submission Hooks ========== @@ -528,38 +231,15 @@ export function useDeleteForm() { * Hook for submitting a form (public) */ export function useSubmitForm(slug: string) { - const { apiBaseURL, apiBasePath, headers } = - usePluginOverrides("form-builder"); - const client = createApiClient({ - baseURL: apiBaseURL, - basePath: apiBasePath, - }); - const queries = createFormBuilderQueryKeys(client, headers); - - return useMutation< - SerializedFormSubmission & { - form: { successMessage?: string; redirectUrl?: string }; - }, - Error, - { data: Record } - >({ - mutationKey: [...queries.forms._def, slug, "submit"], - mutationFn: async ({ data }) => { - const response: unknown = await client("@post/forms/:slug/submit", { - method: "POST", - params: { slug }, - body: { data }, - headers, - }); - if (isErrorResponse(response)) { - throw toError(response.error); - } - return (response as { data?: unknown }) - .data as SerializedFormSubmission & { - form: { successMessage?: string; redirectUrl?: string }; - }; - }, - }); + const mutation = formBuilder.forms.submit.use(); + + return { + ...mutation, + mutate: (vars: { data: Record }) => + mutation.mutate({ slug, data: vars.data }), + mutateAsync: (vars: { data: Record }) => + mutation.mutateAsync({ slug, data: vars.data }), + }; } // ========== Submissions Management Hooks (Admin) ========== @@ -589,21 +269,8 @@ export function useSubmissions( formId: string, options: UseSubmissionsOptions = {}, ): UseSubmissionsResult { - const { apiBaseURL, apiBasePath, headers } = - usePluginOverrides("form-builder"); - const client = createApiClient({ - baseURL: apiBaseURL, - basePath: apiBasePath, - }); - const queries = createFormBuilderQueryKeys(client, headers); const { limit = 20, enabled = true } = options; - const baseQuery = queries.formSubmissions.list({ - formId, - limit, - offset: 0, - }); - const { data, isLoading, @@ -612,51 +279,16 @@ export function useSubmissions( hasNextPage, isFetchingNextPage, refetch, - } = useInfiniteQuery({ - queryKey: baseQuery.queryKey, - queryFn: async ({ pageParam = 0 }) => { - const response: unknown = await client("/forms/:formId/submissions", { - method: "GET", - params: { formId }, - query: { limit, offset: pageParam }, - headers, - }); - if (isErrorResponse(response)) { - throw toError(response.error); - } - return (response as { data?: unknown }).data as PaginatedFormSubmissions; - }, - ...SHARED_QUERY_CONFIG, - initialPageParam: 0, - getNextPageParam: (lastPage, allPages) => { - if (!lastPage || typeof lastPage !== "object") return undefined; - const items = (lastPage as PaginatedFormSubmissions)?.items; - if (!Array.isArray(items) || items.length < limit) return undefined; - const loadedCount = (allPages || []).reduce( - (sum, page) => - sum + - (Array.isArray((page as PaginatedFormSubmissions)?.items) - ? (page as PaginatedFormSubmissions).items.length - : 0), - 0, - ); - const total = (lastPage as PaginatedFormSubmissions)?.total ?? 0; - if (loadedCount >= total) return undefined; - return loadedCount; - }, + } = formBuilder.formSubmissions.list.useInfinite([{ formId, limit }], { enabled: enabled && !!formId, }); - const pages = ( - data as InfiniteData | undefined - )?.pages; - const submissions = (pages?.flatMap((page) => - Array.isArray(page?.items) ? page.items : [], - ) ?? []) as SerializedFormSubmissionWithData[]; - const total = pages?.[0]?.total ?? 0; + const { items, total } = flattenPages( + data?.pages as PaginatedFormSubmissions[] | undefined, + ); return { - submissions, + submissions: items, total, isLoading, error, @@ -681,75 +313,17 @@ export function useSuspenseSubmissions( isLoadingMore: boolean; refetch: () => Promise; } { - const { apiBaseURL, apiBasePath, headers } = - usePluginOverrides("form-builder"); - const client = createApiClient({ - baseURL: apiBaseURL, - basePath: apiBasePath, - }); - const queries = createFormBuilderQueryKeys(client, headers); const { limit = 20 } = options; - const baseQuery = queries.formSubmissions.list({ - formId, - limit, - offset: 0, - }); - - const { - data, - fetchNextPage, - hasNextPage, - isFetchingNextPage, - refetch, - error, - isFetching, - } = useSuspenseInfiniteQuery({ - queryKey: baseQuery.queryKey, - queryFn: async ({ pageParam = 0 }) => { - const response: unknown = await client("/forms/:formId/submissions", { - method: "GET", - params: { formId }, - query: { limit, offset: pageParam }, - headers, - }); - if (isErrorResponse(response)) { - throw toError(response.error); - } - return (response as { data?: unknown }).data as PaginatedFormSubmissions; - }, - ...SHARED_QUERY_CONFIG, - initialPageParam: 0, - getNextPageParam: (lastPage, allPages) => { - if (!lastPage || typeof lastPage !== "object") return undefined; - const items = (lastPage as PaginatedFormSubmissions)?.items; - if (!Array.isArray(items) || items.length < limit) return undefined; - const loadedCount = (allPages || []).reduce( - (sum, page) => - sum + - (Array.isArray((page as PaginatedFormSubmissions)?.items) - ? (page as PaginatedFormSubmissions).items.length - : 0), - 0, - ); - const total = (lastPage as PaginatedFormSubmissions)?.total ?? 0; - if (loadedCount >= total) return undefined; - return loadedCount; - }, - }); - - if (error && !isFetching) { - throw error; - } + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, refetch } = + formBuilder.formSubmissions.list.useSuspenseInfinite([{ formId, limit }]); - const pages = data.pages as PaginatedFormSubmissions[]; - const submissions = (pages?.flatMap((page) => - Array.isArray(page?.items) ? page.items : [], - ) ?? []) as SerializedFormSubmissionWithData[]; - const total = pages?.[0]?.total ?? 0; + const { items, total } = flattenPages( + data.pages as PaginatedFormSubmissions[], + ); return { - submissions, + submissions: items, total, loadMore: fetchNextPage, hasMore: !!hasNextPage, @@ -762,38 +336,11 @@ export function useSuspenseSubmissions( * Hook for deleting a submission */ export function useDeleteSubmission(formId: string) { - const { refresh, apiBaseURL, apiBasePath, headers } = - usePluginOverrides("form-builder"); - const client = createApiClient({ - baseURL: apiBaseURL, - basePath: apiBasePath, - }); - const queryClient = useQueryClient(); - const queries = createFormBuilderQueryKeys(client, headers); - - return useMutation<{ success: boolean }, Error, string>({ - mutationKey: [...queries.formSubmissions._def, formId, "delete"], - mutationFn: async (subId) => { - const response: unknown = await client( - "@delete/forms/:formId/submissions/:subId", - { - method: "DELETE", - params: { formId, subId }, - headers, - }, - ); - if (isErrorResponse(response)) { - throw toError(response.error); - } - return (response as { data?: unknown }).data as { success: boolean }; - }, - onSuccess: async () => { - await queryClient.invalidateQueries({ - queryKey: queries.formSubmissions._def, - }); - if (refresh) { - await refresh(); - } - }, - }); + const mutation = formBuilder.formSubmissions.delete.use(); + + return { + ...mutation, + mutate: (subId: string) => mutation.mutate({ formId, subId }), + mutateAsync: (subId: string) => mutation.mutateAsync({ formId, subId }), + }; } diff --git a/packages/stack/src/plugins/form-builder/client/hooks/form-builder-resource.ts b/packages/stack/src/plugins/form-builder/client/hooks/form-builder-resource.ts new file mode 100644 index 00000000..792fde78 --- /dev/null +++ b/packages/stack/src/plugins/form-builder/client/hooks/form-builder-resource.ts @@ -0,0 +1,14 @@ +"use client"; + +import { createResource } from "@btst/stack/plugins/client/hooks"; +import { formBuilderResources } from "../../query-keys"; + +/** + * Factory-generated Form Builder resource hooks. Internal — the public hook + * surface (`useForms`, `useFormById`, ...) in `form-builder-hooks.tsx` wraps + * these. + */ +export const formBuilder = createResource({ + plugin: "form-builder", + resources: formBuilderResources, +}); diff --git a/packages/stack/src/plugins/form-builder/client/localization/form-builder-common.ts b/packages/stack/src/plugins/form-builder/client/localization/form-builder-common.ts index 88d86967..ad0626d4 100644 --- a/packages/stack/src/plugins/form-builder/client/localization/form-builder-common.ts +++ b/packages/stack/src/plugins/form-builder/client/localization/form-builder-common.ts @@ -31,6 +31,12 @@ export const FORM_BUILDER_COMMON = { FORM_BUILDER_ERROR_NOT_FOUND: "Not found", FORM_BUILDER_ERROR_VALIDATION: "Please fix the errors above", + // 404 page + FORM_BUILDER_404_TITLE: "Page not found", + FORM_BUILDER_404_DESCRIPTION: + "The page you're looking for doesn't exist or has been moved.", + FORM_BUILDER_404_BACK: "Back to Forms", + // Attribution FORM_BUILDER_ATTRIBUTION: "Powered by BTST", }; diff --git a/packages/stack/src/plugins/form-builder/client/localization/form-builder-list.ts b/packages/stack/src/plugins/form-builder/client/localization/form-builder-list.ts index d376f7b4..8d531d5f 100644 --- a/packages/stack/src/plugins/form-builder/client/localization/form-builder-list.ts +++ b/packages/stack/src/plugins/form-builder/client/localization/form-builder-list.ts @@ -11,7 +11,11 @@ export const FORM_BUILDER_LIST = { FORM_BUILDER_LIST_ACTION_EDIT: "Edit", FORM_BUILDER_LIST_ACTION_DELETE: "Delete", FORM_BUILDER_LIST_ACTION_SUBMISSIONS: "Submissions", - FORM_BUILDER_LIST_PAGINATION_SHOWING: "Showing {from}-{to} of {total}", + FORM_BUILDER_LIST_DELETE_TITLE: "Delete Form", + FORM_BUILDER_LIST_SEARCH_PLACEHOLDER: "Search forms...", + FORM_BUILDER_LIST_SEARCH_EMPTY: "No forms match your search", + FORM_BUILDER_LIST_SEARCH_EMPTY_DESCRIPTION: "Try a different search term.", + FORM_BUILDER_LIST_PAGINATION_SHOWING: "Showing {count} of {total}", FORM_BUILDER_LIST_PAGINATION_PREVIOUS: "Previous", - FORM_BUILDER_LIST_PAGINATION_NEXT: "Next", + FORM_BUILDER_LIST_PAGINATION_NEXT: "Load More", }; diff --git a/packages/stack/src/plugins/form-builder/client/localization/form-builder-renderer.ts b/packages/stack/src/plugins/form-builder/client/localization/form-builder-renderer.ts new file mode 100644 index 00000000..ba1045cb --- /dev/null +++ b/packages/stack/src/plugins/form-builder/client/localization/form-builder-renderer.ts @@ -0,0 +1,10 @@ +export const FORM_BUILDER_RENDERER = { + FORM_BUILDER_RENDERER_LOAD_FAILED: "Failed to load form", + FORM_BUILDER_RENDERER_UNEXPECTED_ERROR: "An unexpected error occurred", + FORM_BUILDER_RENDERER_SUBMITTED_TITLE: "Form Submitted", + FORM_BUILDER_RENDERER_NOT_FOUND: "Form not found", + FORM_BUILDER_RENDERER_INACTIVE: + "This form is not currently accepting submissions", + FORM_BUILDER_RENDERER_SCHEMA_ERROR: "Failed to parse form schema", + FORM_BUILDER_RENDERER_THANK_YOU: "Thank you for your submission!", +}; diff --git a/packages/stack/src/plugins/form-builder/client/localization/form-builder-submissions.ts b/packages/stack/src/plugins/form-builder/client/localization/form-builder-submissions.ts index f20e60f0..3b7264e9 100644 --- a/packages/stack/src/plugins/form-builder/client/localization/form-builder-submissions.ts +++ b/packages/stack/src/plugins/form-builder/client/localization/form-builder-submissions.ts @@ -11,7 +11,14 @@ export const FORM_BUILDER_SUBMISSIONS = { FORM_BUILDER_SUBMISSIONS_COLUMN_ACTIONS: "Actions", FORM_BUILDER_SUBMISSIONS_ACTION_VIEW: "View", FORM_BUILDER_SUBMISSIONS_ACTION_DELETE: "Delete", + FORM_BUILDER_SUBMISSIONS_DELETE_TITLE: "Delete Submission", FORM_BUILDER_SUBMISSIONS_DELETE_CONFIRM: "Are you sure you want to delete this submission?", FORM_BUILDER_SUBMISSIONS_BACK_TO_FORM: "Back to Form", + FORM_BUILDER_SUBMISSIONS_DETAILS_TITLE: "Submission Details", + FORM_BUILDER_SUBMISSIONS_FIELD_ID: "ID:", + FORM_BUILDER_SUBMISSIONS_FIELD_SUBMITTED: "Submitted:", + FORM_BUILDER_SUBMISSIONS_FIELD_IP: "IP Address:", + FORM_BUILDER_SUBMISSIONS_FIELD_USER_AGENT: "User Agent:", + FORM_BUILDER_SUBMISSIONS_FIELD_DATA: "Data:", }; diff --git a/packages/stack/src/plugins/form-builder/client/localization/form-builder-toasts.ts b/packages/stack/src/plugins/form-builder/client/localization/form-builder-toasts.ts index c8c30324..125fc3b7 100644 --- a/packages/stack/src/plugins/form-builder/client/localization/form-builder-toasts.ts +++ b/packages/stack/src/plugins/form-builder/client/localization/form-builder-toasts.ts @@ -7,4 +7,8 @@ export const FORM_BUILDER_TOASTS = { FORM_BUILDER_TOAST_VALIDATION_ERROR: "Please fix the validation errors", FORM_BUILDER_TOAST_DUPLICATE_SLUG: "A form with this slug already exists", FORM_BUILDER_TOAST_SUBMISSION_DELETED: "Submission deleted successfully", + FORM_BUILDER_TOAST_NAME_REQUIRED: "Name is required", + FORM_BUILDER_TOAST_SLUG_REQUIRED: "Slug is required", + FORM_BUILDER_TOAST_SCHEMA_REQUIRED: + "Please add at least one field to the form", }; diff --git a/packages/stack/src/plugins/form-builder/client/localization/index.ts b/packages/stack/src/plugins/form-builder/client/localization/index.ts index fd699a19..f7686c2d 100644 --- a/packages/stack/src/plugins/form-builder/client/localization/index.ts +++ b/packages/stack/src/plugins/form-builder/client/localization/index.ts @@ -3,6 +3,7 @@ import { FORM_BUILDER_TOASTS } from "./form-builder-toasts"; import { FORM_BUILDER_LIST } from "./form-builder-list"; import { FORM_BUILDER_EDITOR } from "./form-builder-editor"; import { FORM_BUILDER_SUBMISSIONS } from "./form-builder-submissions"; +import { FORM_BUILDER_RENDERER } from "./form-builder-renderer"; export const FORM_BUILDER_LOCALIZATION = { ...FORM_BUILDER_COMMON, @@ -10,6 +11,7 @@ export const FORM_BUILDER_LOCALIZATION = { ...FORM_BUILDER_LIST, ...FORM_BUILDER_EDITOR, ...FORM_BUILDER_SUBMISSIONS, + ...FORM_BUILDER_RENDERER, }; export type FormBuilderLocalization = typeof FORM_BUILDER_LOCALIZATION; diff --git a/packages/stack/src/plugins/form-builder/client/plugin.tsx b/packages/stack/src/plugins/form-builder/client/plugin.tsx index 08160f71..0a6f0764 100644 --- a/packages/stack/src/plugins/form-builder/client/plugin.tsx +++ b/packages/stack/src/plugins/form-builder/client/plugin.tsx @@ -167,7 +167,7 @@ function createFormListLoader(config: FormBuilderClientConfig) { }); const queries = createFormBuilderQueryKeys(client, headers); const limit = 20; - const listQuery = queries.forms.list({ limit, offset: 0 }); + const listQuery = queries.forms.list({ limit }); try { // Before hook - authorization check @@ -178,25 +178,9 @@ function createFormListLoader(config: FormBuilderClientConfig) { ); } - // Prefetch forms using infinite query + // Prefetch forms using infinite query (matches useSuspenseInfiniteQuery in hooks) await queryClient.prefetchInfiniteQuery({ - queryKey: listQuery.queryKey, - queryFn: async ({ pageParam = 0 }) => { - const response: unknown = await client("/forms", { - method: "GET", - query: { limit, offset: pageParam }, - headers, - }); - if ( - typeof response === "object" && - response !== null && - "error" in response && - response.error - ) { - throw new Error(String(response.error)); - } - return (response as { data?: unknown }).data; - }, + ...listQuery, initialPageParam: 0, }); @@ -347,11 +331,7 @@ function createSubmissionsLoader( const queries = createFormBuilderQueryKeys(client, headers); const limit = 20; const formQuery = queries.forms.byId(formId); - const submissionsQuery = queries.formSubmissions.list({ - formId, - limit, - offset: 0, - }); + const submissionsQuery = queries.formSubmissions.list({ formId, limit }); try { // Before hook - authorization check @@ -365,27 +345,7 @@ function createSubmissionsLoader( // Prefetch form and submissions await queryClient.prefetchQuery(formQuery); await queryClient.prefetchInfiniteQuery({ - queryKey: submissionsQuery.queryKey, - queryFn: async ({ pageParam = 0 }) => { - const response: unknown = await client( - "/forms/:formId/submissions", - { - method: "GET", - params: { formId }, - query: { limit, offset: pageParam }, - headers, - }, - ); - if ( - typeof response === "object" && - response !== null && - "error" in response && - response.error - ) { - throw new Error(String(response.error)); - } - return (response as { data?: unknown }).data; - }, + ...submissionsQuery, initialPageParam: 0, }); diff --git a/packages/stack/src/plugins/form-builder/query-keys.ts b/packages/stack/src/plugins/form-builder/query-keys.ts index 4a730010..2d32f604 100644 --- a/packages/stack/src/plugins/form-builder/query-keys.ts +++ b/packages/stack/src/plugins/form-builder/query-keys.ts @@ -1,13 +1,14 @@ -import { - mergeQueryKeys, - createQueryKeys, -} from "@lukemorales/query-key-factory"; import type { FormBuilderApiRouter } from "./api"; -import { createApiClient } from "@btst/stack/plugins/client"; +import { + createApiClient, + createResourceQueryKeys, + type ResourcesDeclaration, +} from "@btst/stack/plugins/client"; import type { SerializedForm, PaginatedForms, PaginatedFormSubmissions, + SerializedFormSubmission, SerializedFormSubmissionWithData, } from "./types"; import { @@ -15,188 +16,224 @@ import { submissionsListDiscriminator, } from "./api/query-key-defs"; -interface FormListParams { +/** Params for the paginated forms list (one page per `limit`). */ +export interface FormListParams { status?: "active" | "inactive" | "archived"; limit?: number; + /** + * Included in the query key discriminator for compatibility with + * callers that pass it explicitly; the infinite query itself injects + * the page offset per page (always starting at 0). + */ offset?: number; + /** Free-text search across form names and slugs */ + search?: string; } -interface SubmissionListParams { +/** Params for the paginated submissions list of one form. */ +export interface SubmissionListParams { formId: string; limit?: number; + /** See {@link FormListParams.offset} */ offset?: number; } -// Type guard for better-call error responses -function isErrorResponse( - response: unknown, -): response is { error: unknown; data?: never } { - if (typeof response !== "object" || response === null) { - return false; - } - const obj = response as Record; - return "error" in obj && obj.error !== null && obj.error !== undefined; +/** Input for the create-form mutation. */ +export interface CreateFormInput { + name: string; + slug: string; + description?: string; + schema: string; + successMessage?: string; + redirectUrl?: string; + status?: "active" | "inactive" | "archived"; } -// Helper to convert error to a proper Error object with meaningful message -function toError(error: unknown): Error { - if (error instanceof Error) { - return error; - } - - if (typeof error === "object" && error !== null) { - const errorObj = error as Record; - const message = - (typeof errorObj.message === "string" ? errorObj.message : null) || - (typeof errorObj.error === "string" ? errorObj.error : null) || - JSON.stringify(error); - - const err = new Error(message); - Object.assign(err, error); - return err; - } +/** Input for the update-form mutation. */ +export interface UpdateFormInput { + name?: string; + slug?: string; + description?: string; + schema?: string; + successMessage?: string; + redirectUrl?: string; + status?: "active" | "inactive" | "archived"; +} - return new Error(String(error)); +/** Normalizes an empty/whitespace search term to `undefined`. */ +function normalizeSearch(search: string | undefined): string | undefined { + return search !== undefined && search.trim() === "" ? undefined : search; } /** - * Create Form Builder query keys for React Query - * Used by consumers to fetch forms and submissions + * `getNextPageParam` for `{ items, total }` page envelopes: stop when the + * last page is short or everything is loaded, otherwise continue at the + * loaded-item offset. */ -export function createFormBuilderQueryKeys( - client: ReturnType>, - headers?: HeadersInit, -) { - const forms = createFormsQueries(client, headers); - const submissions = createSubmissionsQueries(client, headers); - - return mergeQueryKeys(forms, submissions); +function paginatedNextPageParam( + lastPage: { items?: unknown[]; total?: number }, + allPages: { items?: unknown[]; total?: number }[], + limit: number, +): number | undefined { + const items = Array.isArray(lastPage?.items) ? lastPage.items : []; + if (items.length < limit) return undefined; + const loadedCount = allPages.reduce( + (sum, page) => sum + (Array.isArray(page?.items) ? page.items.length : 0), + 0, + ); + const total = lastPage?.total ?? 0; + if (loadedCount >= total) return undefined; + return loadedCount; } -function createFormsQueries( - client: ReturnType>, - headers?: HeadersInit, -) { - return createQueryKeys("forms", { - list: (params: FormListParams = {}) => ({ - queryKey: ["list", formsListDiscriminator(params)], - queryFn: async () => { - try { - const response: unknown = await client("/forms", { - method: "GET", - query: { - status: params.status, - limit: params.limit ?? 20, - offset: params.offset ?? 0, - }, - headers, - }); - if (isErrorResponse(response)) { - throw toError(response.error); - } - return (response as { data?: unknown }).data as PaginatedForms; - } catch (error) { - throw error; - } +/** + * Form Builder resource declaration — the single source of truth for query + * keys, HTTP mappings and mutations. Feeds both `createFormBuilderQueryKeys` + * (SSR loaders) and `createResource` (client hooks, see `client/hooks`). + * + * Key shapes intentionally match `FORM_QUERY_KEYS` in + * `api/query-key-defs.ts` so SSG `prefetchForRoute` hydration keeps working. + * List pages stay as `{ items, total, limit, offset }` envelopes (via + * `nextPageParam`) so `total` survives SSG dehydration. + */ +export const formBuilderResources = { + forms: { + queries: { + list: { + path: "/forms", + query: (p: FormListParams) => ({ + status: p.status, + limit: p.limit ?? 20, + search: normalizeSearch(p.search), + }), + key: (p: FormListParams) => ["list", formsListDiscriminator(p)], + select: (data: any, _p: FormListParams): PaginatedForms => data, + infinite: true, + pageSize: (p: FormListParams) => p.limit ?? 20, + nextPageParam: ( + lastPage: PaginatedForms, + allPages: PaginatedForms[], + p: FormListParams, + ) => paginatedNextPageParam(lastPage, allPages, p.limit ?? 20), }, - }), - bySlug: (slug: string) => ({ - queryKey: ["bySlug", slug], - queryFn: async () => { - if (!slug) return null; - - try { - const response: unknown = await client("/forms/:slug", { - method: "GET", - params: { slug }, - headers, - }); - if (isErrorResponse(response)) { - throw toError(response.error); - } - return (response as { data?: unknown }).data as SerializedForm | null; - } catch (error) { - throw error; - } + bySlug: { + path: "/forms/:slug", + params: (slug: string) => ({ slug }), + key: (slug: string) => ["bySlug", slug], + select: (data: any, _slug: string): SerializedForm | null => + data ?? null, + skip: (slug: string) => !slug, }, - }), - byId: (id: string) => ({ - queryKey: ["byId", id], - queryFn: async () => { - if (!id) return null; + byId: { + path: "/forms/id/:id", + params: (id: string) => ({ id }), + key: (id: string) => ["byId", id], + select: (data: any, _id: string): SerializedForm | null => data ?? null, + skip: (id: string) => !id, + }, + }, + + mutations: { + create: { + path: "@post/forms", + method: "POST" as const, + input: (vars: CreateFormInput) => ({ body: vars }), + select: (data: any) => data as SerializedForm, + invalidates: ["forms"], + }, + update: { + path: "@put/forms/:id", + method: "PUT" as const, + input: (vars: { id: string; data: UpdateFormInput }) => ({ + params: { id: vars.id }, + body: vars.data, + }), + select: (data: any) => data as SerializedForm, + invalidates: ["forms"], + setData: { + query: "byId", + args: (updated: SerializedForm) => (updated ? [updated.id] : null), + }, + }, + delete: { + path: "@delete/forms/:id", + method: "DELETE" as const, + input: (id: string) => ({ params: { id } }), + select: (data: any) => data as { success: boolean }, + invalidates: ["forms"], + }, + // Public form submission — no cache invalidation needed + submit: { + path: "@post/forms/:slug/submit", + method: "POST" as const, + input: (vars: { slug: string; data: Record }) => ({ + params: { slug: vars.slug }, + body: { data: vars.data }, + }), + select: (data: any) => + data as SerializedFormSubmission & { + form: { successMessage?: string; redirectUrl?: string }; + }, + }, + }, + }, + + formSubmissions: { + queries: { + list: { + path: "/forms/:formId/submissions", + params: (p: SubmissionListParams) => ({ formId: p.formId }), + query: (p: SubmissionListParams) => ({ limit: p.limit ?? 20 }), + key: (p: SubmissionListParams) => [submissionsListDiscriminator(p)], + select: ( + data: any, + _p: SubmissionListParams, + ): PaginatedFormSubmissions => data, + infinite: true, + pageSize: (p: SubmissionListParams) => p.limit ?? 20, + nextPageParam: ( + lastPage: PaginatedFormSubmissions, + allPages: PaginatedFormSubmissions[], + p: SubmissionListParams, + ) => paginatedNextPageParam(lastPage, allPages, p.limit ?? 20), + }, - try { - const response: unknown = await client("/forms/id/:id", { - method: "GET", - params: { id }, - headers, - }); - if (isErrorResponse(response)) { - throw toError(response.error); - } - return (response as { data?: unknown }).data as SerializedForm | null; - } catch (error) { - throw error; - } + detail: { + path: "/forms/:formId/submissions/:subId", + params: (formId: string, subId: string) => ({ formId, subId }), + key: (formId: string, subId: string) => [formId, subId], + select: ( + data: any, + _formId: string, + _subId: string, + ): SerializedFormSubmissionWithData | null => data ?? null, + skip: (formId: string, subId: string) => !formId || !subId, }, - }), - }); -} + }, + + mutations: { + delete: { + path: "@delete/forms/:formId/submissions/:subId", + method: "DELETE" as const, + input: (vars: { formId: string; subId: string }) => ({ + params: { formId: vars.formId, subId: vars.subId }, + }), + select: (data: any) => data as { success: boolean }, + invalidates: ["formSubmissions"], + }, + }, + }, +} satisfies ResourcesDeclaration; -function createSubmissionsQueries( +/** + * Create Form Builder query keys for React Query + * Used by consumers and SSR loaders to fetch forms and submissions + */ +export function createFormBuilderQueryKeys( client: ReturnType>, headers?: HeadersInit, ) { - return createQueryKeys("formSubmissions", { - list: (params: SubmissionListParams) => ({ - queryKey: [submissionsListDiscriminator(params)], - queryFn: async () => { - try { - const response: unknown = await client("/forms/:formId/submissions", { - method: "GET", - params: { formId: params.formId }, - query: { - limit: params.limit ?? 20, - offset: params.offset ?? 0, - }, - headers, - }); - if (isErrorResponse(response)) { - throw toError(response.error); - } - return (response as { data?: unknown }) - .data as PaginatedFormSubmissions; - } catch (error) { - throw error; - } - }, - }), - - detail: (formId: string, subId: string) => ({ - queryKey: [formId, subId], - queryFn: async () => { - if (!formId || !subId) return null; - - try { - const response: unknown = await client( - "/forms/:formId/submissions/:subId", - { - method: "GET", - params: { formId, subId }, - headers, - }, - ); - if (isErrorResponse(response)) { - throw toError(response.error); - } - return (response as { data?: unknown }) - .data as SerializedFormSubmissionWithData | null; - } catch (error) { - throw error; - } - }, - }), - }); + return createResourceQueryKeys(client, formBuilderResources, headers); } diff --git a/packages/stack/src/plugins/form-builder/schemas.ts b/packages/stack/src/plugins/form-builder/schemas.ts index d2135e81..9a32a72f 100644 --- a/packages/stack/src/plugins/form-builder/schemas.ts +++ b/packages/stack/src/plugins/form-builder/schemas.ts @@ -1,12 +1,19 @@ import { z } from "zod"; /** - * Schema for listing forms with pagination + * Cap on the DB scan when free-text search forces the in-memory filter in + * `getAllForms`, bounding server memory use. + */ +export const DEFAULT_MAX_PAGE_SIZE = 1000; + +/** + * Schema for listing forms with pagination and free-text search */ export const listFormsQuerySchema = z.object({ status: z.enum(["active", "inactive", "archived"]).optional(), limit: z.coerce.number().min(1).max(100).optional().default(20), offset: z.coerce.number().min(0).optional().default(0), + search: z.string().max(200).optional(), }); /** From 9806b4ffcafcd2731b668979c3d45b0cefec8e8c Mon Sep 17 00:00:00 2001 From: olliethedev <5933733+olliethedev@users.noreply.github.com> Date: Tue, 18 Aug 2026 22:07:48 +0000 Subject: [PATCH 2/2] fix(core): let mutations opt out of router refresh; keep public form submit client-side The resource mutation hook calls the plugin `refresh` override after every successful mutation. On public form pages that override is a full page reload, which remounted FormRenderer and wiped the client-side success screen right after submission (caught by the codegen E2E public-submission spec). Add `refresh?: boolean` to ResourceMutationDef (default true) and declare `refresh: false` on the public submit mutation. Co-authored-by: Cursor --- .../src/__tests__/resource-factory.test.tsx | 27 +++++++++++++++++++ .../src/plugins/client/resource/internal.ts | 5 ++-- .../src/plugins/client/resource/queries.ts | 6 +++++ .../src/plugins/form-builder/query-keys.ts | 5 +++- 4 files changed, 40 insertions(+), 3 deletions(-) diff --git a/packages/stack/src/__tests__/resource-factory.test.tsx b/packages/stack/src/__tests__/resource-factory.test.tsx index 240c517e..ae49506f 100644 --- a/packages/stack/src/__tests__/resource-factory.test.tsx +++ b/packages/stack/src/__tests__/resource-factory.test.tsx @@ -102,6 +102,15 @@ const resources = { select: (data: any) => data as { success: boolean }, invalidates: ["items"], }, + // Public-style mutation: success UI is client state, so it must not + // trigger the router refresh (a full reload on public pages) + submit: { + path: "@post/items/:id/submit", + method: "POST" as const, + input: (vars: { id: string }) => ({ params: { id: vars.id } }), + select: (data: any) => data as { success: boolean }, + refresh: false, + }, }, }, } satisfies ResourcesDeclaration; @@ -516,6 +525,24 @@ describe("createResource hooks", () => { expect(queryClient.getQueryState(detailKey)?.isInvalidated).toBe(true); }); + it("mutations with refresh: false skip the router refresh", async () => { + fetchMock.mockResolvedValue(jsonResponse({ success: true })); + + let captured: any; + function Probe() { + captured = items.items.submit.use(); + return null; + } + await render(); + + await act(async () => { + await captured.mutateAsync({ id: "7" }); + }); + + expect(captured.isSuccess).toBe(true); + expect(refresh).not.toHaveBeenCalled(); + }); + it("mutations reject with a normalized StackError", async () => { fetchMock.mockResolvedValue( jsonResponse( diff --git a/packages/stack/src/plugins/client/resource/internal.ts b/packages/stack/src/plugins/client/resource/internal.ts index 5b59db0f..7b4bb2ae 100644 --- a/packages/stack/src/plugins/client/resource/internal.ts +++ b/packages/stack/src/plugins/client/resource/internal.ts @@ -101,8 +101,9 @@ export function useResourceMutationForDef( }); } - // Refresh server-side cache (e.g. Next.js router cache) - if (refresh) { + // Refresh server-side cache (e.g. Next.js router cache) unless the + // mutation opts out (public mutations whose success UI is client state) + if (refresh && def.refresh !== false) { await refresh(); } }, diff --git a/packages/stack/src/plugins/client/resource/queries.ts b/packages/stack/src/plugins/client/resource/queries.ts index 4d2a1a05..94d8bd37 100644 --- a/packages/stack/src/plugins/client/resource/queries.ts +++ b/packages/stack/src/plugins/client/resource/queries.ts @@ -104,6 +104,12 @@ export interface ResourceMutationDef { query?: string; args: (result: TResult) => readonly unknown[] | null; }; + /** + * Whether to call the router `refresh` override after success (default + * `true`). Set `false` for mutations that must not reload server-rendered + * state — e.g. public submissions whose success UI lives in client state. + */ + refresh?: boolean; } /** Declaration for one resource: its queries and (optionally) mutations. */ diff --git a/packages/stack/src/plugins/form-builder/query-keys.ts b/packages/stack/src/plugins/form-builder/query-keys.ts index 2d32f604..7fdae0c6 100644 --- a/packages/stack/src/plugins/form-builder/query-keys.ts +++ b/packages/stack/src/plugins/form-builder/query-keys.ts @@ -164,7 +164,9 @@ export const formBuilderResources = { select: (data: any) => data as { success: boolean }, invalidates: ["forms"], }, - // Public form submission — no cache invalidation needed + // Public form submission — no cache invalidation, and no router + // refresh: the success screen is client state in FormRenderer and a + // refresh (full reload on public pages) would wipe it. submit: { path: "@post/forms/:slug/submit", method: "POST" as const, @@ -176,6 +178,7 @@ export const formBuilderResources = { data as SerializedFormSubmission & { form: { successMessage?: string; redirectUrl?: string }; }, + refresh: false, }, }, },