diff --git a/.changeset/config.json b/.changeset/config.json index 9893a31..09d1aad 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -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", diff --git a/.changeset/loading-error-slots.md b/.changeset/loading-error-slots.md new file mode 100644 index 0000000..c94b4ec --- /dev/null +++ b/.changeset/loading-error-slots.md @@ -0,0 +1,5 @@ +--- +"@formrelay/vue": minor +--- + +Add optional `#loading` and `#error` named slots to the `` 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. diff --git a/packages/vue/src/components/FormRelay.test.ts b/packages/vue/src/components/FormRelay.test.ts index a6849db..f34dbc6 100644 --- a/packages/vue/src/components/FormRelay.test.ts +++ b/packages/vue/src/components/FormRelay.test.ts @@ -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", @@ -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(""); + }); }); diff --git a/packages/vue/src/components/FormRelay.ts b/packages/vue/src/components/FormRelay.ts index fb7405c..9f93f0d 100644 --- a/packages/vue/src/components/FormRelay.ts +++ b/packages/vue/src/components/FormRelay.ts @@ -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({