Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
"$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [["@formrelay/*"]],
"fixed": [["@formrelay/*"]],
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
Expand Down
5 changes: 5 additions & 0 deletions .changeset/loading-error-slots.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@formrelay/vue": minor
---

Add optional `#loading` and `#error` named slots to the `<FormRelay>` component. The `#loading` slot renders while the schema is being fetched, and the `#error` slot renders when the schema fetch fails (with `{ error }` as slot props). Both are fully backwards compatible — when omitted, the default slot receives `schemaLoading` and `schemaError` as before.
171 changes: 171 additions & 0 deletions packages/vue/src/components/FormRelay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { describe, expect, test, vi } from "vitest";
import { mount, flushPromises } from "@vue/test-utils";
import { h, nextTick } from "vue";
import FormRelay from "./FormRelay";
import { FormRelayError } from "@formrelay/core";

const mockSchema = {
id: "01abc",
Expand Down Expand Up @@ -100,4 +101,174 @@ describe("FormRelay", () => {
expect(wrapper.find("#test-content").exists()).toBe(true);
expect(wrapper.find("#test-content").text()).toBe("hello");
});

test("renders loading slot while schema is loading", () => {
const wrapper = mount(FormRelay, {
props: { formId: "01abc", publicKey: "pk_fr_test" },
slots: {
loading: () => h("div", { id: "loading" }, "Loading..."),
default: () => h("div", { id: "form" }, "form content"),
},
});

expect(wrapper.find("#loading").exists()).toBe(true);
expect(wrapper.find("#form").exists()).toBe(false);
});

test("renders default slot after schema loads when loading slot is provided", async () => {
const wrapper = mount(FormRelay, {
props: { formId: "01abc", publicKey: "pk_fr_test" },
slots: {
loading: () => h("div", { id: "loading" }, "Loading..."),
default: () => h("div", { id: "form" }, "form content"),
},
});

await flushPromises();
await nextTick();

expect(wrapper.find("#loading").exists()).toBe(false);
expect(wrapper.find("#form").exists()).toBe(true);
});

test("renders default slot during loading when no loading slot is provided", () => {
let slotProps: any;

mount(FormRelay, {
props: { formId: "01abc", publicKey: "pk_fr_test" },
slots: {
default: (props: any) => {
slotProps = props;
return h("div");
},
},
});

expect(slotProps).toBeDefined();
expect(slotProps.schemaLoading).toBe(true);
});

test("renders error slot when schema fetch fails", async () => {
const { createForm } = await import("@formrelay/core");
vi.mocked(createForm).mockReturnValueOnce({
getSchema: vi.fn().mockRejectedValue(new Error("Network error")),
submit: vi.fn(),
} as any);

const wrapper = mount(FormRelay, {
props: { formId: "01abc", publicKey: "pk_fr_test" },
slots: {
error: (props: any) => h("div", { id: "error" }, props.error.detail),
default: () => h("div", { id: "form" }, "form content"),
},
});

await flushPromises();
await nextTick();

expect(wrapper.find("#error").exists()).toBe(true);
expect(wrapper.find("#error").text()).toBe("Network error");
expect(wrapper.find("#form").exists()).toBe(false);
});

test("renders default slot with schemaError when no error slot is provided", async () => {
const { createForm } = await import("@formrelay/core");
vi.mocked(createForm).mockReturnValueOnce({
getSchema: vi.fn().mockRejectedValue(new Error("Network error")),
submit: vi.fn(),
} as any);

let slotProps: any;

mount(FormRelay, {
props: { formId: "01abc", publicKey: "pk_fr_test" },
slots: {
default: (props: any) => {
slotProps = props;
return h("div");
},
},
});

await flushPromises();
await nextTick();

expect(slotProps.schemaError).toBeDefined();
expect(slotProps.schemaError.detail).toBe("Network error");
});

test("transitions from loading slot to error slot when fetch fails", async () => {
const { createForm } = await import("@formrelay/core");
vi.mocked(createForm).mockReturnValueOnce({
getSchema: vi.fn().mockRejectedValue(new Error("Network error")),
submit: vi.fn(),
} as any);

const wrapper = mount(FormRelay, {
props: { formId: "01abc", publicKey: "pk_fr_test" },
slots: {
loading: () => h("div", { id: "loading" }, "Loading..."),
error: (props: any) => h("div", { id: "error" }, props.error.detail),
default: () => h("div", { id: "form" }, "form content"),
},
});

expect(wrapper.find("#loading").exists()).toBe(true);
expect(wrapper.find("#error").exists()).toBe(false);

await flushPromises();
await nextTick();

expect(wrapper.find("#loading").exists()).toBe(false);
expect(wrapper.find("#error").exists()).toBe(true);
expect(wrapper.find("#error").text()).toBe("Network error");
expect(wrapper.find("#form").exists()).toBe(false);
});

test("renders error slot with FormRelayError passed through directly", async () => {
const schemaError = new FormRelayError({
type: "https://formrelay.app/errors#not-found",
title: "Not Found",
status: 404,
detail: "Form not found",
});

const { createForm } = await import("@formrelay/core");
vi.mocked(createForm).mockReturnValueOnce({
getSchema: vi.fn().mockRejectedValue(schemaError),
submit: vi.fn(),
} as any);

let errorProps: any;

const wrapper = mount(FormRelay, {
props: { formId: "01abc", publicKey: "pk_fr_test" },
slots: {
error: (props: any) => {
errorProps = props;
return h("div", { id: "error" }, props.error.detail);
},
default: () => h("div", { id: "form" }),
},
});

await flushPromises();
await nextTick();

expect(wrapper.find("#error").exists()).toBe(true);
expect(errorProps.error).toBe(schemaError);
expect(errorProps.error.status).toBe(404);
expect(errorProps.error.title).toBe("Not Found");
});

test("renders nothing when no slots are provided", async () => {
const wrapper = mount(FormRelay, {
props: { formId: "01abc", publicKey: "pk_fr_test" },
});

await flushPromises();
await nextTick();

expect(wrapper.html()).toBe("");
});
});
10 changes: 10 additions & 0 deletions packages/vue/src/components/FormRelay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,16 @@ export default defineComponent({
const state = useFormRelay(props as unknown as UseFormRelayOptions);

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({
Expand Down
Loading