diff --git a/.gitignore b/.gitignore index aa20fa5..787712d 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,8 @@ # misc .DS_Store *.pem +.playwright-cli/ +tmp/ # debug npm-debug.log* diff --git a/components/form-builder-panels.tsx b/components/form-builder-panels.tsx index c887946..27067e7 100644 --- a/components/form-builder-panels.tsx +++ b/components/form-builder-panels.tsx @@ -83,6 +83,7 @@ export type FormMetadataDraft = { completionLinkUrl: string; respondentLocale: AppLocale | null; showProgress: boolean; + allowBackNavigation: boolean; slug: string; }; @@ -355,9 +356,34 @@ export function FormSettingsPanel({ })) } /> - - {t("builder.showProgress")} - +
+ + {t("builder.showProgress")} + + + {t("builder.showProgressHelp")} + +
+ + + diff --git a/components/form-builder.test.tsx b/components/form-builder.test.tsx index caf4917..25dad81 100644 --- a/components/form-builder.test.tsx +++ b/components/form-builder.test.tsx @@ -55,6 +55,7 @@ function createForm(blocks: BuilderBlock[]): BuilderForm { completionLinkUrl: null, respondentLocale: null, showProgress: true, + allowBackNavigation: true, slug: "demo-form", status: "DRAFT", updatedAt: "2026-04-14T12:00:00.000Z", @@ -159,6 +160,7 @@ describe("FormBuilder", () => { form: { ...createForm([firstBlock]), respondentLocale: "ru", + allowBackNavigation: true, }, }), } as Response; @@ -241,11 +243,154 @@ describe("FormBuilder", () => { expect(String(fetchCalls[0]?.init?.body)).toContain( '"respondentLocale":null', ); + expect(String(fetchCalls[0]?.init?.body)).toContain( + '"allowBackNavigation":true', + ); + + globalThis.fetch = previousFetch; + await cleanupAndRestore(restoreDom); + }); + + test("saves allowBackNavigation as enabled by default from form settings", async () => { + const restoreDom = installTestDom(); + const firstBlock = createBlock({ + id: "block-1", + type: "SHORT_TEXT", + position: 0, + title: "First question", + }); + const fetchCalls: Array<{ url: string; init?: RequestInit }> = []; + const previousFetch = globalThis.fetch; + + globalThis.fetch = (async ( + url: string | URL | Request, + init?: RequestInit, + ) => { + fetchCalls.push({ url: String(url), init }); + + return { + ok: true, + json: async () => ({ + form: createForm([firstBlock]), + }), + } as Response; + }) as typeof fetch; + + const view = renderBuilder(createForm([firstBlock]), undefined, { + builder: { saveFormSettings: "Save form settings" }, + }); + + fireEvent.click(view.getByRole("button", { name: "builder.settings" })); + + await waitFor(() => { + expect(view.getByText("builder.settingsTitle") !== null).toBe(true); + }); + + fireEvent.click(view.getByRole("button", { name: "Save form settings" })); + + await waitFor(() => { + expect(fetchCalls.length).toBe(1); + }); + + expect(String(fetchCalls[0]?.init?.body)).toContain( + '"allowBackNavigation":true', + ); + + globalThis.fetch = previousFetch; + await cleanupAndRestore(restoreDom); + }); + + test("saves a disabled allowBackNavigation value from form settings", async () => { + const restoreDom = installTestDom(); + const firstBlock = createBlock({ + id: "block-1", + type: "SHORT_TEXT", + position: 0, + title: "First question", + }); + const fetchCalls: Array<{ url: string; init?: RequestInit }> = []; + const previousFetch = globalThis.fetch; + + globalThis.fetch = (async ( + url: string | URL | Request, + init?: RequestInit, + ) => { + fetchCalls.push({ url: String(url), init }); + + return { + ok: true, + json: async () => ({ + form: { + ...createForm([firstBlock]), + allowBackNavigation: false, + }, + }), + } as Response; + }) as typeof fetch; + + const view = renderBuilder(createForm([firstBlock]), undefined, { + builder: { + saveFormSettings: "Save form settings", + allowBackNavigation: "Allow going back", + }, + }); + + fireEvent.click(view.getByRole("button", { name: "builder.settings" })); + + await waitFor(() => { + expect(view.getByText("builder.settingsTitle") !== null).toBe(true); + }); + + fireEvent.click(view.getAllByRole("checkbox")[1] as HTMLInputElement); + fireEvent.click(view.getByRole("button", { name: "Save form settings" })); + + await waitFor(() => { + expect(fetchCalls.length).toBe(1); + }); + + expect(String(fetchCalls[0]?.init?.body)).toContain( + '"allowBackNavigation":false', + ); globalThis.fetch = previousFetch; await cleanupAndRestore(restoreDom); }); + test("reopens form settings with a saved disabled allowBackNavigation value", async () => { + const restoreDom = installTestDom(); + const firstBlock = createBlock({ + id: "block-1", + type: "SHORT_TEXT", + position: 0, + title: "First question", + }); + + const view = renderBuilder( + { + ...createForm([firstBlock]), + allowBackNavigation: false, + }, + undefined, + { + builder: { + allowBackNavigation: "Allow going back", + }, + }, + ); + + fireEvent.click(view.getByRole("button", { name: "builder.settings" })); + + await waitFor(() => { + expect(view.getByText("builder.settingsTitle") !== null).toBe(true); + }); + + expect((view.getAllByRole("checkbox")[1] as HTMLInputElement).checked).toBe( + false, + ); + + await cleanupAndRestore(restoreDom); + }); + test("lets the creator cancel or discard unsaved changes before opening form settings", async () => { const restoreDom = installTestDom(); const firstBlock = createBlock({ diff --git a/components/form-builder.tsx b/components/form-builder.tsx index 98056a0..63d048c 100644 --- a/components/form-builder.tsx +++ b/components/form-builder.tsx @@ -286,6 +286,7 @@ export function FormBuilder({ completionLinkUrl: initialForm.completionLinkUrl ?? "", respondentLocale: initialForm.respondentLocale, showProgress: initialForm.showProgress, + allowBackNavigation: initialForm.allowBackNavigation, slug: initialForm.slug, }); @@ -377,6 +378,7 @@ export function FormBuilder({ completionLinkUrl: form.completionLinkUrl ?? "", respondentLocale: form.respondentLocale, showProgress: form.showProgress, + allowBackNavigation: form.allowBackNavigation, slug: form.slug, }); }, [ @@ -387,6 +389,7 @@ export function FormBuilder({ form.completionLinkUrl, form.respondentLocale, form.showProgress, + form.allowBackNavigation, form.slug, ]); diff --git a/components/public-form-runner.test.tsx b/components/public-form-runner.test.tsx new file mode 100644 index 0000000..ea7d525 --- /dev/null +++ b/components/public-form-runner.test.tsx @@ -0,0 +1,202 @@ +import { describe, expect, test } from "bun:test"; +import { cleanup, render, waitFor } from "@testing-library/react"; + +import { I18nProvider } from "@/components/i18n-provider"; +import { PublicFormRunner } from "@/components/public-form-runner"; +import type { SerializedBlock } from "@/lib/blocks"; +import { createPublicFormDraft } from "@/lib/public-form-draft"; +import type { PublicForm } from "@/lib/form-types"; +import { installTestDom } from "@/test/install-dom"; + +function createBlock(overrides: { + id: string; + type: "TEXT" | "SHORT_TEXT" | "SINGLE_CHOICE"; + position: number; + title?: string; + config?: Record; +}): SerializedBlock { + const now = new Date("2026-04-15T12:00:00.000Z"); + + return { + id: overrides.id, + formId: "form-1", + type: overrides.type, + title: overrides.title ?? overrides.id, + description: "", + required: false, + position: overrides.position, + createdAt: now, + updatedAt: now, + config: + overrides.config ?? + (overrides.type === "TEXT" + ? { body: "Intro" } + : overrides.type === "SINGLE_CHOICE" + ? { + options: ["yes", "no"], + branchRules: [], + defaultNextBlockId: null, + } + : { + placeholder: "", + validationRegex: null, + branchRules: [], + defaultNextBlockId: null, + }), + } as SerializedBlock; +} + +function createForm( + blocks: PublicForm["blocks"], + overrides: Partial = {}, +): PublicForm { + return { + id: "form-1", + title: "Demo form", + description: "", + completionTitle: "Thanks", + completionMessage: "Done", + completionLinkLabel: null, + completionLinkUrl: null, + respondentLocale: null, + showProgress: false, + allowBackNavigation: true, + slug: "demo-form", + blocks, + ...overrides, + }; +} + +function renderRunner(form: PublicForm) { + return render( + + + , + ); +} + +async function cleanupAndRestore(restoreDom: () => void) { + cleanup(); + await new Promise((resolve) => setTimeout(resolve, 0)); + restoreDom(); +} + +describe("PublicFormRunner", () => { + test("shows the back button only when the form allows back navigation", async () => { + const restoreDom = installTestDom(); + const blocks = [ + createBlock({ id: "intro", type: "TEXT", position: 0, title: "Intro" }), + createBlock({ + id: "name", + type: "SHORT_TEXT", + position: 1, + title: "Name", + }), + ]; + + const enabledView = renderRunner(createForm(blocks)); + expect(enabledView.getByRole("button", { name: /Back/i }) !== null).toBe( + true, + ); + cleanup(); + + const disabledView = renderRunner( + createForm(blocks, { allowBackNavigation: false }), + ); + expect(disabledView.queryByRole("button", { name: /Back/i }) === null).toBe( + true, + ); + + await cleanupAndRestore(restoreDom); + }); + + test("restored sessions with back navigation disabled do not expose or trigger back navigation", async () => { + const restoreDom = installTestDom(); + const form = createForm( + [ + createBlock({ + id: "name", + type: "SHORT_TEXT", + position: 0, + title: "Name", + }), + createBlock({ + id: "notes", + type: "SHORT_TEXT", + position: 1, + title: "Notes", + }), + ], + { allowBackNavigation: false }, + ); + + window.localStorage.setItem( + `lively-forms-public-runner-draft:${form.id}`, + JSON.stringify( + createPublicFormDraft(form, { + answers: { name: "Ada", notes: "Saved" }, + history: ["name", "notes"], + cursor: 1, + }), + ), + ); + + const view = renderRunner(form); + + await waitFor(() => { + expect(view.getByText("Progress restored") !== null).toBe(true); + }); + expect(view.queryByRole("button", { name: /Back/i }) === null).toBe(true); + + await cleanupAndRestore(restoreDom); + }); + + test("restored sessions with back navigation enabled still allow going back", async () => { + const restoreDom = installTestDom(); + const form = createForm([ + createBlock({ + id: "name", + type: "SHORT_TEXT", + position: 0, + title: "Name", + }), + createBlock({ + id: "notes", + type: "SHORT_TEXT", + position: 1, + title: "Notes", + }), + ]); + + window.localStorage.setItem( + `lively-forms-public-runner-draft:${form.id}`, + JSON.stringify( + createPublicFormDraft(form, { + answers: { name: "Ada", notes: "Saved" }, + history: ["name", "notes"], + cursor: 1, + }), + ), + ); + + const view = renderRunner(form); + + await waitFor(() => { + expect(view.getByText("Progress restored") !== null).toBe(true); + }); + expect(view.getByRole("button", { name: /Back/i }) !== null).toBe(true); + + await cleanupAndRestore(restoreDom); + }); +}); diff --git a/components/public-form-runner.tsx b/components/public-form-runner.tsx index b59bb90..c799b8a 100644 --- a/components/public-form-runner.tsx +++ b/components/public-form-runner.tsx @@ -144,6 +144,7 @@ export function PublicFormRunner({ form }: { form: PublicForm }) { : form.completionMessage.trim(); const completionLinkLabel = form.completionLinkLabel?.trim() || null; const completionLinkUrl = form.completionLinkUrl?.trim() || null; + const allowBackNavigation = form.allowBackNavigation; function setAnswer(blockId: string, value: AnswerValue) { setAnswers((current) => ({ @@ -354,8 +355,8 @@ export function PublicFormRunner({ form }: { form: PublicForm }) { ); const handleBack = useCallback(() => { - setRoute((current) => retreatRunnerRoute(current)); - }, []); + setRoute((current) => retreatRunnerRoute(current, { allowBackNavigation })); + }, [allowBackNavigation]); function handleAdvanceKeyDown( event: ReactKeyboardEvent, @@ -427,7 +428,7 @@ export function PublicFormRunner({ form }: { form: PublicForm }) { return; } - if (event.key === "Escape" && cursor > 0) { + if (event.key === "Escape" && cursor > 0 && allowBackNavigation) { event.preventDefault(); handleBack(); } @@ -437,6 +438,7 @@ export function PublicFormRunner({ form }: { form: PublicForm }) { return () => window.removeEventListener("keydown", handleWindowKeyDown); }, [ currentBlock, + allowBackNavigation, cursor, handleBack, handleContinue, @@ -830,16 +832,20 @@ export function PublicFormRunner({ form }: { form: PublicForm }) {
- + {allowBackNavigation ? ( + + ) : ( +
+ )}