diff --git a/CHANGELOG.md b/CHANGELOG.md index 80654d07..8e75ee1a 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) +- "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 diff --git a/resources/js/Layouts/AppLayout.test.ts b/resources/js/Layouts/AppLayout.test.ts index b0d1821d..f6b89efe 100644 --- a/resources/js/Layouts/AppLayout.test.ts +++ b/resources/js/Layouts/AppLayout.test.ts @@ -64,7 +64,7 @@ describe("app header", () => { ); }); - it("links a draft's bar to its form in a new tab, until the live preview fills the slot", async () => { + it("links a draft's bar to its form's preview in a new tab, where no live preview fills the slot", async () => { window.route = ((name: string) => `/${name}`) as typeof window.route; // the real bar, without its menus const wrapper = mount(AppLayout, { @@ -86,7 +86,8 @@ describe("app header", () => { await wrapper.vm.$nextTick(); const preview = wrapper.get("header a[target='_blank']"); - expect(preview.attributes("href")).toBe("/forms.show"); + // in preview mode: no answers are stored + expect(preview.attributes("href")).toBe("/forms.show?preview=1"); expect(preview.get("span").text()).toBe("Preview"); expect(preview.text()).toContain("(opens in a new tab)"); // on phones it's in the form menu: the name keeps its room diff --git a/resources/js/Pages/Forms/Edit.test.ts b/resources/js/Pages/Forms/Edit.test.ts index fba82d18..143ed586 100644 --- a/resources/js/Pages/Forms/Edit.test.ts +++ b/resources/js/Pages/Forms/Edit.test.ts @@ -1,8 +1,19 @@ import { flushPromises, mount, VueWrapper } from "@vue/test-utils"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import Edit from "./Edit.vue"; +import FormMenu from "@/components/Factory/TopBar/FormMenu.vue"; import { useForm, useWorkbench } from "@/stores"; +// the headlessui dialog and menu need it, jsdom has none +vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + unobserve() {} + disconnect() {} + }, +); + const form = { uuid: "form1", name: "Customer feedback" } as FormModel; const blocks = [ { id: 1, uuid: "b1", type: "none" }, @@ -13,16 +24,23 @@ const stubs = { AppLayout: { props: ["title"], template: "
" }, DocumentList: { template: "
  1. questions
" }, BlockLogicEditor: true, + FormPreview: { + props: ["modelValue"], + template: "", + }, }; let wrapper: VueWrapper | undefined; // the test pinia stubs actions: getBlocks does what each test tells it to -const mountEditor = async (load: () => Promise) => { +const mountEditor = async ( + load: () => Promise, + global: { stubs?: object; mocks?: object } = { stubs }, +) => { vi.mocked(useForm().getBlocks).mockImplementation(load); wrapper = mount(Edit, { props: { form }, - global: { stubs }, + global, attachTo: document.body, }); await flushPromises(); @@ -117,4 +135,59 @@ describe("editor loading", () => { expect(useWorkbench().putOnWorkbench).not.toHaveBeenCalled(); }); + + // the top bar has its Preview from lg + it("opens the preview from the document on a phone", async () => { + const editor = await mountEditor(loads(blocks)); + expect(editor.find("aside").exists()).toBe(false); + + const preview = editor + .findAll("button") + .find((button) => button.text() === "Preview"); + await preview?.trigger("click"); + + expect(editor.find("aside").exists()).toBe(true); + // a click on the text would land on a span, which the closing dialog + // can't focus again: the button's inside lets clicks through to it + expect(preview?.classes()).toContain("[&_*]:pointer-events-none"); + // a 44 px icon below sm, so Outline fits beside it; the label from sm + expect(preview?.classes()).toEqual( + expect.arrayContaining(["h-11", "w-11", "sm:w-auto"]), + ); + expect(preview?.get("span").classes()).toEqual(["sr-only", "sm:not-sr-only"]); + expect(preview?.attributes("aria-haspopup")).toBe("dialog"); + }); + + 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), { + stubs: { + ...stubs, + AppLayout: { + components: { FormMenu }, + template: "
", + }, + FormPreview: false, + PreviewFrame: true, + }, + mocks: { route: () => "/" }, + }); + const menu = editor.get('[aria-haspopup="menu"]').element as HTMLElement; + // a mouse hits the name inside the button + menu.querySelector("span")?.click(); + await flushPromises(); + [...document.querySelectorAll('[role="menuitem"]')] + .find((item) => item.textContent?.trim() === "Preview") + ?.click(); + await new Promise((resolve) => setTimeout(resolve)); + await flushPromises(); + expect(document.activeElement?.closest("[role=dialog]")).not.toBeNull(); + + document.activeElement?.dispatchEvent( + new KeyboardEvent("keydown", { key: "Escape", bubbles: true }), + ); + await flushPromises(); + + expect(document.activeElement).toBe(menu); + }); }); diff --git a/resources/js/Pages/Forms/Edit.vue b/resources/js/Pages/Forms/Edit.vue index 27a60fea..561fe652 100644 --- a/resources/js/Pages/Forms/Edit.vue +++ b/resources/js/Pages/Forms/Edit.vue @@ -1,15 +1,37 @@ diff --git a/resources/js/components/Factory/Preview/PreviewFrame.test.ts b/resources/js/components/Factory/Preview/PreviewFrame.test.ts new file mode 100644 index 00000000..0b95fbb3 --- /dev/null +++ b/resources/js/components/Factory/Preview/PreviewFrame.test.ts @@ -0,0 +1,300 @@ +import { + enableAutoUnmount, + flushPromises, + mount, + type VueWrapper, +} from "@vue/test-utils"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createPinia, getActivePinia, setActivePinia, type Pinia } from "pinia"; +import { PiniaDebounce } from "@pinia/plugin-debounce"; +import debounce from "lodash/debounce"; +import type { AxiosResponse } from "axios"; +import { createApp, nextTick } from "vue"; +import { useForm, useLogic, useWorkbench } from "@/stores"; +import { useNotice } from "@/stores/notice"; +import { callUpdateFormBlock } from "@/api/blocks"; +import { callUpdateFormBlockInteraction } from "@/api/interactions"; +import PreviewFrame from "./PreviewFrame.vue"; + +vi.mock("@/api/blocks", () => ({ callUpdateFormBlock: vi.fn() })); +vi.mock("@/api/interactions", () => ({ + callUpdateFormBlockInteraction: vi.fn(), +})); + +// the form page is / +window.route = ((name: string, params?: { uuid?: string }) => + `/${params?.uuid ?? name}`) as typeof window.route; + +const block = (id: number, uuid: string, type = "input-short") => + ({ + id, + uuid, + type, + title: "", + interactions: [{ id: id * 10, label: "Red", options: [] }], + }) as unknown as FormBlockModel; +const first = block(1, "q1uuid"); +const second = block(2, "q2uuid"); +const group = block(3, "g3uuid", "group"); + +// the testing pinia from the setup file: a test with a real one sets it back +const testing = getActivePinia() as Pinia; + +// a real one, whose typing saves wait a second as in the app +const realPinia = () => { + const pinia = createPinia(); + createApp({}).use(pinia); + pinia.use(PiniaDebounce(debounce)); + setActivePinia(pinia); + return pinia; +}; + +// the server answers each save when the test says so +const answers: Array<() => void> = []; +const answerLater = () => + new Promise((resolve) => + answers.push(() => resolve({ status: 200 } as AxiosResponse)), + ); + +enableAutoUnmount(afterEach); + +const mountFrame = (open: FormBlockModel | null = first, pinia?: Pinia) => { + useForm().form = { uuid: "form1" } as FormModel; + // copies, which the editor changes + useForm().blocks = structuredClone([first, second, group]); + useWorkbench().block = + useForm().blocks!.find((item) => item.uuid === open?.uuid) ?? null; + + return mount(PreviewFrame, { + attachTo: document.body, + global: pinia ? { plugins: [pinia] } : {}, + }); +}; + +const frameWindow = (wrapper: VueWrapper) => + (wrapper.get("iframe").element as HTMLIFrameElement) + .contentWindow as Window; + +const fromFrame = ( + wrapper: VueWrapper, + data: unknown, + init: MessageEventInit = {}, +) => + window.dispatchEvent( + new MessageEvent("message", { + data, + origin: window.location.origin, + source: frameWindow(wrapper), + ...init, + }), + ); + +describe("the preview frame", () => { + beforeEach(() => { + setActivePinia(testing); + vi.clearAllMocks(); + answers.length = 0; + }); + + it("runs the public form in preview mode at the open question", () => { + const iframe = mountFrame().get("iframe"); + + expect(iframe.attributes("src")).toBe("/form1?preview=1&block=q1uuid"); + expect(iframe.attributes("title")).toBe("Preview of your form"); + }); + + it("waits for the questions to load, so it starts at the open one instead of jumping there", async () => { + useForm().form = { uuid: "form1" } as FormModel; + useForm().blocks = null; + useWorkbench().block = null; + const wrapper = mount(PreviewFrame, { attachTo: document.body }); + expect(wrapper.find("iframe").exists()).toBe(false); + + useForm().blocks = structuredClone([first, second, group]); + + useWorkbench().block = second; + await nextTick(); + + expect(wrapper.get("iframe").attributes("src")).toBe( + "/form1?preview=1&block=q2uuid", + ); + }); + + it("starts at once when it opens with no question open", () => { + const iframe = mountFrame(null).get("iframe"); + + expect(iframe.attributes("src")).toBe("/form1?preview=1"); + }); + + it("goes to the question opened in the document", async () => { + const wrapper = mountFrame(); + const post = vi.spyOn(frameWindow(wrapper), "postMessage"); + + useWorkbench().block = second; + await nextTick(); + + expect(post).toHaveBeenCalledWith( + { type: "input:preview:goto", block: "q2uuid" }, + window.location.origin, + ); + }); + + it("opens the question the preview moves on to", () => { + const wrapper = mountFrame(); + + fromFrame(wrapper, { type: "input:preview:block", block: "q1uuid" }); + fromFrame(wrapper, { type: "input:preview:block", block: "q2uuid" }); + + expect(useWorkbench().putOnWorkbench).toHaveBeenCalledWith(second); + }); + + it("doesn't send that question back to the preview", async () => { + const wrapper = mountFrame(); + const post = vi.spyOn(frameWindow(wrapper), "postMessage"); + + fromFrame(wrapper, { type: "input:preview:block", block: "q1uuid" }); + fromFrame(wrapper, { type: "input:preview:block", block: "q2uuid" }); + // the stubbed action doesn't open it + useWorkbench().block = second; + await nextTick(); + + expect(post).not.toHaveBeenCalled(); + }); + + it("keeps the open group where the preview starts", () => { + const wrapper = mountFrame(group); + const post = vi.spyOn(frameWindow(wrapper), "postMessage"); + + // a group is no page of the form: the preview starts at the first question + fromFrame(wrapper, { type: "input:preview:block", block: "q1uuid" }); + + expect(useWorkbench().putOnWorkbench).not.toHaveBeenCalled(); + // and asks for the open block again, it may have missed it while loading + expect(post).toHaveBeenCalledWith( + { type: "input:preview:goto", block: "g3uuid" }, + window.location.origin, + ); + }); + + it("tells which question the preview shows", () => { + const wrapper = mountFrame(); + + fromFrame(wrapper, { type: "input:preview:block", block: "q1uuid" }); + fromFrame(wrapper, { type: "input:preview:block", block: "q2uuid" }); + + expect(wrapper.emitted("update:shown")).toEqual([["q1uuid"], ["q2uuid"]]); + }); + + it("tells when the preview doesn't go to the open question, as when a rule hides it", async () => { + vi.useFakeTimers(); + const wrapper = mountFrame(); + fromFrame(wrapper, { type: "input:preview:block", block: "q1uuid" }); + const missed = () => wrapper.emitted("update:missed")?.at(-1)?.[0] ?? null; + + // the preview moves: nothing missed + useWorkbench().block = second; + await nextTick(); + fromFrame(wrapper, { type: "input:preview:block", block: "q2uuid" }); + vi.advanceTimersByTime(500); + expect(missed()).toBeNull(); + + // it stays + useWorkbench().block = first; + await nextTick(); + vi.advanceTimersByTime(499); + expect(missed()).toBeNull(); + vi.advanceTimersByTime(1); + expect(missed()).toBe("q1uuid"); + vi.useRealTimers(); + }); + + it.each([ + ["another origin", { origin: "https://evil.example" }, {}], + ["another window", { source: window }, {}], + ["another type", {}, { type: "input:preview:goto" }], + ["no block id", {}, { block: 2 }], + ])("ignores a message from %s", (_, init, change) => { + const wrapper = mountFrame(); + const data = { type: "input:preview:block", block: "q1uuid" }; + + fromFrame(wrapper, data); + fromFrame(wrapper, { ...data, block: "q2uuid", ...change }, init); + + expect(useWorkbench().putOnWorkbench).not.toHaveBeenCalled(); + }); + + it.each([ + ["a new choice", () => useNotice().clearSaveError("createInteraction:2")], + ["saved rules", () => useLogic().saveBlockLogic()], + ])("loads the form again at the open question after %s", async (_, save) => { + const wrapper = mountFrame(first, realPinia()); + const before = wrapper.get("iframe").element; + + useWorkbench().block = second; + await save(); + await nextTick(); + await nextTick(); + + const iframe = wrapper.get("iframe"); + expect(iframe.element).not.toBe(before); + expect(iframe.attributes("src")).toBe("/form1?preview=1&block=q2uuid"); + }); + + it("loads the form again after a delete, which takes the question out after its save", async () => { + const wrapper = mountFrame(first, realPinia()); + const before = wrapper.get("iframe").element; + + useNotice().clearSaveError("deleteBlock:2"); + useForm().blocks!.splice(1, 1); + await nextTick(); + await nextTick(); + + expect(wrapper.get("iframe").element).not.toBe(before); + }); + + describe("while typing, which saves a second later", () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] }); + vi.mocked(callUpdateFormBlock).mockImplementation(answerLater); + vi.mocked(callUpdateFormBlockInteraction).mockImplementation( + answerLater, + ); + }); + afterEach(() => vi.useRealTimers()); + + it("shows two saves that overlap: the first comes back before the second is sent", async () => { + const wrapper = mountFrame(first, realPinia()); + const frame = () => wrapper.get("iframe").element; + const start = frame(); + + useWorkbench().updateBlock({ title: "Your colour?" }); + await vi.advanceTimersByTimeAsync(500); + useWorkbench().updateInteraction({ id: 10, label: "Green" }); + await vi.advanceTimersByTimeAsync(500); + answers[0](); + await flushPromises(); + const shown = frame(); + expect(shown).not.toBe(start); + + // the choice reached the server only now + await vi.advanceTimersByTimeAsync(500); + answers[1](); + await flushPromises(); + expect(frame()).not.toBe(shown); + }); + + it("keeps running after a save that changed nothing", async () => { + const wrapper = mountFrame(first, realPinia()); + const before = wrapper.get("iframe").element; + + // opening a card saves its answer field: empty settings come as [], go back as {} + useWorkbench().updateInteraction({ id: 10, options: {} }); + await vi.advanceTimersByTimeAsync(1000); + answers[0](); + await flushPromises(); + + expect(callUpdateFormBlockInteraction).toHaveBeenCalledOnce(); + expect(wrapper.get("iframe").element).toBe(before); + }); + }); +}); diff --git a/resources/js/components/Factory/Preview/PreviewFrame.vue b/resources/js/components/Factory/Preview/PreviewFrame.vue new file mode 100644 index 00000000..7184773d --- /dev/null +++ b/resources/js/components/Factory/Preview/PreviewFrame.vue @@ -0,0 +1,170 @@ + + + diff --git a/resources/js/components/Factory/Preview/PreviewToggle.vue b/resources/js/components/Factory/Preview/PreviewToggle.vue new file mode 100644 index 00000000..d71073c6 --- /dev/null +++ b/resources/js/components/Factory/Preview/PreviewToggle.vue @@ -0,0 +1,18 @@ + + + diff --git a/resources/js/components/Factory/TopBar/FormMenu.vue b/resources/js/components/Factory/TopBar/FormMenu.vue index f3700d07..916fd14b 100644 --- a/resources/js/components/Factory/TopBar/FormMenu.vue +++ b/resources/js/components/Factory/TopBar/FormMenu.vue @@ -15,11 +15,20 @@ - + +