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: "
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 @@
-
+
+