From ac1aef3b48a113284ffd3ebd473574d1ab2139e3 Mon Sep 17 00:00:00 2001 From: Philipp Reinking Date: Tue, 13 Oct 2026 02:01:04 +0200 Subject: [PATCH 01/16] feat: show the end screen as the last block of the document Headline, text and button are edited in place and saved through the same form update as the Settings page, after a 1 s pause or when focus leaves the block. While the form redirects, the block only names the link and points to Settings. Co-Authored-By: Claude Opus 5.5 --- resources/js/app.ts | 2 + .../Factory/Document/EndScreenBlock.test.ts | 223 +++++++++++++++++ .../Factory/Document/EndScreenBlock.vue | 236 ++++++++++++++++++ .../Factory/Document/FormDocument.test.ts | 30 +++ .../Factory/Document/FormDocument.vue | 3 + 5 files changed, 494 insertions(+) create mode 100644 resources/js/components/Factory/Document/EndScreenBlock.test.ts create mode 100644 resources/js/components/Factory/Document/EndScreenBlock.vue create mode 100644 resources/js/components/Factory/Document/FormDocument.test.ts diff --git a/resources/js/app.ts b/resources/js/app.ts index 4fcc4e6a..f84c89d7 100644 --- a/resources/js/app.ts +++ b/resources/js/app.ts @@ -36,6 +36,7 @@ import { faEnvelope, faEye, faEyeSlash, + faFlag, faGear, faGlobe, faGripVertical, @@ -100,6 +101,7 @@ library.add( faEnvelope, faEye, faEyeSlash, + faFlag, faGear, faGlobe, faGripVertical, diff --git a/resources/js/components/Factory/Document/EndScreenBlock.test.ts b/resources/js/components/Factory/Document/EndScreenBlock.test.ts new file mode 100644 index 00000000..686367f4 --- /dev/null +++ b/resources/js/components/Factory/Document/EndScreenBlock.test.ts @@ -0,0 +1,223 @@ +import { flushPromises, mount, VueWrapper } from "@vue/test-utils"; +import { AxiosError, AxiosResponse } from "axios"; +import { createPinia, setActivePinia } from "pinia"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { callUpdateForm } from "@/api/forms"; +import { useForm, useWorkbench } from "@/stores"; +import { useNotice } from "@/stores/notice"; +import CompletionPage from "@/components/Factory/Settings/CompletionPage.vue"; +import EndScreenBlock from "./EndScreenBlock.vue"; + +vi.mock("@/api/forms", async (importOriginal) => ({ + ...(await importOriginal()), + callUpdateForm: vi.fn(), +})); + +const form = { + uuid: "form1", + eoc_headline: "See you on Thursday!", + eoc_text: "

We sent your ticket to your inbox.

", + show_cta_link: true, + cta_label: "Add to calendar", + cta_link: "https://example.com/cal", + use_cta_redirect: false, +} as FormModel; + +let pinia: ReturnType; +let wrapper: VueWrapper | undefined; + +// real actions, not the stubs of the global testing pinia +const mountBlock = () => { + wrapper = mount(EndScreenBlock, { + attachTo: document.body, + global: { plugins: [pinia], directives: { tooltip: {} } }, + }); + return wrapper; +}; + +const open = async () => { + const block = mountBlock(); + await block.find("button").trigger("click"); + await vi.waitFor(() => + expect(block.find("[role=textbox]").exists()).toBe(true), + ); + return block; +}; + +describe("the end screen in the document", () => { + beforeEach(() => { + vi.clearAllMocks(); + window.route = (() => "/settings") as unknown as typeof window.route; + pinia = createPinia(); + setActivePinia(pinia); + useForm().form = { ...form }; + // the server answers with the form as it saved it + vi.mocked(callUpdateForm).mockImplementation( + async (_, values) => + ({ + data: { ...useForm().form, ...values }, + }) as AxiosResponse, + ); + }); + + afterEach(() => { + wrapper?.unmount(); + wrapper = undefined; + }); + + it("shows its headline, text and button when closed", () => { + const text = mountBlock().find("button").text(); + + expect(text).toContain("See you on Thursday!"); + expect(text).toContain("End screen"); + expect(text).toContain("We sent your ticket to your inbox."); + expect(text).toContain("Button: Add to calendar"); + }); + + it("says when it has no headline yet", () => { + useForm().form = { ...form, eoc_headline: null, show_cta_link: false }; + const text = mountBlock().find("button").text(); + + expect(text).toContain("No headline yet"); + expect(text).not.toContain("Button:"); + }); + + it("opens in place, closes the open question, and closes when a question opens", async () => { + const workbench = useWorkbench(); + workbench.block = { id: 4, uuid: "q4" } as FormBlockModel; + + const block = await open(); + + expect(workbench.block).toBeNull(); + const headline = block.find("#end-screen-headline"); + expect(headline.element.value).toBe("See you on Thursday!"); + expect(document.activeElement).toBe(headline.element); + expect(block.find("#end-screen-text-label").text()).toBe("Text"); + + workbench.block = { id: 5, uuid: "q5" } as FormBlockModel; + await flushPromises(); + + expect(block.find("#end-screen-headline").exists()).toBe(false); + }); + + it("saves only what changed, right away when you leave the block", async () => { + const block = await open(); + + await block.find("#end-screen-headline").setValue("Thanks!"); + await block.find("#end-screen-headline").trigger("focusout", { + relatedTarget: block.find("#end-screen-button").element, + }); + await flushPromises(); + expect(callUpdateForm).not.toHaveBeenCalled(); + + await block.find("#end-screen-headline").trigger("focusout"); + await flushPromises(); + + expect(callUpdateForm).toHaveBeenCalledTimes(1); + expect(callUpdateForm).toHaveBeenCalledWith( + expect.objectContaining({ uuid: "form1" }), + { eoc_headline: "Thanks!" }, + ); + expect(useForm().form?.eoc_headline).toBe("Thanks!"); + }); + + it("saves on its own after a short pause while you type", async () => { + vi.useFakeTimers(); + try { + const block = await open(); + + await block.find("#end-screen-headline").setValue("Thanks!"); + vi.advanceTimersByTime(1000); + await flushPromises(); + + expect(callUpdateForm).toHaveBeenCalledWith(expect.anything(), { + eoc_headline: "Thanks!", + }); + } finally { + vi.useRealTimers(); + } + }); + + it("edits the button: off hides its label and link", async () => { + const block = await open(); + + expect( + block.find("#end-screen-button-label").element + .value, + ).toBe("Add to calendar"); + expect( + block.find("#end-screen-button-link").element + .value, + ).toBe("https://example.com/cal"); + + await block.find("#end-screen-button-link").setValue("mailto:a@b.co"); + await block.find("#end-screen-button").trigger("click"); + await block.find("#end-screen-button").trigger("focusout"); + await flushPromises(); + + expect(callUpdateForm).toHaveBeenCalledWith(expect.anything(), { + cta_link: "mailto:a@b.co", + show_cta_link: false, + }); + expect(block.find("#end-screen-button-label").exists()).toBe(false); + }); + + it("shows the server's reason when a save is refused", async () => { + const refused = new AxiosError( + "Unprocessable", + undefined, + undefined, + undefined, + { + status: 422, + data: { message: "The cta link field format is invalid." }, + } as AxiosResponse, + ); + vi.mocked(callUpdateForm).mockRejectedValue(refused); + const block = await open(); + + await block.find("#end-screen-button-link").setValue("example.com"); + await block.find("#end-screen-button-link").trigger("focusout"); + await flushPromises(); + + expect(useNotice().message).toBe( + "Your change was not saved: The cta link field format is invalid.", + ); + expect(useForm().form?.cta_link).toBe("https://example.com/cal"); + }); + + it("only links to Settings while the form sends people to a link instead", () => { + useForm().form = { ...form, use_cta_redirect: true }; + const block = mountBlock(); + + expect(block.find("button").exists()).toBe(false); + expect(block.text()).toContain("https://example.com/cal"); + expect(block.find("a").attributes("href")).toBe( + "/settings#completion-page", + ); + }); + + it("stays in step with the Settings page through the form store", async () => { + const block = await open(); + await block.find("#end-screen-headline").setValue("Thanks!"); + await block.find("#end-screen-headline").trigger("focusout"); + await flushPromises(); + + const settings = mount(CompletionPage, { + global: { plugins: [pinia], directives: { tooltip: {} } }, + }); + expect( + settings.find("#eoc-headline").element.value, + ).toBe("Thanks!"); + + await settings.find("#eoc-headline").setValue("Bye!"); + await settings.find("form").trigger("submit"); + await flushPromises(); + settings.unmount(); + + // closed, the block shows the saved headline without a reload + useWorkbench().block = { id: 5, uuid: "q5" } as FormBlockModel; + await flushPromises(); + expect(block.find("button").text()).toContain("Bye!"); + }); +}); diff --git a/resources/js/components/Factory/Document/EndScreenBlock.vue b/resources/js/components/Factory/Document/EndScreenBlock.vue new file mode 100644 index 00000000..3e387953 --- /dev/null +++ b/resources/js/components/Factory/Document/EndScreenBlock.vue @@ -0,0 +1,236 @@ + + + diff --git a/resources/js/components/Factory/Document/FormDocument.test.ts b/resources/js/components/Factory/Document/FormDocument.test.ts new file mode 100644 index 00000000..e6fb0289 --- /dev/null +++ b/resources/js/components/Factory/Document/FormDocument.test.ts @@ -0,0 +1,30 @@ +import { mount } from "@vue/test-utils"; +import { describe, expect, it } from "vitest"; +import { useForm } from "@/stores"; +import FormDocument from "./FormDocument.vue"; + +const mountDocument = () => + mount(FormDocument, { + props: { status: "loaded" }, + global: { + stubs: { + DocumentList: { template: "
questions
" }, + EmptyState: { template: "
no questions
" }, + EndScreenBlock: { template: "
end screen
" }, + BlockLogicEditor: true, + }, + }, + }); + +describe("the form document", () => { + it.each([ + ["questions", [{ uuid: "q1", type: "input-short" }]], + ["no questions", []], + ])("shows the end screen after %s", (first, blocks) => { + useForm().blocks = blocks as FormBlockModel[]; + const text = mountDocument().text(); + + expect(text.indexOf(first)).toBeGreaterThan(-1); + expect(text.indexOf("end screen")).toBeGreaterThan(text.indexOf(first)); + }); +}); diff --git a/resources/js/components/Factory/Document/FormDocument.vue b/resources/js/components/Factory/Document/FormDocument.vue index 58ac1ce4..f5d7752b 100644 --- a/resources/js/components/Factory/Document/FormDocument.vue +++ b/resources/js/components/Factory/Document/FormDocument.vue @@ -22,6 +22,8 @@ description="Add your first question below." /> + +
Date: Tue, 13 Oct 2026 02:03:04 +0200 Subject: [PATCH 02/16] docs: add the end screen block to the changelog Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c3bb6e06..82c2ee3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Lines marked **Upgrade step** need you to act after updating. - The API can copy a question (`POST /api/blocks/{block}/duplicate`) with its text, settings, answers and Required, but not its rules. The copy goes right after the original. (#262) - The form's team can try a form in preview mode, also while it's a draft. Answers in preview mode are never stored or counted and send no webhooks or emails. (#266) - Add `?preview=1` to your form's link to try it as a member of its team. Nothing is stored, files aren't uploaded and the form doesn't redirect at the end. (#270) +- The form editor shows the end screen as the last block of your form. You can edit its headline, text and button right there, and the Completion Page in Settings shows the same values. (#271) ### Changed From 32143ee4a3f487789bea2311196494af51c45f15 Mon Sep 17 00:00:00 2001 From: Philipp Reinking Date: Tue, 13 Oct 2026 02:09:59 +0200 Subject: [PATCH 03/16] fix: load the form again when browser Back returns to Settings Back restored the old props from history, so the tabs showed values from before an edit in the document, and Save there undid that edit. Co-Authored-By: Claude Opus 5.5 --- resources/js/Pages/Forms/Settings.test.ts | 80 +++++++++++++++++++++++ resources/js/Pages/Forms/Settings.vue | 24 +++++++ 2 files changed, 104 insertions(+) create mode 100644 resources/js/Pages/Forms/Settings.test.ts diff --git a/resources/js/Pages/Forms/Settings.test.ts b/resources/js/Pages/Forms/Settings.test.ts new file mode 100644 index 00000000..59b96310 --- /dev/null +++ b/resources/js/Pages/Forms/Settings.test.ts @@ -0,0 +1,80 @@ +import { mount, VueWrapper } from "@vue/test-utils"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { router } from "@inertiajs/vue3"; +import Settings from "./Settings.vue"; + +vi.mock("@inertiajs/vue3", async (importOriginal) => ({ + ...(await importOriginal()), + router: { on: vi.fn(), visit: vi.fn() }, +})); +// its code highlighter needs a global Vue +vi.mock("@/components/Factory/Settings/Embed.vue", () => ({ + default: { name: "Embed", template: "
" }, +})); + +const form = { uuid: "form1", name: "Customer feedback" } as FormModel; +// Settings listens for Inertia visits once, when it loads +const [listensTo, onVisit] = vi.mocked(router.on).mock.calls[0] ?? []; + +const stubs = { + AppLayout: { props: ["title"], template: "
" }, + Options: true, + Privacy: true, + CompletionPage: true, + Appearance: true, + Embed: true, + TemplateExport: true, + Delete: true, +}; + +let wrapper: VueWrapper | undefined; + +const mountSettings = () => { + wrapper = mount(Settings, { props: { form }, global: { stubs } }); +}; + +describe("settings page from history", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + wrapper?.unmount(); + wrapper = undefined; + }); + + it("loads the form again when browser Back brings the page back", () => { + window.dispatchEvent( + new PopStateEvent("popstate", { state: { component: "x" } }), + ); + + mountSettings(); + + expect(router.visit).toHaveBeenCalledWith(window.location.href, { + only: ["form"], + replace: true, + preserveScroll: true, + }); + }); + + it("loads nothing more on a normal visit, also one after a Back", () => { + // a Back to another page, then a link to Settings + window.dispatchEvent( + new PopStateEvent("popstate", { state: { component: "x" } }), + ); + expect(listensTo).toBe("before"); + onVisit(new CustomEvent("inertia:before")); + + mountSettings(); + + expect(router.visit).not.toHaveBeenCalled(); + }); + + it("loads nothing more when only the tab in the address changes", () => { + window.dispatchEvent(new PopStateEvent("popstate", { state: null })); + + mountSettings(); + + expect(router.visit).not.toHaveBeenCalled(); + }); +}); diff --git a/resources/js/Pages/Forms/Settings.vue b/resources/js/Pages/Forms/Settings.vue index c0ac0705..9649392a 100644 --- a/resources/js/Pages/Forms/Settings.vue +++ b/resources/js/Pages/Forms/Settings.vue @@ -61,6 +61,20 @@ + + - diff --git a/resources/js/stores/form.test.ts b/resources/js/stores/form.test.ts index 51828c2a..7beb18ca 100644 --- a/resources/js/stores/form.test.ts +++ b/resources/js/stores/form.test.ts @@ -30,9 +30,11 @@ describe("saving form settings", () => { await useForm().updateForm({ name: " Feedback " }); - expect(handler.post).toHaveBeenCalledWith("/api/forms/form1", { - name: " Feedback ", - }); + expect(handler.post).toHaveBeenCalledWith( + "/api/forms/form1", + { name: " Feedback " }, + undefined, + ); expect(useForm().form?.name).toBe("Feedback"); }); diff --git a/resources/js/stores/form.ts b/resources/js/stores/form.ts index 0429d4d8..49bb49c2 100644 --- a/resources/js/stores/form.ts +++ b/resources/js/stores/form.ts @@ -1,4 +1,5 @@ import { defineStore } from "pinia"; +import { AxiosRequestConfig } from "axios"; import { callDeleteFormImage, callUpdateForm, @@ -134,12 +135,15 @@ export const useForm = defineStore("form", { }, // only the page's own fields, and the store changes only once they're saved - async updateForm(newValues: Partial) { + async updateForm( + newValues: Partial, + config?: AxiosRequestConfig, + ) { if (!this.form) { return; } - const response = await callUpdateForm(this.form, newValues); + const response = await callUpdateForm(this.form, newValues, config); // the editor may have left this form while it saved if (this.form?.uuid !== response.data.uuid) { return; From ef472fa15f36595b0e19baa6d1da053578736159 Mon Sep 17 00:00:00 2001 From: Philipp Reinking Date: Tue, 13 Oct 2026 03:46:21 +0200 Subject: [PATCH 14/16] fix: give the open end card the question card's phone sizes and one focus bar touch-fields on the card (44 px fields with 16 px text, 44 px switch tap area below 1024), a 44 px tap area on "Also in Settings", the number column at w-8 below 640 like the questions, and no wrapper bar around the text: the bare editor draws its own. Co-Authored-By: Claude Opus 5.5 --- .../Factory/Document/EndScreenBlock.test.ts | 15 +++++++++++++++ .../Factory/Document/EndScreenBlock.vue | 11 ++++------- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/resources/js/components/Factory/Document/EndScreenBlock.test.ts b/resources/js/components/Factory/Document/EndScreenBlock.test.ts index 193541af..3b109d52 100644 --- a/resources/js/components/Factory/Document/EndScreenBlock.test.ts +++ b/resources/js/components/Factory/Document/EndScreenBlock.test.ts @@ -112,6 +112,21 @@ describe("the end screen in the document", () => { expect(block.find("#end-screen-headline").exists()).toBe(false); }); + it("has the question card's phone sizes, number column and focus bar", async () => { + const block = await open(); + const [number, card] = block.findAll("#row-end-screen > div"); + + expect(number.classes()).toEqual( + expect.arrayContaining(["w-8", "sm:w-12"]), + ); + expect(card.classes()).toContain("touch-fields"); + expect(block.get("a").classes()).toContain("min-h-11"); + // the editor draws its own bar + expect(block.find(".focus-within\\:border-blue-500").exists()).toBe( + false, + ); + }); + it("saves only what changed, right away when you leave the block", async () => { const block = await open(); diff --git a/resources/js/components/Factory/Document/EndScreenBlock.vue b/resources/js/components/Factory/Document/EndScreenBlock.vue index 58b20d29..d350493e 100644 --- a/resources/js/components/Factory/Document/EndScreenBlock.vue +++ b/resources/js/components/Factory/Document/EndScreenBlock.vue @@ -1,6 +1,6 @@