Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
a2f0513
feat: show a live preview beside the document
PhilReinking Oct 13, 2026
bbfa3ed
feat: say in the form's preview that answers aren't saved
PhilReinking Oct 13, 2026
f750209
feat: put Preview beside the question count on a phone
PhilReinking Oct 13, 2026
cbfd30d
docs: add changelog line for #273
PhilReinking Oct 13, 2026
de485c2
fix: open the top bar's Preview links in preview mode
PhilReinking Oct 13, 2026
e75dc55
fix: put focus back on the phone's Preview after the panel closes
PhilReinking Oct 13, 2026
4964034
fix: give the phone's Preview a 44 px tap area
PhilReinking Oct 13, 2026
7baa898
fix: close the preview when the window gets narrower than a desktop
PhilReinking Oct 13, 2026
98562b3
feat: say "Add a question to see it here." in an empty form's preview
PhilReinking Oct 13, 2026
852103e
feat: say which question the preview shows, and when a rule hides the…
PhilReinking Oct 13, 2026
682709f
Merge remote-tracking branch 'origin/feat/bits-282b-outline-phone' in…
PhilReinking Oct 13, 2026
20e5418
fix: fit Outline and Preview in the phone's count row
PhilReinking Oct 13, 2026
7e08a5e
feat: open the editor's preview from the phone's form menu
PhilReinking Oct 13, 2026
3ac5e2d
fix: reload the preview after deleting a question, a choice or a rule
PhilReinking Oct 13, 2026
8af59b7
fix: show the last of two overlapping saves in the preview
PhilReinking Oct 13, 2026
0514426
fix: start the preview at the open question, without a jump
PhilReinking Oct 13, 2026
4469fb7
fix: return focus to the form menu after its preview closes
PhilReinking Oct 13, 2026
a83d560
Merge remote-tracking branch 'origin/feat/bits-282b-outline-phone' in…
PhilReinking Oct 13, 2026
12ed786
Merge remote-tracking branch 'origin/feat/bits-282c-keyboard-move' in…
PhilReinking Oct 13, 2026
f0e5ccc
fix: start the preview at once when no question is open
PhilReinking Oct 13, 2026
072884f
fix: say why the preview can't show a hidden question
PhilReinking Oct 13, 2026
a1f2a83
docs: say the other pages' Preview saves no answers either
PhilReinking Oct 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 3 additions & 2 deletions resources/js/Layouts/AppLayout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand All @@ -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
Expand Down
77 changes: 75 additions & 2 deletions resources/js/Pages/Forms/Edit.test.ts
Original file line number Diff line number Diff line change
@@ -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" },
Expand All @@ -13,16 +24,23 @@ const stubs = {
AppLayout: { props: ["title"], template: "<main><slot /></main>" },
DocumentList: { template: "<ol><li>questions</li></ol>" },
BlockLogicEditor: true,
FormPreview: {
props: ["modelValue"],
template: "<aside v-if='modelValue'>preview</aside>",
},
};

let wrapper: VueWrapper | undefined;

// the test pinia stubs actions: getBlocks does what each test tells it to
const mountEditor = async (load: () => Promise<void>) => {
const mountEditor = async (
load: () => Promise<void>,
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();
Expand Down Expand Up @@ -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: "<main><FormMenu /><slot /></main>",
},
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<HTMLElement>('[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);
});
});
31 changes: 29 additions & 2 deletions resources/js/Pages/Forms/Edit.vue
Original file line number Diff line number Diff line change
@@ -1,15 +1,37 @@
<template>
<app-layout title="Edit Form" limit-height>
<FormDocument :status="status" @retry="loadBlocks" />
<template #preview><PreviewToggle v-model="isPreviewing" /></template>
<FormDocument :status="status" @retry="loadBlocks">
<!-- a phone's top bar has no room for Preview; it looks like Outline beside it,
an icon below sm; a click inside lands on the button, so closing the
preview puts focus back on it -->
<template #actions>
<button
type="button"
class="flex h-11 w-11 items-center justify-center gap-1.5 rounded-md border border-grey-300 bg-white px-2.5 text-[13px] font-semibold text-grey-700 hover:bg-grey-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 sm:w-auto lg:hidden [&_*]:pointer-events-none"
aria-haspopup="dialog"
:aria-expanded="isPreviewing"
@click="isPreviewing = true"
>
<D9Icon name="eye" aria-hidden="true" />
<span class="sr-only sm:not-sr-only">Preview</span>
</button>
</template>
</FormDocument>
<FormPreview v-model="isPreviewing" />
</app-layout>
</template>

<script setup lang="ts">
import AppLayout from "@/Layouts/AppLayout.vue";
import FormDocument from "@/components/Factory/Document/FormDocument.vue";
import FormPreview from "@/components/Factory/Preview/FormPreview.vue";
import PreviewToggle from "@/components/Factory/Preview/PreviewToggle.vue";
import { useForm, useWorkbench } from "@/stores";
import { useSaveOnLeave } from "@/components/Factory/Shared/useSaveOnLeave";
import { onBeforeUnmount, onMounted, ref } from "vue";
import { onBeforeUnmount, onMounted, provide, ref } from "vue";
import { useMediaQuery } from "@vueuse/core";
import { D9Icon } from "@deck9/ui";

const props = defineProps<{
form: FormModel;
Expand All @@ -18,6 +40,11 @@ const props = defineProps<{
const store = useForm();
const workbench = useWorkbench();
const status = ref<"loading" | "failed" | "loaded">("loading");
// on a desktop the preview sits beside the document, so it shows at once
const isPreviewing = ref(useMediaQuery("(min-width: 1280px)").value);
// the form menu's Preview, for when the button above the questions scrolled away;
// it opens once the menu gave focus back to its button, so closing returns there
provide("openPreview", () => setTimeout(() => (isPreviewing.value = true)));

const loadBlocks = async () => {
status.value = "loading";
Expand Down
18 changes: 18 additions & 0 deletions resources/js/components/Factory/Document/FormDocument.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,24 @@ describe("the form document", () => {
expect(scrolled).toHaveBeenCalledWith("row-b2", "start");
});

it("puts Outline and the page's actions side by side, as 44 px icons below sm", () => {
useForm().blocks = blocks;
wrapper = mount(FormDocument, {
props: { status: "loaded" },
slots: { actions: "<button type='button'>Preview</button>" },
global: { stubs },
});

const outline = wrapper
.findAll("button")
.find((button) => button.text() === "Outline");
expect(outline?.classes()).toEqual(
expect.arrayContaining(["h-11", "w-11", "sm:w-auto"]),
);
expect(outline?.get("span").classes()).toEqual(["sr-only", "sm:not-sr-only"]);
expect(outline?.element.nextElementSibling?.textContent).toBe("Preview");
});

it("reads out where a moved question went", async () => {
useForm().blocks = blocks;
wrapper = mount(FormDocument, {
Expand Down
29 changes: 17 additions & 12 deletions resources/js/components/Factory/Document/FormDocument.vue
Original file line number Diff line number Diff line change
Expand Up @@ -26,18 +26,23 @@
<h1 class="sr-only">Questions</h1>
<div class="mb-4 flex items-center justify-between gap-2 pl-12">
<p class="text-sm tabular-nums text-grey-600">{{ countLine }}</p>
<!-- from lg it sits in the margin, out of the scrolling column, so it stays in view -->
<button
v-if="store.hasBlocks"
type="button"
class="flex h-11 items-center gap-1.5 rounded-md border border-grey-300 bg-white px-2.5 text-[13px] font-semibold text-grey-700 hover:bg-grey-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 lg:absolute lg:h-8 lg:left-4 lg:top-3 lg:z-10 xl:hidden"
aria-haspopup="dialog"
:aria-expanded="outlineOpen"
@click="outlineOpen = true"
>
<D9Icon name="list-ul" aria-hidden="true" />
Outline
</button>
<div class="flex gap-2">
<!-- from lg it sits in the margin, out of the scrolling column, so it stays in view;
below sm an icon, so Preview fits beside it; clicks land on the button,
so focus can go back to it -->
<button
v-if="store.hasBlocks"
type="button"
class="flex h-11 w-11 items-center justify-center gap-1.5 rounded-md border border-grey-300 bg-white px-2.5 text-[13px] font-semibold text-grey-700 hover:bg-grey-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 sm:w-auto lg:absolute lg:h-8 lg:left-4 lg:top-3 lg:z-10 xl:hidden [&_*]:pointer-events-none"
aria-haspopup="dialog"
:aria-expanded="outlineOpen"
@click="outlineOpen = true"
>
<D9Icon name="list-ul" aria-hidden="true" />
<span class="sr-only sm:not-sr-only">Outline</span>
</button>
<slot name="actions" />
</div>
</div>

<DocumentList v-if="store.hasBlocks" :numbers="numbers" />
Expand Down
Loading