From a2f05134f0c1df4e5a00dcb0436b77c6f3551bce Mon Sep 17 00:00:00 2001 From: Philipp Reinking Date: Tue, 13 Oct 2026 02:17:09 +0200 Subject: [PATCH 01/19] feat: show a live preview beside the document Preview in the top bar shows the public form in preview mode: beside the document on a desktop, as a panel over it on tablet and phone. It follows the open question, and the document follows Next and Back in the preview. A saved change reloads it at the open question. Co-Authored-By: Claude Opus 5.5 --- resources/js/Pages/Forms/Edit.vue | 7 + .../Factory/Preview/FormPreview.test.ts | 89 +++++++++ .../Factory/Preview/FormPreview.vue | 47 +++++ .../Factory/Preview/PreviewFrame.test.ts | 189 ++++++++++++++++++ .../Factory/Preview/PreviewFrame.vue | 125 ++++++++++++ .../Factory/Preview/PreviewToggle.vue | 18 ++ 6 files changed, 475 insertions(+) create mode 100644 resources/js/components/Factory/Preview/FormPreview.test.ts create mode 100644 resources/js/components/Factory/Preview/FormPreview.vue create mode 100644 resources/js/components/Factory/Preview/PreviewFrame.test.ts create mode 100644 resources/js/components/Factory/Preview/PreviewFrame.vue create mode 100644 resources/js/components/Factory/Preview/PreviewToggle.vue diff --git a/resources/js/Pages/Forms/Edit.vue b/resources/js/Pages/Forms/Edit.vue index 29c8a279..ab459972 100644 --- a/resources/js/Pages/Forms/Edit.vue +++ b/resources/js/Pages/Forms/Edit.vue @@ -1,14 +1,19 @@ 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..a5574d87 --- /dev/null +++ b/resources/js/components/Factory/Preview/PreviewFrame.test.ts @@ -0,0 +1,189 @@ +import { enableAutoUnmount, 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 { nextTick } from "vue"; +import { useForm, useLogic, useWorkbench } from "@/stores"; +import { useNotice } from "@/stores/notice"; +import PreviewFrame from "./PreviewFrame.vue"; + +// 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, interactions: [{ 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; + +enableAutoUnmount(afterEach); + +const mountFrame = (open: FormBlockModel | null = first, pinia?: Pinia) => { + useForm().form = { uuid: "form1" } as FormModel; + useForm().blocks = [{ ...first }, { ...second }, { ...group }]; + useWorkbench().block = open; + + 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(); + }); + + 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("starts at the first question when none is open", () => { + expect(mountFrame(null).get("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.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 save", () => useNotice().clearSaveError("saveBlock:1")], + ["saved rules", () => useLogic().saveBlockLogic()], + ])("loads the form again at the open question after %s", async (_, save) => { + const pinia = createPinia(); + setActivePinia(pinia); + const wrapper = mountFrame(first, pinia); + const before = wrapper.get("iframe").element; + + useWorkbench().block = second; + useForm().blocks![1].message = "

Changed

"; + await save(); + await nextTick(); + + const iframe = wrapper.get("iframe"); + expect(iframe.element).not.toBe(before); + expect(iframe.attributes("src")).toBe("/form1?preview=1&block=q2uuid"); + }); + + it("keeps running after a save that changed nothing", async () => { + const pinia = createPinia(); + setActivePinia(pinia); + const wrapper = mountFrame(first, pinia); + const before = wrapper.get("iframe").element; + + // opening a card saves its answer field: empty settings come as [], go back as {} + useForm().blocks![0].interactions = [ + { options: {} }, + ] as unknown as FormBlockInteractionModel[]; + useNotice().clearSaveError("saveInteraction:1"); + await nextTick(); + + expect(wrapper.get("iframe").element).toBe(before); + }); + + it("compares with the questions as they load, after the preview", async () => { + const pinia = createPinia(); + setActivePinia(pinia); + useForm().form = { uuid: "form1" } as FormModel; + const wrapper = mount(PreviewFrame, { + attachTo: document.body, + global: { plugins: [pinia] }, + }); + const before = wrapper.get("iframe").element; + + useForm().blocks = [{ ...first }]; + await nextTick(); + useNotice().clearSaveError("saveInteraction:1"); + await nextTick(); + + 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..474430fd --- /dev/null +++ b/resources/js/components/Factory/Preview/PreviewFrame.vue @@ -0,0 +1,125 @@ + + + diff --git a/resources/js/components/Factory/Preview/PreviewToggle.vue b/resources/js/components/Factory/Preview/PreviewToggle.vue new file mode 100644 index 00000000..1ee50b0f --- /dev/null +++ b/resources/js/components/Factory/Preview/PreviewToggle.vue @@ -0,0 +1,18 @@ + + + From bbfa3ed7086f927ae532b1bf6c15d52c9b523590 Mon Sep 17 00:00:00 2001 From: Philipp Reinking Date: Tue, 13 Oct 2026 02:17:09 +0200 Subject: [PATCH 02/19] feat: say in the form's preview that answers aren't saved The preview shows a small note instead of the owner's orange bar, also on a copied preview link. Co-Authored-By: Claude Opus 5.5 --- resources/js/forms/classic/ClassicForm.vue | 7 +++++++ resources/views/form.blade.php | 3 ++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/resources/js/forms/classic/ClassicForm.vue b/resources/js/forms/classic/ClassicForm.vue index e2316082..a230da70 100644 --- a/resources/js/forms/classic/ClassicForm.vue +++ b/resources/js/forms/classic/ClassicForm.vue @@ -13,6 +13,13 @@ }" >
+ +

+ Preview: answers aren't saved +

diff --git a/resources/views/form.blade.php b/resources/views/form.blade.php index f8917422..a4a5b967 100644 --- a/resources/views/form.blade.php +++ b/resources/views/form.blade.php @@ -73,7 +73,8 @@ @auth - @if(!$form->is_published && auth()->user()->id === $form->user_id) + {{-- the editor's preview shows its own note --}} + @if(!$form->is_published && auth()->user()->id === $form->user_id && request()->query('preview') !== '1')
You are viewing a preview of your form. From f750209942ff6ffd7d9804bad34797d9304b4be2 Mon Sep 17 00:00:00 2001 From: Philipp Reinking Date: Tue, 13 Oct 2026 02:20:48 +0200 Subject: [PATCH 03/19] feat: put Preview beside the question count on a phone The phone's top bar keeps its room for the form name, as before. Co-Authored-By: Claude Opus 5.5 --- resources/js/Pages/Forms/Edit.test.ts | 17 +++++++++++++++++ resources/js/Pages/Forms/Edit.vue | 15 ++++++++++++++- .../Factory/Document/FormDocument.vue | 9 ++++++--- .../Factory/Preview/PreviewToggle.vue | 4 ++-- 4 files changed, 39 insertions(+), 6 deletions(-) diff --git a/resources/js/Pages/Forms/Edit.test.ts b/resources/js/Pages/Forms/Edit.test.ts index fba82d18..19d8215e 100644 --- a/resources/js/Pages/Forms/Edit.test.ts +++ b/resources/js/Pages/Forms/Edit.test.ts @@ -13,6 +13,10 @@ const stubs = { AppLayout: { props: ["title"], template: "
" }, DocumentList: { template: "
  1. questions
" }, BlockLogicEditor: true, + FormPreview: { + props: ["modelValue"], + template: "", + }, }; let wrapper: VueWrapper | undefined; @@ -117,4 +121,17 @@ 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); + }); }); diff --git a/resources/js/Pages/Forms/Edit.vue b/resources/js/Pages/Forms/Edit.vue index ab459972..55f6d11b 100644 --- a/resources/js/Pages/Forms/Edit.vue +++ b/resources/js/Pages/Forms/Edit.vue @@ -1,7 +1,19 @@ @@ -14,6 +26,7 @@ import PreviewToggle from "@/components/Factory/Preview/PreviewToggle.vue"; import { useForm, useWorkbench } from "@/stores"; import { onBeforeUnmount, onMounted, ref } from "vue"; import { useMediaQuery } from "@vueuse/core"; +import { D9Button } from "@deck9/ui"; const props = defineProps<{ form: FormModel; diff --git a/resources/js/components/Factory/Document/FormDocument.vue b/resources/js/components/Factory/Document/FormDocument.vue index 58ac1ce4..9ce21967 100644 --- a/resources/js/components/Factory/Document/FormDocument.vue +++ b/resources/js/components/Factory/Document/FormDocument.vue @@ -11,9 +11,12 @@ - + { const item = await previewItem(false); expect(text(item!)).toBe("Preview ↗"); expect(item?.getAttribute("aria-label")).toBe("Preview (opens in a new tab)"); - expect(item?.getAttribute("href")).toBe(`${location.origin}/forms.show`); + // in preview mode: no answers are stored + expect(item?.getAttribute("href")).toBe(`${location.origin}/forms.show?preview=1`); expect(item?.getAttribute("target")).toBe("_blank"); // from 1024 px it's in the bar expect(await previewItem(true)).toBeUndefined(); diff --git a/resources/js/components/Factory/TopBar/FormTopBar.vue b/resources/js/components/Factory/TopBar/FormTopBar.vue index 0a5f686f..76a0ac0e 100644 --- a/resources/js/components/Factory/TopBar/FormTopBar.vue +++ b/resources/js/components/Factory/TopBar/FormTopBar.vue @@ -55,10 +55,11 @@ > - +