Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
ac1aef3
feat: show the end screen as the last block of the document
PhilReinking Oct 13, 2026
819c489
Merge remote-tracking branch 'origin/feat/bits-282-document-editor' i…
PhilReinking Oct 13, 2026
ec80e90
docs: add the end screen block to the changelog
PhilReinking Oct 13, 2026
32143ee
fix: load the form again when browser Back returns to Settings
PhilReinking Oct 13, 2026
ab05962
fix: save the end screen when its Settings link or browser Back leave…
PhilReinking Oct 13, 2026
c6a8d88
style: make the open end screen card flat like a question card
PhilReinking Oct 13, 2026
f08f890
fix: show the default end screen headline people see when it's empty
PhilReinking Oct 13, 2026
dd3ac2f
fix: load the form again when browser Back returns to the editor
PhilReinking Oct 13, 2026
0e89538
fix: reload the form after Back only once the end screen save has landed
PhilReinking Oct 13, 2026
c2b787b
fix: reload the form when a page loads while an end screen save is on…
PhilReinking Oct 13, 2026
92c54b5
fix: load the form over the API, so a tab picked meanwhile stays
PhilReinking Oct 13, 2026
2d1b81b
fix: open the end screen card with the form loaded again after Back
PhilReinking Oct 13, 2026
996c41f
Merge remote-tracking branch 'origin/feat/bits-282-document-editor' i…
PhilReinking Oct 13, 2026
c00627e
test: expect the save error text part a uses now
PhilReinking Oct 13, 2026
ad113ff
fix: keep an end screen change when the page reloads or closes
PhilReinking Oct 13, 2026
bcb79eb
Merge remote-tracking branch 'origin/feat/bits-282c-keyboard-move' in…
PhilReinking Oct 13, 2026
ef472fa
fix: give the open end card the question card's phone sizes and one f…
PhilReinking Oct 13, 2026
aa1c1f4
fix: name the end screen in its save error
PhilReinking Oct 13, 2026
4b57119
fix: keep the end screen's hidden label inside the document
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)
- 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)

### Changed

Expand Down
28 changes: 28 additions & 0 deletions resources/js/Pages/Forms/Edit.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import { flushPromises, mount, VueWrapper } from "@vue/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AxiosResponse } from "axios";
import { callGetForm } from "@/api/forms";
import Edit from "./Edit.vue";
import { useForm, useWorkbench } from "@/stores";

vi.mock("@/api/forms", async (importOriginal) => ({
...(await importOriginal()),
callGetForm: vi.fn(),
}));

const form = { uuid: "form1", name: "Customer feedback" } as FormModel;
const blocks = [
{ id: 1, uuid: "b1", type: "none" },
Expand Down Expand Up @@ -118,3 +125,24 @@ describe("editor loading", () => {
expect(useWorkbench().putOnWorkbench).not.toHaveBeenCalled();
});
});

describe("editor from history", () => {
afterEach(() => {
wrapper?.unmount();
wrapper = undefined;
vi.restoreAllMocks();
});

it("loads the form again when browser Back brings the editor back", async () => {
vi.mocked(callGetForm).mockResolvedValue({
data: { ...form, eoc_headline: "Thanks!" },
} as AxiosResponse);
window.dispatchEvent(
new PopStateEvent("popstate", { state: { component: "x" } }),
);

await mountEditor(loads(blocks));

expect(useForm().form?.eoc_headline).toBe("Thanks!");
});
});
2 changes: 2 additions & 0 deletions resources/js/Pages/Forms/Edit.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import AppLayout from "@/Layouts/AppLayout.vue";
import FormDocument from "@/components/Factory/Document/FormDocument.vue";
import { useForm, useWorkbench } from "@/stores";
import { useSaveOnLeave } from "@/components/Factory/Shared/useSaveOnLeave";
import { useFreshForm } from "@/utils/useFreshForm";
import { onBeforeUnmount, onMounted, ref } from "vue";

const props = defineProps<{
Expand Down Expand Up @@ -65,6 +66,7 @@ onMounted(async () => {
await store.getFormBlockMapping();
});

useFreshForm();
workbench.clearWorkbench();
store.$patch({
form: props.form,
Expand Down
133 changes: 133 additions & 0 deletions resources/js/Pages/Forms/Settings.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { flushPromises, mount, VueWrapper } from "@vue/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { router } from "@inertiajs/vue3";
import { AxiosResponse } from "axios";
import { callGetForm } from "@/api/forms";
import { useForm } from "@/stores";
import Settings from "./Settings.vue";
import { trackFormSave } from "@/utils/useFreshForm";

vi.mock("@inertiajs/vue3", async (importOriginal) => ({
...(await importOriginal<typeof import("@inertiajs/vue3")>()),
router: { on: vi.fn() },
}));
vi.mock("@/api/forms", async (importOriginal) => ({
...(await importOriginal()),
callGetForm: vi.fn(),
}));
// its code highlighter needs a global Vue
vi.mock("@/components/Factory/Settings/Embed.vue", () => ({
default: { name: "Embed", template: "<div />" },
}));

const form = { uuid: "form1", name: "Customer feedback" } as FormModel;
// Settings listens for Inertia visits once, when it loads
const [listensTo, onVisit] = vi.mocked(router.on).mock.calls[0] ?? [];

const stubs = {
AppLayout: { props: ["title"], template: "<main><slot /></main>" },
Options: true,
Privacy: true,
// like the real one, it copies the form when it loads
CompletionPage: {
setup: () => ({ headline: useForm().form?.eoc_headline }),
template: "<p>{{ headline }}</p>",
},
Appearance: true,
Embed: true,
TemplateExport: true,
Delete: true,
};

let wrapper: VueWrapper | undefined;

const mountSettings = () => {
wrapper = mount(Settings, { props: { form }, global: { stubs } });
};

describe("settings page from history", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(callGetForm).mockResolvedValue({
data: form,
} as AxiosResponse);
});

afterEach(() => {
wrapper?.unmount();
wrapper = undefined;
});

it("loads the form again when browser Back brings the page back", async () => {
window.dispatchEvent(
new PopStateEvent("popstate", { state: { component: "x" } }),
);

mountSettings();
await flushPromises();

expect(callGetForm).toHaveBeenCalledTimes(1);
});

it("loads nothing more on a normal visit, also one after a Back", async () => {
// a Back to another page, then a link to Settings
window.dispatchEvent(
new PopStateEvent("popstate", { state: { component: "x" } }),
);
expect(listensTo).toBe("before");
onVisit(new CustomEvent("inertia:before"));

mountSettings();
await flushPromises();

expect(callGetForm).not.toHaveBeenCalled();
});

it("lets a visit started while a save is on its way win, and its page loads the form after the save", async () => {
let landed = () => {};
trackFormSave(new Promise<void>((resolve) => (landed = resolve)));
window.dispatchEvent(
new PopStateEvent("popstate", { state: { component: "x" } }),
);
mountSettings();

// you go to another page before the save lands
onVisit(new CustomEvent("inertia:before"));
wrapper?.unmount();
mountSettings();
landed();
await flushPromises();

expect(callGetForm).toHaveBeenCalledTimes(1);
});

it("loads nothing more when only the tab in the address changes", async () => {
window.dispatchEvent(new PopStateEvent("popstate", { state: null }));

mountSettings();
await flushPromises();

expect(callGetForm).not.toHaveBeenCalled();
});

it("keeps the tab you switch to while the form loads again", async () => {
let landed = () => {};
trackFormSave(new Promise<void>((resolve) => (landed = resolve)));
vi.mocked(callGetForm).mockResolvedValue({
data: { ...form, eoc_headline: "Thanks!" },
} as AxiosResponse);
window.dispatchEvent(
new PopStateEvent("popstate", { state: { component: "x" } }),
);
mountSettings();

const tab = wrapper!.findAll("button[role=tab]")[2];
await tab.trigger("click");
landed();
await flushPromises();

expect(location.hash).toBe("#completion-page");
expect(tab.attributes("aria-selected")).toBe("true");
expect(wrapper!.text()).toContain("Thanks!");
});
});
7 changes: 6 additions & 1 deletion resources/js/Pages/Forms/Settings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
</h3>

<div class="mt-6 pb-6">
<component :is="item.component" />
<component :is="item.component" :key="freshForms" />
</div>
</TabPanel>
</TabPanels>
Expand All @@ -73,6 +73,7 @@ import Options from "@/components/Factory/Settings/Options.vue";
import Privacy from "@/components/Factory/Settings/Privacy.vue";
import TemplateExport from "@/components/Factory/Settings/TemplateExport.vue";
import { useForm } from "@/stores";
import { useFreshForm } from "@/utils/useFreshForm";
import { onBeforeUnmount, ref } from "vue";

const props = defineProps<{
Expand Down Expand Up @@ -121,6 +122,10 @@ if (foundIndex !== -1) {
tabChanged(0);
}

// the panels copy the form when they load
const freshForms = ref(0);
useFreshForm(() => freshForms.value++);

onBeforeUnmount(() => {
store.clearForm();
});
Expand Down
4 changes: 3 additions & 1 deletion resources/js/api/forms.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/* eslint-disable no-async-promise-executor */
import { AxiosResponse } from "axios";
import { AxiosRequestConfig, AxiosResponse } from "axios";
import handler from "./handler";

export function callListForms(
Expand Down Expand Up @@ -86,12 +86,14 @@ export function callGetForm(
export function callUpdateForm(
form: FormModel,
values: Partial<FormModel>,
config?: AxiosRequestConfig,
): Promise<AxiosResponse<FormModel>> {
return new Promise(async (resolve, reject) => {
try {
const response = await handler.post(
window.route("api.forms.update", { uuid: form.uuid }),
values,
config,
);

resolve(response as AxiosResponse<FormModel>);
Expand Down
2 changes: 2 additions & 0 deletions resources/js/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
faEnvelope,
faEye,
faEyeSlash,
faFlag,
faGear,
faGlobe,
faGripVertical,
Expand Down Expand Up @@ -100,6 +101,7 @@ library.add(
faEnvelope,
faEye,
faEyeSlash,
faFlag,
faGear,
faGlobe,
faGripVertical,
Expand Down
Loading