diff --git a/.changeset/nuxt-formrelay-component.md b/.changeset/nuxt-formrelay-component.md new file mode 100644 index 0000000..140b005 --- /dev/null +++ b/.changeset/nuxt-formrelay-component.md @@ -0,0 +1,12 @@ +--- +"@formrelay/vue": minor +"@formrelay/nuxt": minor +--- + +Make `publicKey` optional on the `` component and `useFormRelay` composable. When omitted, the schema fetch is skipped and the form renders immediately with empty state for manual form building. + +Add `initialSchema` and `botProtectionContainer` as optional props on the Vue `` component, matching features already available on the composable. All props now use `PropType` for proper template-level type safety. + +Extract shared `renderFormRelay()` helper for consistent slot rendering across packages. + +The Nuxt `` component is now an async component wrapping the Nuxt `useFormRelay` composable, providing SSR schema prefetch, automatic `publicKey` injection from runtime config, and secret key support. Only `formId` is required. The Nuxt composable now correctly skips the schema fetch when no `publicKey` is configured. diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index 9d20df0..64a2ddc 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -15,7 +15,7 @@ export default defineNuxtModule({ const resolver = createResolver(import.meta.url); nuxt.options.runtimeConfig.public.formrelay = { - publicKey: options.publicKey ?? "", + publicKey: options.publicKey, }; if (options.secretKey) { diff --git a/packages/nuxt/src/runtime/components/FormRelay.ts b/packages/nuxt/src/runtime/components/FormRelay.ts index 048366b..7a98962 100644 --- a/packages/nuxt/src/runtime/components/FormRelay.ts +++ b/packages/nuxt/src/runtime/components/FormRelay.ts @@ -1 +1,44 @@ -export { FormRelay as default } from "@formrelay/vue"; +import { defineComponent, toRef, type PropType, type Ref } from "vue"; +import { renderFormRelay } from "@formrelay/vue"; +import type { FormRelayError, JsonSchema } from "@formrelay/core"; +import { useFormRelay } from "../composables/useFormRelay"; + +// No initialSchema prop — the Nuxt composable handles SSR schema +// prefetch internally via useAsyncData. +export default defineComponent({ + name: "FormRelay", + props: { + formId: { type: String, required: true }, + publicKey: { type: String, default: undefined }, + botProtectionContainer: { + type: Object as PropType>, + default: undefined, + }, + validate: { + type: Function as PropType< + (data: Record, schema: JsonSchema) => Record + >, + default: undefined, + }, + onSuccess: { + type: Function as PropType<(result: { message: string }) => void>, + default: undefined, + }, + onError: { + type: Function as PropType<(error: FormRelayError) => void>, + default: undefined, + }, + }, + async setup(props, { slots }) { + const state = await useFormRelay({ + formId: props.formId, + publicKey: props.publicKey, + botProtectionContainer: toRef(props, "botProtectionContainer"), + validate: props.validate, + onSuccess: props.onSuccess, + onError: props.onError, + }); + + return () => renderFormRelay(state, slots); + }, +}); diff --git a/packages/nuxt/src/runtime/composables/useFormRelay.ts b/packages/nuxt/src/runtime/composables/useFormRelay.ts index 61f558b..2db6ac1 100644 --- a/packages/nuxt/src/runtime/composables/useFormRelay.ts +++ b/packages/nuxt/src/runtime/composables/useFormRelay.ts @@ -1,8 +1,8 @@ -import { effectScope, onScopeDispose } from "vue"; +import { effectScope, onScopeDispose, type Ref } from "vue"; import { useFormRelay as useVueFormRelay } from "@formrelay/vue"; import type { UseFormRelayOptions } from "@formrelay/vue"; import { createForm } from "@formrelay/core"; -import type { HttpAdapter, HttpResponse, RequestOptions } from "@formrelay/core"; +import type { FormSchema, HttpAdapter, HttpResponse, RequestOptions } from "@formrelay/core"; import { useRuntimeConfig, useAsyncData } from "#imports"; function createSecretKeyAdapter(secretKey: string): HttpAdapter { @@ -36,7 +36,7 @@ function createSecretKeyAdapter(secretKey: string): HttpAdapter { export async function useFormRelay(options: Partial & { formId: string }) { const runtimeConfig = useRuntimeConfig(); const config = runtimeConfig.public.formrelay as { - publicKey: string; + publicKey?: string; }; const secretKey = (runtimeConfig as Record).formrelaySecretKey as @@ -51,23 +51,28 @@ export async function useFormRelay(options: Partial & { for const scope = effectScope(); onScopeDispose(() => scope.stop()); - const schemaClient = - import.meta.server && secretKey - ? createForm(options.formId, { - publicKey, - httpClient: createSecretKeyAdapter(secretKey), - }) - : createForm(options.formId, { publicKey }); + let initialSchema: Ref | undefined; - const { data: initialSchema } = await useAsyncData(`formrelay-schema-${options.formId}`, () => - schemaClient.getSchema(), - ); + if (publicKey) { + const schemaClient = + import.meta.server && secretKey + ? createForm(options.formId, { + publicKey, + httpClient: createSecretKeyAdapter(secretKey), + }) + : createForm(options.formId, { publicKey }); + + const asyncData = await useAsyncData(`formrelay-schema-${options.formId}`, () => + schemaClient.getSchema(), + ); + initialSchema = asyncData.data; + } return scope.run(() => useVueFormRelay({ formId: options.formId, publicKey, - initialSchema: initialSchema.value ?? undefined, + initialSchema: initialSchema?.value ?? undefined, botProtectionContainer: options.botProtectionContainer, validate: options.validate, onSuccess: options.onSuccess, diff --git a/packages/vue/src/components/FormRelay.test.ts b/packages/vue/src/components/FormRelay.test.ts index f34dbc6..53bb803 100644 --- a/packages/vue/src/components/FormRelay.test.ts +++ b/packages/vue/src/components/FormRelay.test.ts @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { describe, expect, test, vi } from "vitest"; import { mount, flushPromises } from "@vue/test-utils"; -import { h, nextTick } from "vue"; +import { h, nextTick, ref } from "vue"; import FormRelay from "./FormRelay"; import { FormRelayError } from "@formrelay/core"; @@ -271,4 +271,86 @@ describe("FormRelay", () => { expect(wrapper.html()).toBe(""); }); + + test("uses initialSchema prop and skips fetch", async () => { + const { createForm } = await import("@formrelay/core"); + const mockGetSchema = vi.fn(); + vi.mocked(createForm).mockReturnValueOnce({ + getSchema: mockGetSchema, + submit: vi.fn().mockResolvedValue({ success: true, message: "OK" }), + } as any); + + let slotProps: any; + + mount(FormRelay, { + props: { formId: "01abc", publicKey: "pk_fr_test", initialSchema: mockSchema }, + slots: { + default: (props: any) => { + slotProps = props; + return h("div"); + }, + }, + }); + + await flushPromises(); + await nextTick(); + + expect(mockGetSchema).not.toHaveBeenCalled(); + expect(slotProps.schemaLoading).toBe(false); + expect(slotProps.fields).toHaveLength(1); + }); + + test("forwards botProtectionContainer as reactive ref to composable", async () => { + const containerRef = ref(null); + + const mockSchemaWithBot = { + ...mockSchema, + botProtection: { type: "turnstile" as const, siteKey: "0x-key" }, + }; + + const { createForm } = await import("@formrelay/core"); + vi.mocked(createForm).mockReturnValueOnce({ + getSchema: vi.fn().mockResolvedValue(mockSchemaWithBot), + submit: vi.fn().mockResolvedValue({ success: true, message: "OK" }), + } as any); + + let slotProps: any; + + mount(FormRelay, { + props: { + formId: "01abc", + publicKey: "pk_fr_test", + botProtectionContainer: containerRef, + }, + slots: { + default: (props: any) => { + slotProps = props; + return h("div"); + }, + }, + }); + + await flushPromises(); + await nextTick(); + + expect(slotProps.botProtection).toEqual({ type: "turnstile", siteKey: "0x-key" }); + }); + + test("renders default slot immediately when publicKey is omitted", () => { + let slotProps: any; + + mount(FormRelay, { + props: { formId: "01abc" }, + slots: { + default: (props: any) => { + slotProps = props; + return h("div"); + }, + }, + }); + + expect(slotProps).toBeDefined(); + expect(slotProps.schemaLoading).toBe(false); + expect(slotProps.fields).toEqual([]); + }); }); diff --git a/packages/vue/src/components/FormRelay.ts b/packages/vue/src/components/FormRelay.ts index 9f93f0d..f6e1113 100644 --- a/packages/vue/src/components/FormRelay.ts +++ b/packages/vue/src/components/FormRelay.ts @@ -1,49 +1,44 @@ -import { defineComponent } from "vue"; +import { defineComponent, toRef, type PropType, type Ref } from "vue"; import { useFormRelay } from "../composables/useFormRelay"; -import type { UseFormRelayOptions } from "../types"; +import { renderFormRelay } from "./renderFormRelay"; +import type { FormRelayError, FormSchema, JsonSchema } from "@formrelay/core"; export default defineComponent({ name: "FormRelay", props: { formId: { type: String, required: true }, - publicKey: { type: String, required: true }, - validate: { type: Function, default: undefined }, - onSuccess: { type: Function, default: undefined }, - onError: { type: Function, default: undefined }, + publicKey: { type: String, default: undefined }, + initialSchema: { type: Object as PropType, default: undefined }, + botProtectionContainer: { + type: Object as PropType>, + default: undefined, + }, + validate: { + type: Function as PropType< + (data: Record, schema: JsonSchema) => Record + >, + default: undefined, + }, + onSuccess: { + type: Function as PropType<(result: { message: string }) => void>, + default: undefined, + }, + onError: { + type: Function as PropType<(error: FormRelayError) => void>, + default: undefined, + }, }, setup(props, { slots }) { - const state = useFormRelay(props as unknown as UseFormRelayOptions); + const state = useFormRelay({ + formId: props.formId, + publicKey: props.publicKey, + initialSchema: props.initialSchema, + botProtectionContainer: toRef(props, "botProtectionContainer"), + validate: props.validate, + onSuccess: props.onSuccess, + onError: props.onError, + }); - return () => { - if (state.schemaLoading.value && !state.schemaError.value && slots.loading) { - return slots.loading(); - } - - if (state.schemaError.value && slots.error) { - return slots.error({ - error: state.schemaError.value, - }); - } - - if (!slots.default) return null; - - return slots.default({ - schema: state.schema.value, - columns: state.columns.value, - fields: state.fields.value, - schemaLoading: state.schemaLoading.value, - schemaError: state.schemaError.value, - values: state.values, - errors: state.errors.value, - submitting: state.submitting.value, - submitted: state.submitted.value, - canSubmit: state.canSubmit.value, - submit: state.submit, - reset: state.reset, - setBotToken: state.setBotToken, - validationSchema: state.validationSchema.value, - botProtection: state.botProtection.value, - }); - }; + return () => renderFormRelay(state, slots); }, }); diff --git a/packages/vue/src/components/renderFormRelay.ts b/packages/vue/src/components/renderFormRelay.ts new file mode 100644 index 0000000..f8b8ab6 --- /dev/null +++ b/packages/vue/src/components/renderFormRelay.ts @@ -0,0 +1,34 @@ +import type { Slots, VNode } from "vue"; +import type { UseFormRelayReturn } from "../types"; + +export function renderFormRelay(state: UseFormRelayReturn, slots: Slots): VNode | VNode[] | null { + if (state.schemaLoading.value && !state.schemaError.value && slots.loading) { + return slots.loading(); + } + + if (state.schemaError.value && slots.error) { + return slots.error({ + error: state.schemaError.value, + }); + } + + if (!slots.default) return null; + + return slots.default({ + schema: state.schema.value, + columns: state.columns.value, + fields: state.fields.value, + schemaLoading: state.schemaLoading.value, + schemaError: state.schemaError.value, + values: state.values, + errors: state.errors.value, + submitting: state.submitting.value, + submitted: state.submitted.value, + canSubmit: state.canSubmit.value, + submit: state.submit, + reset: state.reset, + setBotToken: state.setBotToken, + validationSchema: state.validationSchema.value, + botProtection: state.botProtection.value, + }); +} diff --git a/packages/vue/src/composables/useFormRelay.test.ts b/packages/vue/src/composables/useFormRelay.test.ts index f4982c0..2808427 100644 --- a/packages/vue/src/composables/useFormRelay.test.ts +++ b/packages/vue/src/composables/useFormRelay.test.ts @@ -449,6 +449,47 @@ describe("useFormRelay", () => { { botToken: "new-token" }, ); }); + + test("skips schema fetch when publicKey is not provided", async () => { + const { schema, schemaLoading, fields, values } = useFormRelay({ + formId: "01abc", + }); + + await nextTick(); + await nextTick(); + + expect(mockGetSchema).not.toHaveBeenCalled(); + expect(schemaLoading.value).toBe(false); + expect(schema.value).toBeNull(); + expect(fields.value).toEqual([]); + expect(Object.keys(values)).toEqual([]); + }); + + test("submit is a no-op when no schema is available", async () => { + const { submit, values, submitting } = useFormRelay({ + formId: "01abc", + }); + + values.email = "john@example.com"; + await submit(); + + expect(mockSubmit).not.toHaveBeenCalled(); + expect(submitting.value).toBe(false); + }); + + test("uses initialSchema without publicKey for display-only rendering", () => { + const { schema, schemaLoading, fields, values } = useFormRelay({ + formId: "01abc", + initialSchema: mockSchema, + }); + + expect(mockGetSchema).not.toHaveBeenCalled(); + expect(schemaLoading.value).toBe(false); + expect(schema.value).toEqual(mockSchema); + expect(fields.value).toHaveLength(2); + expect(values.email).toBe(""); + expect(values.name).toBe(""); + }); }); describe("auto bot protection", () => { diff --git a/packages/vue/src/composables/useFormRelay.ts b/packages/vue/src/composables/useFormRelay.ts index 68f4e7b..9a2b735 100644 --- a/packages/vue/src/composables/useFormRelay.ts +++ b/packages/vue/src/composables/useFormRelay.ts @@ -5,12 +5,12 @@ import type { BotProtectionWidget } from "@formrelay/core/bot-protection"; import type { UseFormRelayOptions, UseFormRelayReturn } from "../types"; export function useFormRelay(options: UseFormRelayOptions): UseFormRelayReturn { - const client = createForm(options.formId, { - publicKey: options.publicKey, - }); + const client = options.publicKey + ? createForm(options.formId, { publicKey: options.publicKey }) + : null; const schema = ref(null); - const schemaLoading = ref(!options.initialSchema); + const schemaLoading = ref(!options.initialSchema && !!options.publicKey); const schemaError = ref(null); const values = reactive>({}); @@ -43,7 +43,7 @@ export function useFormRelay(options: UseFormRelayOptions): UseFormRelayReturn { if (options.initialSchema) { schema.value = options.initialSchema; initializeValues(options.initialSchema); - } else { + } else if (options.publicKey) { fetchSchema(); } @@ -52,7 +52,7 @@ export function useFormRelay(options: UseFormRelayOptions): UseFormRelayReturn { schemaError.value = null; try { - const loadedSchema = await client.getSchema(); + const loadedSchema = await client!.getSchema(); schema.value = loadedSchema; initializeValues(loadedSchema); } catch (error) { @@ -71,7 +71,7 @@ export function useFormRelay(options: UseFormRelayOptions): UseFormRelayReturn { } async function submit() { - if (!schema.value || !canSubmit.value) return; + if (!client || !schema.value || !canSubmit.value) return; errors.value = {}; diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index 5f54cb6..30f6a98 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -1,4 +1,5 @@ export { useFormRelay } from "./composables/useFormRelay"; export { default as FormRelay } from "./components/FormRelay"; export { default as FormRelayGrid } from "./components/FormRelayGrid"; +export { renderFormRelay } from "./components/renderFormRelay"; export type { UseFormRelayOptions, UseFormRelayReturn } from "./types"; diff --git a/packages/vue/src/types.ts b/packages/vue/src/types.ts index 4d567f8..355aa03 100644 --- a/packages/vue/src/types.ts +++ b/packages/vue/src/types.ts @@ -9,7 +9,7 @@ import type { ComputedRef, Ref } from "vue"; export interface UseFormRelayOptions { formId: string; - publicKey: string; + publicKey?: string; initialSchema?: FormSchema; botProtectionContainer?: Ref; validate?: (data: Record, schema: JsonSchema) => Record;