diff --git a/CHANGELOG.md b/CHANGELOG.md index cd5af102..800dd2ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ Lines marked **Upgrade step** need you to act after updating. - 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) - Add a question right where you need it: between questions or from a question's menu, and type "/" in an empty question to pick its answer type. A picker groups the answer types as Text, Choice, Rating, Other and Content, and the new question opens ready to type. The answer types have new names, like Single choice, Short text and Text only. (#276) -- 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) +- 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 End screen tab in Settings shows the same values. (#271) - "Preview" in the editor shows your form beside the questions, on tablet and phone as a panel. It follows the question you edit, and the editor follows when you click Next in it. On the other form pages, "Preview" opens your form in a new tab in preview mode. Answers in either preview aren't saved. (#273) ### Changed @@ -30,6 +30,7 @@ Lines marked **Upgrade step** need you to act after updating. - Deleting a question, a group, an answer choice, a consent policy or a logic rule in the form editor now asks first in the app's own dialog. It names the question, how many answers will be deleted with it, and the questions whose rules will stop working. A choice nobody picked, or a policy nobody agreed to, is still deleted at once. (#269) - Each rule now shows as a sentence under its question, and a group says when it's hidden. Edit changes a rule right there, and for choices, ratings and scales you pick the answer instead of typing it. A rule whose question was deleted shows in red and can't be saved until you fix it. (#274) - The form editor uses plain words: a question's settings are headed by its answer type, like Choices or Rating, "Identifier" is now "Short name", and every switch shows on or off. Settings name what they do, like "Lowest value", "Shuffle choices" and "None ticked: all types allowed." on file uploads. (#279) +- The editor's preview shows the end screen while you edit it, with your latest text, and says so when your form sends people to a link instead. Its button opens in a new tab there, so you stay in the editor, and typing in a question you just opened no longer goes into the preview. On a tablet or phone, Esc closes the preview even after you clicked into it. Settings now calls the Completion Page "End screen", like the editor. (#280) ### Fixed diff --git a/resources/js/Pages/Forms/Edit.test.ts b/resources/js/Pages/Forms/Edit.test.ts index 74660edb..00704f77 100644 --- a/resources/js/Pages/Forms/Edit.test.ts +++ b/resources/js/Pages/Forms/Edit.test.ts @@ -4,6 +4,7 @@ import { AxiosResponse } from "axios"; import { callGetForm } from "@/api/forms"; import Edit from "./Edit.vue"; import FormMenu from "@/components/Factory/TopBar/FormMenu.vue"; +import PreviewFrame from "@/components/Factory/Preview/PreviewFrame.vue"; import { useForm, useWorkbench } from "@/stores"; vi.mock("@/api/forms", async (importOriginal) => ({ @@ -164,6 +165,26 @@ describe("editor loading", () => { expect(preview?.attributes("aria-haspopup")).toBe("dialog"); }); + it("closes the preview on Esc inside it and puts focus back on Preview", async () => { + const editor = await mountEditor(loads(blocks), { + stubs: { ...stubs, FormPreview: false, PreviewFrame: true }, + mocks: { route: () => "/" }, + }); + const preview = editor + .findAll("button") + .find((button) => button.text() === "Preview"); + await preview?.trigger("click"); + await flushPromises(); + expect(document.activeElement?.closest("[role=dialog]")).not.toBeNull(); + + // after a click into the frame, the form there gets the key and tells the editor + editor.findComponent(PreviewFrame).vm.$emit("escape"); + await flushPromises(); + + expect(document.querySelector("[role=dialog]")).toBeNull(); + expect(document.activeElement).toBe(preview?.element); + }); + it("puts focus back on the form menu when a preview opened from it closes", async () => { // the menu item is gone by then, so the dialog can't focus it again const editor = await mountEditor(loads(blocks), { diff --git a/resources/js/Pages/Forms/Settings.vue b/resources/js/Pages/Forms/Settings.vue index a8712d4a..e6e6fbc0 100644 --- a/resources/js/Pages/Forms/Settings.vue +++ b/resources/js/Pages/Forms/Settings.vue @@ -92,7 +92,7 @@ const navigation: Array<{ { name: "Options", component: Options, slug: "options" }, { name: "Privacy", component: Privacy, slug: "privacy" }, { - name: "Completion Page", + name: "End screen", component: CompletionPage, slug: "completion-page", }, diff --git a/resources/js/components/Factory/Document/EndScreenBlock.test.ts b/resources/js/components/Factory/Document/EndScreenBlock.test.ts index 1025d4b5..279e4262 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"); @@ -447,6 +459,15 @@ describe("the end screen in the document", () => { ); }); + // the public form only goes to a web link, else it shows the end screen + it("shows the end screen when the link isn't a web link", () => { + useForm().form = { ...form, use_cta_redirect: true, cta_link: "mailto:hi@example.com" }; + const block = mountBlock(); + + expect(block.text()).not.toContain("No end screen"); + expect(block.find("button").text()).toContain("See you on Thursday!"); + }); + 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!"); diff --git a/resources/js/components/Factory/Document/EndScreenBlock.vue b/resources/js/components/Factory/Document/EndScreenBlock.vue index 70eac0d6..d222d88c 100644 --- a/resources/js/components/Factory/Document/EndScreenBlock.vue +++ b/resources/js/components/Factory/Document/EndScreenBlock.vue @@ -8,12 +8,12 @@

No end screen: - people go to {{ store.form.cta_link }} + people go to {{ store.form?.cta_link }}

Change in Settings 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..42ccf7e4 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,54 @@ 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("shows the end screen when the link isn't a web link, as the public form does", async () => { + screen(true); + useForm().form = { use_cta_redirect: true, cta_link: "mailto:hi@example.com" } as FormModel; + const wrapper = mountPreview(); + + const frame = wrapper.findComponent(PreviewFrame); + frame.vm.$emit("update:shown", END_SCREEN); + await flushPromises(); + + expect(wrapper.text()).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..75cd09af 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,23 @@

{{ 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 +75,8 @@ 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 { isAllowedLink } from "@/utils/sanitize"; import PreviewFrame from "./PreviewFrame.vue"; const open = defineModel({ required: true }); @@ -68,9 +91,21 @@ 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 && + isAllowedLink(store.form.cta_link, ["http:", "https:"]) + ? 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..d643947a 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(); @@ -188,6 +234,19 @@ describe("the preview frame", () => { expect(wrapper.emitted("update:shown")).toEqual([["q1uuid"], ["q2uuid"]]); }); + // after a click into the frame, the form there gets the key + it("passes on Esc from the form in the frame, from no other origin or window", () => { + const wrapper = mountFrame(); + const escape = { type: "input:preview:escape" }; + + fromFrame(wrapper, escape, { origin: "https://evil.example" }); + fromFrame(wrapper, escape, { source: window }); + expect(wrapper.emitted("escape")).toBeUndefined(); + + fromFrame(wrapper, escape); + expect(wrapper.emitted("escape")).toEqual([[]]); + }); + it("tells when the preview doesn't go to the open question, as when a rule hides it", async () => { vi.useFakeTimers(); const wrapper = mountFrame(); diff --git a/resources/js/components/Factory/Preview/PreviewFrame.vue b/resources/js/components/Factory/Preview/PreviewFrame.vue index ef17170f..c0406cc2 100644 --- a/resources/js/components/Factory/Preview/PreviewFrame.vue +++ b/resources/js/components/Factory/Preview/PreviewFrame.vue @@ -11,26 +11,35 @@