Skip to content
Merged
12 changes: 12 additions & 0 deletions .changeset/nuxt-formrelay-component.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@formrelay/vue": minor
"@formrelay/nuxt": minor
---

Make `publicKey` optional on the `<FormRelay>` 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 `<FormRelay>` 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 `<FormRelay>` 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.
2 changes: 1 addition & 1 deletion packages/nuxt/src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export default defineNuxtModule<ModuleOptions>({
const resolver = createResolver(import.meta.url);

nuxt.options.runtimeConfig.public.formrelay = {
publicKey: options.publicKey ?? "",
publicKey: options.publicKey,
};

if (options.secretKey) {
Expand Down
45 changes: 44 additions & 1 deletion packages/nuxt/src/runtime/components/FormRelay.ts
Original file line number Diff line number Diff line change
@@ -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<Ref<HTMLElement | null>>,
default: undefined,
},
validate: {
type: Function as PropType<
(data: Record<string, unknown>, schema: JsonSchema) => Record<string, string[]>
>,
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);
},
});
33 changes: 19 additions & 14 deletions packages/nuxt/src/runtime/composables/useFormRelay.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -36,7 +36,7 @@ function createSecretKeyAdapter(secretKey: string): HttpAdapter {
export async function useFormRelay(options: Partial<UseFormRelayOptions> & { formId: string }) {
const runtimeConfig = useRuntimeConfig();
const config = runtimeConfig.public.formrelay as {
publicKey: string;
publicKey?: string;
};

const secretKey = (runtimeConfig as Record<string, unknown>).formrelaySecretKey as
Expand All @@ -51,23 +51,28 @@ export async function useFormRelay(options: Partial<UseFormRelayOptions> & { 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<FormSchema | null> | 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,
Expand Down
84 changes: 83 additions & 1 deletion packages/vue/src/components/FormRelay.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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<HTMLElement | null>(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([]);
});
});
71 changes: 33 additions & 38 deletions packages/vue/src/components/FormRelay.ts
Original file line number Diff line number Diff line change
@@ -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<FormSchema>, default: undefined },
botProtectionContainer: {
type: Object as PropType<Ref<HTMLElement | null>>,
default: undefined,
},
validate: {
type: Function as PropType<
(data: Record<string, unknown>, schema: JsonSchema) => Record<string, string[]>
>,
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);
},
});
34 changes: 34 additions & 0 deletions packages/vue/src/components/renderFormRelay.ts
Original file line number Diff line number Diff line change
@@ -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,
});
}
41 changes: 41 additions & 0 deletions packages/vue/src/composables/useFormRelay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
Loading
Loading