From 003acf38c6de9500f7588311ccdfe9f5105b21c0 Mon Sep 17 00:00:00 2001 From: Philipp Reinking Date: Tue, 13 Oct 2026 11:18:22 +0200 Subject: [PATCH 01/11] fix: show the end screen in the preview while its block is open The preview went only to questions. Now the open end block sends the preview to the end screen (nothing is sent), a save shows the new text, and a form that sends people to a link gets a short note instead. Co-Authored-By: Claude Opus 5.5 --- .../Factory/Document/EndScreenBlock.test.ts | 12 +++++ .../Factory/Document/EndScreenBlock.vue | 7 ++- .../components/Factory/Document/blockFocus.ts | 3 ++ .../Factory/Preview/FormPreview.test.ts | 36 +++++++++++++++ .../Factory/Preview/FormPreview.vue | 34 +++++++++++++- .../Factory/Preview/PreviewFrame.test.ts | 46 +++++++++++++++++++ .../Factory/Preview/PreviewFrame.vue | 43 ++++++++--------- resources/js/forms/classic/previewTarget.ts | 2 + .../forms/classic/usePreviewMessages.test.ts | 19 ++++++++ .../js/forms/classic/usePreviewMessages.ts | 6 ++- resources/js/stores/conversation.ts | 9 +++- 11 files changed, 189 insertions(+), 28 deletions(-) create mode 100644 resources/js/forms/classic/previewTarget.ts diff --git a/resources/js/components/Factory/Document/EndScreenBlock.test.ts b/resources/js/components/Factory/Document/EndScreenBlock.test.ts index 1025d4b5..53bf9330 100644 --- a/resources/js/components/Factory/Document/EndScreenBlock.test.ts +++ b/resources/js/components/Factory/Document/EndScreenBlock.test.ts @@ -8,6 +8,7 @@ import { useNotice } from "@/stores/notice"; import { useFreshForm } from "@/utils/useFreshForm"; import CompletionPage from "@/components/Factory/Settings/CompletionPage.vue"; import EndScreenBlock from "./EndScreenBlock.vue"; +import { endScreenOpen } from "./blockFocus"; vi.mock("@/api/forms", async (importOriginal) => ({ ...(await importOriginal()), @@ -30,6 +31,8 @@ let wrapper: VueWrapper | undefined; // real actions, not the stubs of the global testing pinia const mountBlock = () => { + // one at a time, as in the document: they share the open state + wrapper?.unmount(); wrapper = mount(EndScreenBlock, { attachTo: document.body, global: { plugins: [pinia], directives: { tooltip: {} } }, @@ -119,6 +122,15 @@ describe("the end screen in the document", () => { expect(block.find("#end-screen-headline").exists()).toBe(false); }); + it("tells the preview while it's open, and starts closed on the next page", async () => { + const block = await open(); + expect(endScreenOpen.value).toBe(true); + + block.unmount(); + wrapper = undefined; + expect(endScreenOpen.value).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"); diff --git a/resources/js/components/Factory/Document/EndScreenBlock.vue b/resources/js/components/Factory/Document/EndScreenBlock.vue index 70eac0d6..f56e3cc9 100644 --- a/resources/js/components/Factory/Document/EndScreenBlock.vue +++ b/resources/js/components/Factory/Document/EndScreenBlock.vue @@ -127,7 +127,7 @@ diff --git a/resources/js/components/Factory/Document/blockFocus.ts b/resources/js/components/Factory/Document/blockFocus.ts index c1be464c..24e3458e 100644 --- a/resources/js/components/Factory/Document/blockFocus.ts +++ b/resources/js/components/Factory/Document/blockFocus.ts @@ -4,6 +4,9 @@ import { useForm, useWorkbench } from "@/stores"; // the question whose card takes the cursor once it shows: its text editor isn't there at once export const cursorFor = ref(null); +// the end screen's card is open: it's no block, so the workbench doesn't hold it +export const endScreenOpen = ref(false); + // puts the cursor in a question's text, or in a group's name export async function focusBlock(block: FormBlockModel | null): Promise { if (!block) { diff --git a/resources/js/components/Factory/Preview/FormPreview.test.ts b/resources/js/components/Factory/Preview/FormPreview.test.ts index 6f3a8dea..e1449a9d 100644 --- a/resources/js/components/Factory/Preview/FormPreview.test.ts +++ b/resources/js/components/Factory/Preview/FormPreview.test.ts @@ -1,6 +1,7 @@ import { enableAutoUnmount, flushPromises, mount } from "@vue/test-utils"; import { afterEach, describe, expect, it, vi } from "vitest"; import { useForm, useWorkbench } from "@/stores"; +import { END_SCREEN } from "@/forms/classic/previewTarget"; import FormPreview from "./FormPreview.vue"; import PreviewToggle from "./PreviewToggle.vue"; @@ -149,6 +150,41 @@ describe("the preview panel", () => { ); }); + it("says when it shows the end screen", async () => { + screen(true); + useForm().form = { use_cta_redirect: false } as FormModel; + const wrapper = mountPreview(); + + wrapper.findComponent(PreviewFrame).vm.$emit("update:shown", END_SCREEN); + await flushPromises(); + + expect(wrapper.get("#preview-heading").text()).toBe("Preview · end screen"); + expect(wrapper.find("iframe").isVisible()).toBe(true); + }); + + // the preview doesn't follow the link, so its end screen is one nobody sees + it.each([true, false])("says where people go instead of the end screen when the form sends them to a link (desktop: %s)", async (wide) => { + screen(wide); + useForm().blocks = [{ uuid: "q1", type: "input-short" }] as FormBlockModel[]; + useForm().form = { use_cta_redirect: true, cta_link: "https://example.com/thanks" } as FormModel; + const wrapper = mountPreview(); + await flushPromises(); + + const frame = wrapper.findComponent(PreviewFrame); + frame.vm.$emit("update:shown", END_SCREEN); + await flushPromises(); + + const panel = document.querySelector(wide ? "aside" : "[role=dialog]"); + expect(panel?.textContent).toContain("No end screen: people go to https://example.com/thanks."); + expect(frame.isVisible()).toBe(false); + + // back at a question, the frame shows again + frame.vm.$emit("update:shown", "q1"); + await flushPromises(); + expect(document.body.textContent).not.toContain("No end screen"); + expect(frame.isVisible()).toBe(true); + }); + it.each([true, false])("shows nothing when closed (desktop: %s)", (wide) => { screen(wide); const wrapper = mountPreview(false); diff --git a/resources/js/components/Factory/Preview/FormPreview.vue b/resources/js/components/Factory/Preview/FormPreview.vue index 1606636e..4b3dfa41 100644 --- a/resources/js/components/Factory/Preview/FormPreview.vue +++ b/resources/js/components/Factory/Preview/FormPreview.vue @@ -11,10 +11,18 @@

{{ hidden }}, so the preview can't jump to it.

+

+ No end screen: people go to {{ redirect }}. +

Add a question to see it here.

- + @@ -39,10 +47,21 @@

{{ hidden }}, so the preview can't jump to it.

+

+ No end screen: people go to {{ redirect }}. +

Add a question to see it here.

- + @@ -54,6 +73,7 @@ import { useMediaQuery } from "@vueuse/core"; import { computed, ref, watch } from "vue"; import { useForm, useWorkbench } from "@/stores"; import { questionNumbers } from "@/components/Factory/Document/questionSummary"; +import { END_SCREEN } from "@/forms/classic/previewTarget"; import PreviewFrame from "./PreviewFrame.vue"; const open = defineModel({ required: true }); @@ -68,9 +88,19 @@ const numbers = computed(() => questionNumbers(store.blocks ?? [])); const shown = ref(null); const missed = ref(null); const heading = computed(() => { + if (shown.value === END_SCREEN) { + return "Preview · end screen"; + } + const number = shown.value && numbers.value[shown.value]; return number ? `Preview · question ${number}` : "Preview"; }); +// the preview doesn't follow the link, so its end screen is one nobody sees +const redirect = computed(() => + shown.value === END_SCREEN && store.form?.use_cta_redirect + ? store.form.cta_link + : undefined, +); // the open question, when the preview couldn't go to it (a group has no number); // the public form leaves out a hidden question and each one in a hidden group const hidden = computed(() => { diff --git a/resources/js/components/Factory/Preview/PreviewFrame.test.ts b/resources/js/components/Factory/Preview/PreviewFrame.test.ts index dc9818d1..93319a0a 100644 --- a/resources/js/components/Factory/Preview/PreviewFrame.test.ts +++ b/resources/js/components/Factory/Preview/PreviewFrame.test.ts @@ -14,6 +14,8 @@ import { useForm, useLogic, useWorkbench } from "@/stores"; import { useNotice } from "@/stores/notice"; import { callUpdateFormBlock } from "@/api/blocks"; import { callUpdateFormBlockInteraction } from "@/api/interactions"; +import { endScreenOpen } from "@/components/Factory/Document/blockFocus"; +import { END_SCREEN } from "@/forms/classic/previewTarget"; import PreviewFrame from "./PreviewFrame.vue"; vi.mock("@/api/blocks", () => ({ callUpdateFormBlock: vi.fn() })); @@ -97,6 +99,7 @@ describe("the preview frame", () => { setActivePinia(testing); vi.clearAllMocks(); answers.length = 0; + endScreenOpen.value = false; }); it("runs the public form in preview mode at the open question", () => { @@ -142,6 +145,49 @@ describe("the preview frame", () => { ); }); + it("starts at the end screen while its card is open", () => { + endScreenOpen.value = true; + const iframe = mountFrame(null).get("iframe"); + + expect(iframe.attributes("src")).toBe(`/form1?preview=1&block=${END_SCREEN}`); + }); + + it("goes to the end screen when its card opens, and back to a question opened after", async () => { + const wrapper = mountFrame(); + const post = vi.spyOn(frameWindow(wrapper), "postMessage"); + + // opening the card closes the open question first + useWorkbench().block = null; + endScreenOpen.value = true; + await nextTick(); + expect(post).toHaveBeenLastCalledWith( + { type: "input:preview:goto", block: END_SCREEN }, + window.location.origin, + ); + + useWorkbench().block = second; + await nextTick(); + expect(post).toHaveBeenLastCalledWith( + { type: "input:preview:goto", block: "q2uuid" }, + window.location.origin, + ); + }); + + it("loads the form again at the end screen after its save", async () => { + const wrapper = mountFrame(null, realPinia()); + endScreenOpen.value = true; + await nextTick(); + const before = wrapper.get("iframe").element; + + useNotice().clearSaveError("saveEndScreen"); + await nextTick(); + await nextTick(); + + const iframe = wrapper.get("iframe"); + expect(iframe.element).not.toBe(before); + expect(iframe.attributes("src")).toBe(`/form1?preview=1&block=${END_SCREEN}`); + }); + it("opens the question the preview moves on to", () => { const wrapper = mountFrame(); diff --git a/resources/js/components/Factory/Preview/PreviewFrame.vue b/resources/js/components/Factory/Preview/PreviewFrame.vue index ef17170f..3e9f2841 100644 --- a/resources/js/components/Factory/Preview/PreviewFrame.vue +++ b/resources/js/components/Factory/Preview/PreviewFrame.vue @@ -11,13 +11,15 @@