diff --git a/CHANGELOG.md b/CHANGELOG.md index 80654d07..daf24175 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 diff --git a/resources/js/Pages/Forms/Edit.test.ts b/resources/js/Pages/Forms/Edit.test.ts index fba82d18..c3800bde 100644 --- a/resources/js/Pages/Forms/Edit.test.ts +++ b/resources/js/Pages/Forms/Edit.test.ts @@ -1,8 +1,15 @@ import { flushPromises, mount, VueWrapper } from "@vue/test-utils"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { AxiosResponse } from "axios"; +import { callGetForm } from "@/api/forms"; import Edit from "./Edit.vue"; import { useForm, useWorkbench } from "@/stores"; +vi.mock("@/api/forms", async (importOriginal) => ({ + ...(await importOriginal()), + callGetForm: vi.fn(), +})); + const form = { uuid: "form1", name: "Customer feedback" } as FormModel; const blocks = [ { id: 1, uuid: "b1", type: "none" }, @@ -118,3 +125,24 @@ describe("editor loading", () => { expect(useWorkbench().putOnWorkbench).not.toHaveBeenCalled(); }); }); + +describe("editor from history", () => { + afterEach(() => { + wrapper?.unmount(); + wrapper = undefined; + vi.restoreAllMocks(); + }); + + it("loads the form again when browser Back brings the editor back", async () => { + vi.mocked(callGetForm).mockResolvedValue({ + data: { ...form, eoc_headline: "Thanks!" }, + } as AxiosResponse); + window.dispatchEvent( + new PopStateEvent("popstate", { state: { component: "x" } }), + ); + + await mountEditor(loads(blocks)); + + expect(useForm().form?.eoc_headline).toBe("Thanks!"); + }); +}); diff --git a/resources/js/Pages/Forms/Edit.vue b/resources/js/Pages/Forms/Edit.vue index 27a60fea..63ab96d4 100644 --- a/resources/js/Pages/Forms/Edit.vue +++ b/resources/js/Pages/Forms/Edit.vue @@ -9,6 +9,7 @@ import AppLayout from "@/Layouts/AppLayout.vue"; import FormDocument from "@/components/Factory/Document/FormDocument.vue"; import { useForm, useWorkbench } from "@/stores"; import { useSaveOnLeave } from "@/components/Factory/Shared/useSaveOnLeave"; +import { useFreshForm } from "@/utils/useFreshForm"; import { onBeforeUnmount, onMounted, ref } from "vue"; const props = defineProps<{ @@ -65,6 +66,7 @@ onMounted(async () => { await store.getFormBlockMapping(); }); +useFreshForm(); workbench.clearWorkbench(); store.$patch({ form: props.form, diff --git a/resources/js/Pages/Forms/Settings.test.ts b/resources/js/Pages/Forms/Settings.test.ts new file mode 100644 index 00000000..30ad825a --- /dev/null +++ b/resources/js/Pages/Forms/Settings.test.ts @@ -0,0 +1,133 @@ +import { flushPromises, mount, VueWrapper } from "@vue/test-utils"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { router } from "@inertiajs/vue3"; +import { AxiosResponse } from "axios"; +import { callGetForm } from "@/api/forms"; +import { useForm } from "@/stores"; +import Settings from "./Settings.vue"; +import { trackFormSave } from "@/utils/useFreshForm"; + +vi.mock("@inertiajs/vue3", async (importOriginal) => ({ + ...(await importOriginal()), + router: { on: vi.fn() }, +})); +vi.mock("@/api/forms", async (importOriginal) => ({ + ...(await importOriginal()), + callGetForm: 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, + // like the real one, it copies the form when it loads + CompletionPage: { + setup: () => ({ headline: useForm().form?.eoc_headline }), + template: "

{{ headline }}

", + }, + 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(); + vi.mocked(callGetForm).mockResolvedValue({ + data: form, + } as AxiosResponse); + }); + + afterEach(() => { + wrapper?.unmount(); + wrapper = undefined; + }); + + it("loads the form again when browser Back brings the page back", async () => { + window.dispatchEvent( + new PopStateEvent("popstate", { state: { component: "x" } }), + ); + + mountSettings(); + await flushPromises(); + + expect(callGetForm).toHaveBeenCalledTimes(1); + }); + + it("loads nothing more on a normal visit, also one after a Back", async () => { + // 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(); + await flushPromises(); + + expect(callGetForm).not.toHaveBeenCalled(); + }); + + it("lets a visit started while a save is on its way win, and its page loads the form after the save", async () => { + let landed = () => {}; + trackFormSave(new Promise((resolve) => (landed = resolve))); + window.dispatchEvent( + new PopStateEvent("popstate", { state: { component: "x" } }), + ); + mountSettings(); + + // you go to another page before the save lands + onVisit(new CustomEvent("inertia:before")); + wrapper?.unmount(); + mountSettings(); + landed(); + await flushPromises(); + + expect(callGetForm).toHaveBeenCalledTimes(1); + }); + + it("loads nothing more when only the tab in the address changes", async () => { + window.dispatchEvent(new PopStateEvent("popstate", { state: null })); + + mountSettings(); + await flushPromises(); + + expect(callGetForm).not.toHaveBeenCalled(); + }); + + it("keeps the tab you switch to while the form loads again", async () => { + let landed = () => {}; + trackFormSave(new Promise((resolve) => (landed = resolve))); + vi.mocked(callGetForm).mockResolvedValue({ + data: { ...form, eoc_headline: "Thanks!" }, + } as AxiosResponse); + window.dispatchEvent( + new PopStateEvent("popstate", { state: { component: "x" } }), + ); + mountSettings(); + + const tab = wrapper!.findAll("button[role=tab]")[2]; + await tab.trigger("click"); + landed(); + await flushPromises(); + + expect(location.hash).toBe("#completion-page"); + expect(tab.attributes("aria-selected")).toBe("true"); + expect(wrapper!.text()).toContain("Thanks!"); + }); +}); diff --git a/resources/js/Pages/Forms/Settings.vue b/resources/js/Pages/Forms/Settings.vue index c0ac0705..a8712d4a 100644 --- a/resources/js/Pages/Forms/Settings.vue +++ b/resources/js/Pages/Forms/Settings.vue @@ -52,7 +52,7 @@
- +
@@ -73,6 +73,7 @@ import Options from "@/components/Factory/Settings/Options.vue"; import Privacy from "@/components/Factory/Settings/Privacy.vue"; import TemplateExport from "@/components/Factory/Settings/TemplateExport.vue"; import { useForm } from "@/stores"; +import { useFreshForm } from "@/utils/useFreshForm"; import { onBeforeUnmount, ref } from "vue"; const props = defineProps<{ @@ -121,6 +122,10 @@ if (foundIndex !== -1) { tabChanged(0); } +// the panels copy the form when they load +const freshForms = ref(0); +useFreshForm(() => freshForms.value++); + onBeforeUnmount(() => { store.clearForm(); }); diff --git a/resources/js/api/forms.ts b/resources/js/api/forms.ts index 194bbb55..48482582 100644 --- a/resources/js/api/forms.ts +++ b/resources/js/api/forms.ts @@ -1,5 +1,5 @@ /* eslint-disable no-async-promise-executor */ -import { AxiosResponse } from "axios"; +import { AxiosRequestConfig, AxiosResponse } from "axios"; import handler from "./handler"; export function callListForms( @@ -86,12 +86,14 @@ export function callGetForm( export function callUpdateForm( form: FormModel, values: Partial, + config?: AxiosRequestConfig, ): Promise> { return new Promise(async (resolve, reject) => { try { const response = await handler.post( window.route("api.forms.update", { uuid: form.uuid }), values, + config, ); resolve(response as AxiosResponse); diff --git a/resources/js/app.ts b/resources/js/app.ts index 29da4271..f7985e0c 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..1025d4b5 --- /dev/null +++ b/resources/js/components/Factory/Document/EndScreenBlock.test.ts @@ -0,0 +1,473 @@ +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 { callGetForm, callUpdateForm } from "@/api/forms"; +import { useForm, useWorkbench } from "@/stores"; +import { useNotice } from "@/stores/notice"; +import { useFreshForm } from "@/utils/useFreshForm"; +import CompletionPage from "@/components/Factory/Settings/CompletionPage.vue"; +import EndScreenBlock from "./EndScreenBlock.vue"; + +vi.mock("@/api/forms", async (importOriginal) => ({ + ...(await importOriginal()), + callGetForm: vi.fn(), + 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("keeps its hidden label inside the document's scroll box", () => { + // else the absolute sr-only label stretches the whole page + expect(mountBlock().get("#row-end-screen").classes()).toContain( + "relative", + ); + }); + + it("shows the default headline people see when it has none", async () => { + useForm().form = { ...form, eoc_headline: null, show_cta_link: false }; + const text = mountBlock().find("button").text(); + + expect(text).toContain("Form Submitted · default"); + expect(text).not.toContain("Button:"); + + const block = await open(); + expect( + block.find("#end-screen-headline").attributes("placeholder"), + ).toBe("Form Submitted"); + }); + + 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("label[for=end-screen-headline]").text()).toBe( + "End screen headline", + ); + expect(block.find("#end-screen-text-label").text()).toBe( + "End screen text", + ); + + workbench.block = { id: 5, uuid: "q5" } as FormBlockModel; + await flushPromises(); + + 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(); + + 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!" }, + undefined, + ); + expect(useForm().form?.eoc_headline).toBe("Thanks!"); + }); + + it("saves right away when the focus moves to the Settings link in 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("a").element, + }); + await flushPromises(); + + expect(callUpdateForm).toHaveBeenCalledWith( + expect.anything(), + { eoc_headline: "Thanks!" }, + undefined, + ); + }); + + it("saves right away on browser Back", async () => { + const block = await open(); + + await block.find("#end-screen-headline").setValue("Thanks!"); + window.dispatchEvent(new PopStateEvent("popstate")); + await flushPromises(); + + expect(callUpdateForm).toHaveBeenCalledWith( + expect.anything(), + { eoc_headline: "Thanks!" }, + undefined, + ); + }); + + it("lets the next page load the form again only after the save made on Back", async () => { + vi.mocked(callGetForm).mockImplementation( + async () => + ({ + data: useForm().form, + }) as AxiosResponse, + ); + let answer = () => {}; + vi.mocked(callUpdateForm).mockImplementation( + (_, values) => + new Promise((resolve) => { + answer = () => + resolve({ + data: { ...useForm().form, ...values }, + } as AxiosResponse); + }), + ); + const block = await open(); + + await block.find("#end-screen-headline").setValue("Thanks!"); + window.dispatchEvent( + new PopStateEvent("popstate", { state: { component: "x" } }), + ); + // the page Back goes to + useFreshForm(); + await flushPromises(); + expect(callGetForm).not.toHaveBeenCalled(); + + answer(); + await flushPromises(); + expect(callGetForm).toHaveBeenCalledTimes(1); + }); + + it("lets a page opened by a link while the save is on its way load the form again after it", async () => { + vi.mocked(callGetForm).mockImplementation( + async () => + ({ + data: useForm().form, + }) as AxiosResponse, + ); + let answer = () => {}; + vi.mocked(callUpdateForm).mockImplementation( + (_, values) => + new Promise((resolve) => { + answer = () => + resolve({ + data: { ...useForm().form, ...values }, + } as AxiosResponse); + }), + ); + const block = await open(); + + await block.find("#end-screen-headline").setValue("Thanks!"); + await block.find("#end-screen-headline").trigger("focusout"); + // the link's visit starts, and its page loads + document.dispatchEvent( + new CustomEvent("inertia:before", { detail: { visit: {} } }), + ); + useFreshForm(); + await flushPromises(); + expect(callGetForm).not.toHaveBeenCalled(); + + answer(); + await flushPromises(); + expect(callGetForm).toHaveBeenCalledTimes(1); + }); + + it("opens with the form loaded again after Back, not the older one the page came with", async () => { + let answer = () => {}; + vi.mocked(callGetForm).mockImplementation( + () => + new Promise((resolve) => { + answer = () => + resolve({ + data: { ...form, eoc_headline: "Thanks!" }, + } as AxiosResponse); + }), + ); + window.dispatchEvent( + new PopStateEvent("popstate", { state: { component: "x" } }), + ); + // the page Back goes to + useFreshForm(); + const block = mountBlock(); + await flushPromises(); + + await block.find("button").trigger("click"); + answer(); + await vi.waitFor(() => + expect(block.find("#end-screen-headline").exists()).toBe(true), + ); + + expect( + block.get("#end-screen-headline").element.value, + ).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!" }, + undefined, + ); + } finally { + vi.useRealTimers(); + } + }); + + it("edits the button: off hides its text and link", async () => { + const block = await open(); + + expect(block.find("#end-screen-button-label-label").text()).toBe( + "Button text", + ); + + 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 }, + undefined, + ); + 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 to the end screen wasn't saved: The cta link field format is invalid.", + ); + expect(useForm().form?.cta_link).toBe("https://example.com/cal"); + }); + + it("shows Saving in the save status from the first key until the save lands", async () => { + let answer = () => {}; + vi.mocked(callUpdateForm).mockImplementation( + (_, values) => + new Promise((resolve) => { + answer = () => + resolve({ + data: { ...useForm().form, ...values }, + } as AxiosResponse); + }), + ); + const block = await open(); + + await block.find("#end-screen-headline").setValue("Thanks!"); + expect(useNotice().saveStatus).toBe("saving"); + + await block.find("#end-screen-headline").trigger("focusout"); + expect(useNotice().saveStatus).toBe("saving"); + + answer(); + await flushPromises(); + expect(useNotice().saveStatus).toBe("idle"); + }); + + it("sends a refused change again on Retry", async () => { + vi.mocked(callUpdateForm).mockRejectedValueOnce(new AxiosError()); + const block = await open(); + + await block.find("#end-screen-headline").setValue("Thanks!"); + await block.find("#end-screen-headline").trigger("focusout"); + await flushPromises(); + expect(useNotice().saveStatus).toBe("failed"); + + useNotice().retrySaves(); + await flushPromises(); + + expect(callUpdateForm).toHaveBeenCalledTimes(2); + expect(useForm().form?.eoc_headline).toBe("Thanks!"); + expect(useNotice().saveStatus).toBe("idle"); + }); + + it("sends a Retry only to the form it was made in", async () => { + vi.mocked(callUpdateForm).mockRejectedValueOnce(new AxiosError()); + const block = await open(); + + await block.find("#end-screen-headline").setValue("Thanks!"); + await block.find("#end-screen-headline").trigger("focusout"); + await flushPromises(); + // you open another form, then click Retry + block.unmount(); + wrapper = undefined; + useForm().form = { ...form, uuid: "form2" }; + useNotice().retrySaves(); + await flushPromises(); + + expect(callUpdateForm).toHaveBeenCalledTimes(1); + }); + + it("stops showing Saving when it closes with a change still in its pause", async () => { + const block = await open(); + + await block.find("#end-screen-headline").setValue("Thanks!"); + block.unmount(); + wrapper = undefined; + + expect(useNotice().saveStatus).toBe("idle"); + }); + + it("sends a change still in its pause when the page closes, so it outlives the page", async () => { + const block = await open(); + + await block.find("#end-screen-headline").setValue("Thanks!"); + window.dispatchEvent(new Event("pagehide")); + + expect(callUpdateForm).toHaveBeenCalledWith( + expect.anything(), + { eoc_headline: "Thanks!" }, + { adapter: "fetch", fetchOptions: { keepalive: true } }, + ); + }); + + 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..97dbf42c --- /dev/null +++ b/resources/js/components/Factory/Document/EndScreenBlock.vue @@ -0,0 +1,269 @@ + + + diff --git a/resources/js/components/Factory/Document/FormDocument.test.ts b/resources/js/components/Factory/Document/FormDocument.test.ts index 847160e0..ca92e1ff 100644 --- a/resources/js/components/Factory/Document/FormDocument.test.ts +++ b/resources/js/components/Factory/Document/FormDocument.test.ts @@ -11,8 +11,10 @@ const blocks = [ const stubs = { DocumentList: { - template: `
`, + template: `
[list]
`, }, + EmptyState: { template: "
[empty]
" }, + EndScreenBlock: { template: "
[end]
" }, BlockLogicEditor: true, }; @@ -80,4 +82,19 @@ describe("the form document", () => { document.querySelector("[aria-live=polite]")?.textContent, ).toBe("Moved to position 2 of 2"); }); + + it.each([ + ["the questions", "[list]", blocks], + ["no questions", "[empty]", []], + ])("shows the end screen after %s", (_, first, list) => { + useForm().blocks = list as FormBlockModel[]; + wrapper = mount(FormDocument, { + props: { status: "loaded" }, + global: { stubs }, + }); + const text = wrapper.text(); + + expect(text.indexOf(first)).toBeGreaterThan(-1); + expect(text.indexOf("[end]")).toBeGreaterThan(text.indexOf(first)); + }); }); diff --git a/resources/js/components/Factory/Document/FormDocument.vue b/resources/js/components/Factory/Document/FormDocument.vue index fe991a6d..53c30ec8 100644 --- a/resources/js/components/Factory/Document/FormDocument.vue +++ b/resources/js/components/Factory/Document/FormDocument.vue @@ -47,6 +47,8 @@ description="Add your first question below." /> + +
{ 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; diff --git a/resources/js/stores/notice.ts b/resources/js/stores/notice.ts index 80a38a05..59d3e14b 100644 --- a/resources/js/stores/notice.ts +++ b/resources/js/stores/notice.ts @@ -43,6 +43,9 @@ function questionName(block: FormBlockModel): string { return quoted ? `question ${number} (${quoted})` : `question ${number}`; } +// saves without a block, by what they change +const saveNames: Record = { saveEndScreen: "the end screen" }; + export const useNotice = defineStore("notice", { state: (): NoticeStore => { return { @@ -109,7 +112,9 @@ export const useNotice = defineStore("notice", { save: string, block?: FormBlockModel | null, ) { - const name = block ? questionName(block) : ""; + const name = block + ? questionName(block) + : (saveNames[save] ?? ""); const change = name ? `Your change to ${name}` : "Your change"; const response = error instanceof AxiosError ? error.response : undefined; diff --git a/resources/js/utils/useFreshForm.ts b/resources/js/utils/useFreshForm.ts new file mode 100644 index 00000000..1eab5cc5 --- /dev/null +++ b/resources/js/utils/useFreshForm.ts @@ -0,0 +1,72 @@ +import { router } from "@inertiajs/vue3"; +import { callGetForm } from "@/api/forms"; +import { useForm } from "@/stores"; + +// browser Back and Forward show a page with its old props from history; a tab +// change in the address has no state and keeps the page +let fromHistory = false; +// a page asked for while a form save is on its way can get the form from before it +let askedDuringSave = false; +let savesOnTheirWay = 0; +let saves: Promise = Promise.resolve(); +// counts page changes, so a late form load can see that the page moved on +let navigations = 0; +let loading: Promise = Promise.resolve(); + +window.addEventListener("popstate", (event) => { + fromHistory = event.state !== null; + if (fromHistory) { + navigations++; + } +}); +router.on("before", () => { + fromHistory = false; + askedDuringSave = savesOnTheirWay > 0; + navigations++; +}); + +// pages that load meanwhile wait for this save, then load the form again +export function trackFormSave(save: Promise): Promise { + const done = () => savesOnTheirWay--; + + savesOnTheirWay++; + save.then(done, done); + saves = Promise.allSettled([saves, save]); + return save; +} + +// call it in a page's setup: loads the form again when the page may show it +// from before a change, from history or from a save that hadn't landed yet; +// onFresh remounts the parts that copied the old form +export function useFreshForm(onFresh?: () => void) { + if (!fromHistory && !askedDuringSave) { + return; + } + + fromHistory = false; + askedDuringSave = false; + const page = navigations; + const store = useForm(); + + // a visit started meanwhile wins; the address and the tab stay as they are + const formHere = () => (page === navigations ? store.form : null); + + loading = saves + .then(() => { + const form = formHere(); + return form && callGetForm(form); + }) + .then((response) => { + if (response && formHere()?.uuid === response.data.uuid) { + store.form = response.data; + onFresh?.(); + } + }) + // the page keeps the form it came with + .catch(() => {}); +} + +// a copy of the form taken before this lands can be older than the page's form +export function freshFormLoaded() { + return loading; +}