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 ? (
+
+ ) : (
+
+ )}