this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: restrict back navigation in form runner

+700 -23
+2
.gitignore
··· 22 22 # misc 23 23 .DS_Store 24 24 *.pem 25 + .playwright-cli/ 26 + tmp/ 25 27 26 28 # debug 27 29 npm-debug.log*
+29 -3
components/form-builder-panels.tsx
··· 83 83 completionLinkUrl: string; 84 84 respondentLocale: AppLocale | null; 85 85 showProgress: boolean; 86 + allowBackNavigation: boolean; 86 87 slug: string; 87 88 }; 88 89 ··· 355 356 })) 356 357 } 357 358 /> 358 - <span className="font-medium text-[var(--ink)]"> 359 - {t("builder.showProgress")} 360 - </span> 359 + <div className="grid gap-1"> 360 + <span className="font-medium text-[var(--ink)]"> 361 + {t("builder.showProgress")} 362 + </span> 363 + <span className="text-xs text-[var(--muted)]"> 364 + {t("builder.showProgressHelp")} 365 + </span> 366 + </div> 367 + </label> 368 + 369 + <label className="flex items-start gap-3 text-sm text-[var(--muted)]"> 370 + <Checkbox 371 + checked={metadataDraft.allowBackNavigation} 372 + onChange={(event) => 373 + setMetadataDraft((current) => ({ 374 + ...current, 375 + allowBackNavigation: event.target.checked, 376 + })) 377 + } 378 + /> 379 + <div className="grid gap-1"> 380 + <span className="font-medium text-[var(--ink)]"> 381 + {t("builder.allowBackNavigation")} 382 + </span> 383 + <span className="text-xs text-[var(--muted)]"> 384 + {t("builder.allowBackNavigationHelp")} 385 + </span> 386 + </div> 361 387 </label> 362 388 </div> 363 389
+145
components/form-builder.test.tsx
··· 55 55 completionLinkUrl: null, 56 56 respondentLocale: null, 57 57 showProgress: true, 58 + allowBackNavigation: true, 58 59 slug: "demo-form", 59 60 status: "DRAFT", 60 61 updatedAt: "2026-04-14T12:00:00.000Z", ··· 159 160 form: { 160 161 ...createForm([firstBlock]), 161 162 respondentLocale: "ru", 163 + allowBackNavigation: true, 162 164 }, 163 165 }), 164 166 } as Response; ··· 241 243 expect(String(fetchCalls[0]?.init?.body)).toContain( 242 244 '"respondentLocale":null', 243 245 ); 246 + expect(String(fetchCalls[0]?.init?.body)).toContain( 247 + '"allowBackNavigation":true', 248 + ); 244 249 245 250 globalThis.fetch = previousFetch; 251 + await cleanupAndRestore(restoreDom); 252 + }); 253 + 254 + test("saves allowBackNavigation as enabled by default from form settings", async () => { 255 + const restoreDom = installTestDom(); 256 + const firstBlock = createBlock({ 257 + id: "block-1", 258 + type: "SHORT_TEXT", 259 + position: 0, 260 + title: "First question", 261 + }); 262 + const fetchCalls: Array<{ url: string; init?: RequestInit }> = []; 263 + const previousFetch = globalThis.fetch; 264 + 265 + globalThis.fetch = (async ( 266 + url: string | URL | Request, 267 + init?: RequestInit, 268 + ) => { 269 + fetchCalls.push({ url: String(url), init }); 270 + 271 + return { 272 + ok: true, 273 + json: async () => ({ 274 + form: createForm([firstBlock]), 275 + }), 276 + } as Response; 277 + }) as typeof fetch; 278 + 279 + const view = renderBuilder(createForm([firstBlock]), undefined, { 280 + builder: { saveFormSettings: "Save form settings" }, 281 + }); 282 + 283 + fireEvent.click(view.getByRole("button", { name: "builder.settings" })); 284 + 285 + await waitFor(() => { 286 + expect(view.getByText("builder.settingsTitle") !== null).toBe(true); 287 + }); 288 + 289 + fireEvent.click(view.getByRole("button", { name: "Save form settings" })); 290 + 291 + await waitFor(() => { 292 + expect(fetchCalls.length).toBe(1); 293 + }); 294 + 295 + expect(String(fetchCalls[0]?.init?.body)).toContain( 296 + '"allowBackNavigation":true', 297 + ); 298 + 299 + globalThis.fetch = previousFetch; 300 + await cleanupAndRestore(restoreDom); 301 + }); 302 + 303 + test("saves a disabled allowBackNavigation value from form settings", async () => { 304 + const restoreDom = installTestDom(); 305 + const firstBlock = createBlock({ 306 + id: "block-1", 307 + type: "SHORT_TEXT", 308 + position: 0, 309 + title: "First question", 310 + }); 311 + const fetchCalls: Array<{ url: string; init?: RequestInit }> = []; 312 + const previousFetch = globalThis.fetch; 313 + 314 + globalThis.fetch = (async ( 315 + url: string | URL | Request, 316 + init?: RequestInit, 317 + ) => { 318 + fetchCalls.push({ url: String(url), init }); 319 + 320 + return { 321 + ok: true, 322 + json: async () => ({ 323 + form: { 324 + ...createForm([firstBlock]), 325 + allowBackNavigation: false, 326 + }, 327 + }), 328 + } as Response; 329 + }) as typeof fetch; 330 + 331 + const view = renderBuilder(createForm([firstBlock]), undefined, { 332 + builder: { 333 + saveFormSettings: "Save form settings", 334 + allowBackNavigation: "Allow going back", 335 + }, 336 + }); 337 + 338 + fireEvent.click(view.getByRole("button", { name: "builder.settings" })); 339 + 340 + await waitFor(() => { 341 + expect(view.getByText("builder.settingsTitle") !== null).toBe(true); 342 + }); 343 + 344 + fireEvent.click(view.getAllByRole("checkbox")[1] as HTMLInputElement); 345 + fireEvent.click(view.getByRole("button", { name: "Save form settings" })); 346 + 347 + await waitFor(() => { 348 + expect(fetchCalls.length).toBe(1); 349 + }); 350 + 351 + expect(String(fetchCalls[0]?.init?.body)).toContain( 352 + '"allowBackNavigation":false', 353 + ); 354 + 355 + globalThis.fetch = previousFetch; 356 + await cleanupAndRestore(restoreDom); 357 + }); 358 + 359 + test("reopens form settings with a saved disabled allowBackNavigation value", async () => { 360 + const restoreDom = installTestDom(); 361 + const firstBlock = createBlock({ 362 + id: "block-1", 363 + type: "SHORT_TEXT", 364 + position: 0, 365 + title: "First question", 366 + }); 367 + 368 + const view = renderBuilder( 369 + { 370 + ...createForm([firstBlock]), 371 + allowBackNavigation: false, 372 + }, 373 + undefined, 374 + { 375 + builder: { 376 + allowBackNavigation: "Allow going back", 377 + }, 378 + }, 379 + ); 380 + 381 + fireEvent.click(view.getByRole("button", { name: "builder.settings" })); 382 + 383 + await waitFor(() => { 384 + expect(view.getByText("builder.settingsTitle") !== null).toBe(true); 385 + }); 386 + 387 + expect((view.getAllByRole("checkbox")[1] as HTMLInputElement).checked).toBe( 388 + false, 389 + ); 390 + 246 391 await cleanupAndRestore(restoreDom); 247 392 }); 248 393
+3
components/form-builder.tsx
··· 286 286 completionLinkUrl: initialForm.completionLinkUrl ?? "", 287 287 respondentLocale: initialForm.respondentLocale, 288 288 showProgress: initialForm.showProgress, 289 + allowBackNavigation: initialForm.allowBackNavigation, 289 290 slug: initialForm.slug, 290 291 }); 291 292 ··· 377 378 completionLinkUrl: form.completionLinkUrl ?? "", 378 379 respondentLocale: form.respondentLocale, 379 380 showProgress: form.showProgress, 381 + allowBackNavigation: form.allowBackNavigation, 380 382 slug: form.slug, 381 383 }); 382 384 }, [ ··· 387 389 form.completionLinkUrl, 388 390 form.respondentLocale, 389 391 form.showProgress, 392 + form.allowBackNavigation, 390 393 form.slug, 391 394 ]); 392 395
+202
components/public-form-runner.test.tsx
··· 1 + import { describe, expect, test } from "bun:test"; 2 + import { cleanup, render, waitFor } from "@testing-library/react"; 3 + 4 + import { I18nProvider } from "@/components/i18n-provider"; 5 + import { PublicFormRunner } from "@/components/public-form-runner"; 6 + import type { SerializedBlock } from "@/lib/blocks"; 7 + import { createPublicFormDraft } from "@/lib/public-form-draft"; 8 + import type { PublicForm } from "@/lib/form-types"; 9 + import { installTestDom } from "@/test/install-dom"; 10 + 11 + function createBlock(overrides: { 12 + id: string; 13 + type: "TEXT" | "SHORT_TEXT" | "SINGLE_CHOICE"; 14 + position: number; 15 + title?: string; 16 + config?: Record<string, unknown>; 17 + }): SerializedBlock { 18 + const now = new Date("2026-04-15T12:00:00.000Z"); 19 + 20 + return { 21 + id: overrides.id, 22 + formId: "form-1", 23 + type: overrides.type, 24 + title: overrides.title ?? overrides.id, 25 + description: "", 26 + required: false, 27 + position: overrides.position, 28 + createdAt: now, 29 + updatedAt: now, 30 + config: 31 + overrides.config ?? 32 + (overrides.type === "TEXT" 33 + ? { body: "Intro" } 34 + : overrides.type === "SINGLE_CHOICE" 35 + ? { 36 + options: ["yes", "no"], 37 + branchRules: [], 38 + defaultNextBlockId: null, 39 + } 40 + : { 41 + placeholder: "", 42 + validationRegex: null, 43 + branchRules: [], 44 + defaultNextBlockId: null, 45 + }), 46 + } as SerializedBlock; 47 + } 48 + 49 + function createForm( 50 + blocks: PublicForm["blocks"], 51 + overrides: Partial<PublicForm> = {}, 52 + ): PublicForm { 53 + return { 54 + id: "form-1", 55 + title: "Demo form", 56 + description: "", 57 + completionTitle: "Thanks", 58 + completionMessage: "Done", 59 + completionLinkLabel: null, 60 + completionLinkUrl: null, 61 + respondentLocale: null, 62 + showProgress: false, 63 + allowBackNavigation: true, 64 + slug: "demo-form", 65 + blocks, 66 + ...overrides, 67 + }; 68 + } 69 + 70 + function renderRunner(form: PublicForm) { 71 + return render( 72 + <I18nProvider 73 + locale="en" 74 + messages={{ 75 + publicRunner: { 76 + back: "Back", 77 + continue: "Continue", 78 + submit: "Submit", 79 + progressRestored: "Progress restored", 80 + savedProgressDiscarded: "Saved progress discarded", 81 + }, 82 + }} 83 + > 84 + <PublicFormRunner form={form} /> 85 + </I18nProvider>, 86 + ); 87 + } 88 + 89 + async function cleanupAndRestore(restoreDom: () => void) { 90 + cleanup(); 91 + await new Promise((resolve) => setTimeout(resolve, 0)); 92 + restoreDom(); 93 + } 94 + 95 + describe("PublicFormRunner", () => { 96 + test("shows the back button only when the form allows back navigation", async () => { 97 + const restoreDom = installTestDom(); 98 + const blocks = [ 99 + createBlock({ id: "intro", type: "TEXT", position: 0, title: "Intro" }), 100 + createBlock({ 101 + id: "name", 102 + type: "SHORT_TEXT", 103 + position: 1, 104 + title: "Name", 105 + }), 106 + ]; 107 + 108 + const enabledView = renderRunner(createForm(blocks)); 109 + expect(enabledView.getByRole("button", { name: /Back/i }) !== null).toBe( 110 + true, 111 + ); 112 + cleanup(); 113 + 114 + const disabledView = renderRunner( 115 + createForm(blocks, { allowBackNavigation: false }), 116 + ); 117 + expect(disabledView.queryByRole("button", { name: /Back/i }) === null).toBe( 118 + true, 119 + ); 120 + 121 + await cleanupAndRestore(restoreDom); 122 + }); 123 + 124 + test("restored sessions with back navigation disabled do not expose or trigger back navigation", async () => { 125 + const restoreDom = installTestDom(); 126 + const form = createForm( 127 + [ 128 + createBlock({ 129 + id: "name", 130 + type: "SHORT_TEXT", 131 + position: 0, 132 + title: "Name", 133 + }), 134 + createBlock({ 135 + id: "notes", 136 + type: "SHORT_TEXT", 137 + position: 1, 138 + title: "Notes", 139 + }), 140 + ], 141 + { allowBackNavigation: false }, 142 + ); 143 + 144 + window.localStorage.setItem( 145 + `lively-forms-public-runner-draft:${form.id}`, 146 + JSON.stringify( 147 + createPublicFormDraft(form, { 148 + answers: { name: "Ada", notes: "Saved" }, 149 + history: ["name", "notes"], 150 + cursor: 1, 151 + }), 152 + ), 153 + ); 154 + 155 + const view = renderRunner(form); 156 + 157 + await waitFor(() => { 158 + expect(view.getByText("Progress restored") !== null).toBe(true); 159 + }); 160 + expect(view.queryByRole("button", { name: /Back/i }) === null).toBe(true); 161 + 162 + await cleanupAndRestore(restoreDom); 163 + }); 164 + 165 + test("restored sessions with back navigation enabled still allow going back", async () => { 166 + const restoreDom = installTestDom(); 167 + const form = createForm([ 168 + createBlock({ 169 + id: "name", 170 + type: "SHORT_TEXT", 171 + position: 0, 172 + title: "Name", 173 + }), 174 + createBlock({ 175 + id: "notes", 176 + type: "SHORT_TEXT", 177 + position: 1, 178 + title: "Notes", 179 + }), 180 + ]); 181 + 182 + window.localStorage.setItem( 183 + `lively-forms-public-runner-draft:${form.id}`, 184 + JSON.stringify( 185 + createPublicFormDraft(form, { 186 + answers: { name: "Ada", notes: "Saved" }, 187 + history: ["name", "notes"], 188 + cursor: 1, 189 + }), 190 + ), 191 + ); 192 + 193 + const view = renderRunner(form); 194 + 195 + await waitFor(() => { 196 + expect(view.getByText("Progress restored") !== null).toBe(true); 197 + }); 198 + expect(view.getByRole("button", { name: /Back/i }) !== null).toBe(true); 199 + 200 + await cleanupAndRestore(restoreDom); 201 + }); 202 + });
+19 -13
components/public-form-runner.tsx
··· 144 144 : form.completionMessage.trim(); 145 145 const completionLinkLabel = form.completionLinkLabel?.trim() || null; 146 146 const completionLinkUrl = form.completionLinkUrl?.trim() || null; 147 + const allowBackNavigation = form.allowBackNavigation; 147 148 148 149 function setAnswer(blockId: string, value: AnswerValue) { 149 150 setAnswers((current) => ({ ··· 354 355 ); 355 356 356 357 const handleBack = useCallback(() => { 357 - setRoute((current) => retreatRunnerRoute(current)); 358 - }, []); 358 + setRoute((current) => retreatRunnerRoute(current, { allowBackNavigation })); 359 + }, [allowBackNavigation]); 359 360 360 361 function handleAdvanceKeyDown( 361 362 event: ReactKeyboardEvent<HTMLElement>, ··· 427 428 return; 428 429 } 429 430 430 - if (event.key === "Escape" && cursor > 0) { 431 + if (event.key === "Escape" && cursor > 0 && allowBackNavigation) { 431 432 event.preventDefault(); 432 433 handleBack(); 433 434 } ··· 437 438 return () => window.removeEventListener("keydown", handleWindowKeyDown); 438 439 }, [ 439 440 currentBlock, 441 + allowBackNavigation, 440 442 cursor, 441 443 handleBack, 442 444 handleContinue, ··· 830 832 </AnimatePresence> 831 833 832 834 <div className="mt-6 flex items-center justify-between gap-3 border-t border-[color:var(--line)] pt-4"> 833 - <Button 834 - variant="secondary" 835 - onClick={handleBack} 836 - disabled={cursor === 0 || isSubmitting} 837 - > 838 - {t("publicRunner.back")} 839 - <span className="ml-1 inline-flex h-5 items-center rounded-md border border-current/20 bg-black/10 px-1.5 text-[10px] font-medium opacity-90 dark:bg-white/10"> 840 - Esc 841 - </span> 842 - </Button> 835 + {allowBackNavigation ? ( 836 + <Button 837 + variant="secondary" 838 + onClick={handleBack} 839 + disabled={cursor === 0 || isSubmitting} 840 + > 841 + {t("publicRunner.back")} 842 + <span className="ml-1 inline-flex h-5 items-center rounded-md border border-current/20 bg-black/10 px-1.5 text-[10px] font-medium opacity-90 dark:bg-white/10"> 843 + Esc 844 + </span> 845 + </Button> 846 + ) : ( 847 + <div /> 848 + )} 843 849 <Button 844 850 onClick={() => void handleContinue()} 845 851 disabled={isSubmitting}
+19
lib/form-metadata-schema.test.ts
··· 21 21 22 22 expect(parsed.respondentLocale).toBe(null); 23 23 }); 24 + 25 + test("defaults allowBackNavigation to true", () => { 26 + const parsed = formMetadataSchema.parse({ 27 + title: "Demo form", 28 + slug: "demo-form", 29 + }); 30 + 31 + expect(parsed.allowBackNavigation).toBe(true); 32 + }); 33 + 34 + test("preserves an explicit disabled allowBackNavigation value", () => { 35 + const parsed = formMetadataSchema.parse({ 36 + title: "Demo form", 37 + slug: "demo-form", 38 + allowBackNavigation: false, 39 + }); 40 + 41 + expect(parsed.allowBackNavigation).toBe(false); 42 + }); 24 43 });
+2
lib/form-types.ts
··· 47 47 completionLinkUrl: string | null; 48 48 respondentLocale: AppLocale | null; 49 49 showProgress: boolean; 50 + allowBackNavigation: boolean; 50 51 slug: string; 51 52 status: FormStatus; 52 53 updatedAt: string; ··· 65 66 completionLinkUrl: string | null; 66 67 respondentLocale: AppLocale | null; 67 68 showProgress: boolean; 69 + allowBackNavigation: boolean; 68 70 slug: string; 69 71 blocks: PublicBlock[]; 70 72 };
+7 -3
lib/forms.ts
··· 88 88 }, 89 89 } satisfies Prisma.FormInclude; 90 90 91 - type BuilderFormRecord = Prisma.FormGetPayload<{ 92 - include: typeof builderFormInclude; 93 - }>; 91 + type BuilderFormRecord = Form & 92 + Prisma.FormGetPayload<{ 93 + include: typeof builderFormInclude; 94 + }>; 94 95 95 96 export type { 96 97 BuilderForm, ··· 131 132 ? normalizeLocale(form.respondentLocale) 132 133 : null, 133 134 showProgress: form.showProgress, 135 + allowBackNavigation: form.allowBackNavigation, 134 136 slug: form.slug, 135 137 status: form.status, 136 138 updatedAt: form.updatedAt.toISOString(), ··· 153 155 ? normalizeLocale(form.respondentLocale) 154 156 : null, 155 157 showProgress: form.showProgress, 158 + allowBackNavigation: form.allowBackNavigation, 156 159 slug: form.slug, 157 160 blocks: form.blocks.map(serializeBlock), 158 161 }; ··· 465 468 completionTitle: copy.completionTitle, 466 469 completionMessage: copy.completionMessage, 467 470 showProgress: true, 471 + allowBackNavigation: true, 468 472 slug, 469 473 blocks: { 470 474 create: initialBlocks(locale),
+1
lib/public-form-draft.test.ts
··· 19 19 completionLinkUrl: null, 20 20 respondentLocale: null, 21 21 showProgress: true, 22 + allowBackNavigation: true, 22 23 slug: "demo-form", 23 24 blocks, 24 25 };
+47
lib/public-form-runner-state.test.ts
··· 1 1 import { describe, expect, test } from "bun:test"; 2 2 3 3 import type { SerializedBlock } from "@/lib/blocks"; 4 + import { resolveNextBlockId } from "@/lib/branching"; 4 5 import { 5 6 advanceRunnerRoute, 6 7 createInitialRunnerRoute, ··· 99 100 expect(total).toBe(2); 100 101 }); 101 102 103 + test("branches correctly during forward-only progression", () => { 104 + const blocks = [ 105 + createBlock({ 106 + id: "path", 107 + type: "SINGLE_CHOICE", 108 + position: 0, 109 + config: { 110 + options: ["yes", "no"], 111 + branchRules: [ 112 + { operator: "equals", value: "yes", targetBlockId: "finish" }, 113 + ], 114 + defaultNextBlockId: "fallback", 115 + }, 116 + }), 117 + createBlock({ id: "fallback", type: "SHORT_TEXT", position: 1 }), 118 + createBlock({ id: "finish", type: "SHORT_TEXT", position: 2 }), 119 + ]; 120 + const nextBlockId = resolveNextBlockId(blocks, "path", { path: "yes" }); 121 + const nextRoute = advanceRunnerRoute( 122 + createInitialRunnerRoute(blocks), 123 + "path", 124 + nextBlockId ?? "fallback", 125 + ); 126 + 127 + expect(nextBlockId).toBe("finish"); 128 + expect(JSON.stringify(nextRoute.history)).toBe( 129 + JSON.stringify(["path", "finish"]), 130 + ); 131 + expect(nextRoute.cursor).toBe(1); 132 + }); 133 + 102 134 test("detects saved progress from answers, cursor movement, or history growth", () => { 103 135 expect( 104 136 hasRunnerProgress({ ··· 135 167 }); 136 168 137 169 expect(route.cursor).toBe(1); 170 + expect(JSON.stringify(route.history)).toBe( 171 + JSON.stringify(["intro", "question", "finish"]), 172 + ); 173 + }); 174 + 175 + test("does not move backward when back navigation is disabled", () => { 176 + const route = retreatRunnerRoute( 177 + { 178 + history: ["intro", "question", "finish"], 179 + cursor: 2, 180 + }, 181 + { allowBackNavigation: false }, 182 + ); 183 + 184 + expect(route.cursor).toBe(2); 138 185 expect(JSON.stringify(route.history)).toBe( 139 186 JSON.stringify(["intro", "question", "finish"]), 140 187 );
+8 -1
lib/public-form-runner-state.ts
··· 91 91 }; 92 92 } 93 93 94 - export function retreatRunnerRoute(route: PublicFormRunnerRoute) { 94 + export function retreatRunnerRoute( 95 + route: PublicFormRunnerRoute, 96 + options: { allowBackNavigation?: boolean } = {}, 97 + ) { 98 + if (options.allowBackNavigation === false) { 99 + return route; 100 + } 101 + 95 102 return { 96 103 ...route, 97 104 cursor: Math.max(0, route.cursor - 1),
+2
lib/validators.ts
··· 13 13 completionLinkUrl: z.string().trim().max(2048).default(""), 14 14 respondentLocale: z.enum(supportedLocales).nullable().default(null), 15 15 showProgress: z.boolean().default(true), 16 + allowBackNavigation: z.boolean().default(true), 16 17 slug: z 17 18 .string() 18 19 .trim() ··· 63 64 respondentLocale: value.respondentLocale ?? null, 64 65 completionLinkLabel: value.completionLinkLabel || null, 65 66 completionLinkUrl: value.completionLinkUrl || null, 67 + allowBackNavigation: value.allowBackNavigation, 66 68 })); 67 69 68 70 export const createBlockSchema = z.object({
+3
locales/en.yml
··· 166 166 respondentLanguageOptions: 167 167 visitor: Use visitor's language 168 168 showProgress: Show form progress 169 + showProgressHelp: Show respondents where they are in the form. 170 + allowBackNavigation: Allow going back to previous steps 171 + allowBackNavigationHelp: Let respondents return to earlier visited steps in the public form runner. 169 172 copyLink: Copy link 170 173 openRunner: Open runner 171 174 deleteForm: Delete form
+3
locales/ru.yml
··· 166 166 respondentLanguageOptions: 167 167 visitor: Использовать язык посетителя 168 168 showProgress: Показывать прогресс формы 169 + showProgressHelp: Показывать респондентам, на каком шаге формы они находятся. 170 + allowBackNavigation: Разрешить возврат к предыдущим шагам 171 + allowBackNavigationHelp: Разрешить респондентам возвращаться к уже пройденным шагам в публичном раннере формы. 169 172 copyLink: Скопировать ссылку 170 173 openRunner: Открыть раннер 171 174 deleteForm: Удалить форму
+2
openspec/changes/archive/2026-04-15-restrict-form-runner-back-navigation/.openspec.yaml
··· 1 + schema: spec-driven 2 + created: 2026-04-15
+74
openspec/changes/archive/2026-04-15-restrict-form-runner-back-navigation/design.md
··· 1 + ## Context 2 + 3 + The public form runner currently models navigation as a visited-route history plus a cursor. That supports branching, respondent backtracking, and resumed sessions, but it assumes every published form allows backward movement. Form settings already persist respondent-facing runner options such as progress visibility and interface locale, so a new navigation-control setting fits the same metadata path. Because this change touches persisted form data, creator settings UI, public runner behavior, and draft restore semantics, a short design is useful before implementation. 4 + 5 + ## Goals / Non-Goals 6 + 7 + **Goals:** 8 + - Let creators decide per form whether respondents may navigate back to earlier visited steps. 9 + - Preserve the existing experience for all current forms by defaulting to back navigation enabled. 10 + - Enforce the setting consistently in the live runner and after saved-progress restoration. 11 + - Keep the implementation aligned with existing form metadata storage, validation, and serialization patterns. 12 + 13 + **Non-Goals:** 14 + - Introducing per-block or conditional navigation locks. 15 + - Preventing browser-level page refresh/close/navigation outside the runner UI itself. 16 + - Reworking branching, submission validation, or progress calculations beyond the changes needed to respect the new setting. 17 + - Adding new respondent roles, authentication, or anti-tampering guarantees beyond existing public runner behavior. 18 + 19 + ## Decisions 20 + 21 + ### 1. Store back-navigation control as a persisted form-level boolean 22 + The system will add a boolean form field such as `allowBackNavigation` with a default of `true`, and include it in builder/public form payloads plus form metadata validation. 23 + 24 + **Why:** This setting is authored form behavior, similar to `showProgress` and `respondentLocale`, and must survive draft saves, publication, and later edits. 25 + 26 + **Alternatives considered:** 27 + - Keep the value only in client state: rejected because respondent behavior must follow the saved form, not the current creator session. 28 + - Encode the behavior indirectly from branching rules or block settings: rejected because the request is a simple form-wide navigation policy. 29 + 30 + ### 2. Expose the control in form settings next to other respondent-experience options 31 + The builder will surface the option in the form settings panel alongside respondent language and progress visibility, using clear copy that explains the setting applies to respondents in the public runner. 32 + 33 + **Why:** Creators already manage runner-wide presentation and behavior in this panel, so adding one more runner-level control keeps discovery and mental model simple. 34 + 35 + **Alternatives considered:** 36 + - Add a separate advanced settings area: rejected because the feature is straightforward and would be harder to find. 37 + - Put the option on each block: rejected because the requested behavior is form-wide, not per-step. 38 + 39 + ### 3. Enforce the rule at the runner action layer, not only in the UI 40 + When `allowBackNavigation` is false, the public runner will not render the back action and will also guard any back-navigation code paths so route state cannot move backward through helper calls or keyboard handling. 41 + 42 + **Why:** UI hiding alone is fragile. Central action-level enforcement keeps behavior correct if the component grows more navigation triggers later. 43 + 44 + **Alternatives considered:** 45 + - Hide only the back button: rejected because future shortcuts or internal calls could accidentally bypass the setting. 46 + - Rebuild the route model without a cursor/history concept: rejected because the current route model still works for forward progression and restore validation. 47 + 48 + ### 4. Keep saved-progress restoration compatible and honor the current form setting on resume 49 + Stored drafts do not need to persist a separate copy of the back-navigation flag. On restore, the runner can continue restoring answers/history/cursor as it does today, but resumed interaction must use the current form setting to decide whether backward movement is available. 50 + 51 + **Why:** The restored state describes where the respondent is, while the form setting describes what navigation actions are currently allowed. This avoids invalidating saved progress just because the creator toggled the setting. 52 + 53 + **Alternatives considered:** 54 + - Include the flag in the draft fingerprint and invalidate drafts whenever it changes: rejected because restoring current position is still safe and less disruptive. 55 + - Force-collapsing restored history when back navigation is disabled: rejected because forward flow and progress calculations still benefit from the visited route. 56 + 57 + ## Risks / Trade-offs 58 + 59 + - [Creators may expect the setting to prevent all browser back behavior] → Mitigation: scope copy and specs to runner back navigation within the form UI, not global browser history control. 60 + - [Hidden UI without logic guards could regress later] → Mitigation: centralize enforcement in the runner navigation handler/helper path and cover it with tests. 61 + - [Existing forms could change behavior unexpectedly] → Mitigation: default the stored field to `true` and treat missing legacy values as enabled. 62 + - [Restored sessions might feel inconsistent if creators change the setting mid-response] → Mitigation: honor the latest saved form behavior while still restoring the respondent's current step and answers. 63 + 64 + ## Migration Plan 65 + 66 + - Add a new boolean column on `Form` with default `true`. 67 + - Thread the field through form loading, builder serialization, and metadata validation. 68 + - Update the builder settings save flow to persist the value. 69 + - Update public runner rendering and tests to respect the flag. 70 + - Rollback is low risk: the code can ignore the field and default behavior remains equivalent to enabled back navigation. 71 + 72 + ## Open Questions 73 + 74 + - None currently. The requested behavior is clear enough to implement as a form-level boolean with a default-enabled migration.
+25
openspec/changes/archive/2026-04-15-restrict-form-runner-back-navigation/proposal.md
··· 1 + ## Why 2 + 3 + The public form runner currently always lets respondents move backward through their visited path. That works for editable interview-style flows, but some forms need creators to lock answers once a respondent advances so later questions cannot be influenced by revisiting earlier steps. 4 + 5 + ## What Changes 6 + 7 + - Add a per-form setting that lets creators choose whether respondents can navigate backward in the public form runner. 8 + - Preserve the current behavior as the default so existing forms continue allowing back navigation. 9 + - Update the public runner to hide or disable backward navigation when the setting is off while keeping forward progress, validation, branching, and submission intact. 10 + - Ensure saved draft progress and resumed sessions honor the form's configured back-navigation mode. 11 + 12 + ## Capabilities 13 + 14 + ### New Capabilities 15 + - None. 16 + 17 + ### Modified Capabilities 18 + - `conversational-form-builder`: add a form-level setting for whether respondents may go back to earlier steps in the public runner. 19 + - `anonymous-form-runner`: allow forms to disable respondent back navigation and enforce that behavior consistently during active and restored sessions. 20 + 21 + ## Impact 22 + 23 + - Affected code: Prisma form model, form settings validation and serialization, creator builder settings UI, public form runner navigation logic, runner draft restore behavior, and related tests/spec coverage. 24 + - No new external dependencies are expected. 25 + - Existing forms should remain backward-compatible by defaulting to back navigation enabled.
+33
openspec/changes/archive/2026-04-15-restrict-form-runner-back-navigation/specs/anonymous-form-runner/spec.md
··· 1 + ## MODIFIED Requirements 2 + 3 + ### Requirement: Runner presents blocks in a linear one-at-a-time flow 4 + The system SHALL present form blocks one at a time starting from the first saved block and SHALL resolve each next block by evaluating the current block's saved branch rules against the respondent's current answer. If no branch rule matches, the system SHALL continue to the block's configured default next target when present, or otherwise continue to the next block in saved order. Text blocks SHALL remain part of the flow but SHALL NOT collect an answer. When the form allows respondent back navigation, the system SHALL allow backward navigation across the respondent's visited path and SHALL recalculate future visited steps when a prior answer changes. When the form disables respondent back navigation, the system SHALL keep the respondent moving forward only and SHALL NOT expose runner controls that return to earlier visited steps. 5 + 6 + #### Scenario: Respondent sees a conditional follow-up question 7 + - **WHEN** a respondent answers a question with a value that matches a saved branch rule pointing to a later follow-up block 8 + - **THEN** the system shows that follow-up block as the next step in the runner 9 + 10 + #### Scenario: Respondent skips a branch-only follow-up 11 + - **WHEN** a respondent answers a question with a value that does not match any saved branch rule 12 + - **THEN** the system continues to the next block in saved order instead of showing the branched follow-up block 13 + 14 + #### Scenario: Respondent changes an earlier branching answer when back navigation is enabled 15 + - **WHEN** a respondent navigates backward on a form that allows back navigation, changes an answer that controls branching, and continues again 16 + - **THEN** the system recalculates the subsequent route from that changed answer and replaces the no-longer-valid future steps 17 + 18 + #### Scenario: Respondent uses a form with back navigation disabled 19 + - **WHEN** a respondent advances past a step on a form that disables back navigation 20 + - **THEN** the system does not provide runner navigation that returns them to an earlier visited step 21 + 22 + ## ADDED Requirements 23 + 24 + ### Requirement: Restored public runner sessions honor the form's back-navigation mode 25 + The system SHALL restore saved public-runner progress only when the saved route remains valid for the current form, and resumed interaction SHALL honor the form's currently configured back-navigation mode. 26 + 27 + #### Scenario: Respondent resumes a form with back navigation disabled 28 + - **WHEN** a respondent restores saved progress for a form whose current settings disable back navigation 29 + - **THEN** the system restores the saved current step and answers without exposing runner navigation that goes back to earlier visited steps 30 + 31 + #### Scenario: Respondent resumes a form with back navigation enabled 32 + - **WHEN** a respondent restores saved progress for a form whose current settings allow back navigation 33 + - **THEN** the system restores the saved current step and continues allowing backward navigation across the restored visited path
+16
openspec/changes/archive/2026-04-15-restrict-form-runner-back-navigation/specs/conversational-form-builder/spec.md
··· 1 + ## ADDED Requirements 2 + 3 + ### Requirement: Creator can configure respondent back navigation per form 4 + The system SHALL allow an authenticated creator to configure whether respondents may navigate backward to earlier visited steps in the public form runner for any accessible form. The setting SHALL default to enabled for existing and newly created forms unless the creator explicitly disables it. 5 + 6 + #### Scenario: Creator leaves back navigation enabled 7 + - **WHEN** an authenticated creator keeps the respondent back-navigation setting enabled and saves form settings 8 + - **THEN** the system persists the form with respondent back navigation allowed 9 + 10 + #### Scenario: Creator disables back navigation 11 + - **WHEN** an authenticated creator disables respondent back navigation in form settings and saves 12 + - **THEN** the system persists the form so respondents can continue forward but cannot return to earlier visited steps in the public runner 13 + 14 + #### Scenario: Creator reopens form settings with back navigation disabled 15 + - **WHEN** an authenticated creator opens settings for a form that already has respondent back navigation disabled 16 + - **THEN** the system shows the saved disabled state as the current value
+22
openspec/changes/archive/2026-04-15-restrict-form-runner-back-navigation/tasks.md
··· 1 + ## 1. Persist form-level back-navigation settings 2 + 3 + - [x] 1.1 Add a persisted `allowBackNavigation` form field with a default-enabled migration for existing and new forms 4 + - [x] 1.2 Thread `allowBackNavigation` through form metadata validation, form serialization, and builder/public form TypeScript types 5 + - [x] 1.3 Add or update validation tests to confirm the setting defaults to `true` and preserves explicit disabled values 6 + 7 + ## 2. Expose the setting in creator form settings 8 + 9 + - [x] 2.1 Extend the form settings draft/save flow so creators can edit and persist the respondent back-navigation option 10 + - [x] 2.2 Update the form settings panel UI and localized copy to explain the option alongside other respondent-experience controls 11 + - [x] 2.3 Add builder UI tests covering default enabled behavior, disabling the setting, and reopening settings with a saved disabled value 12 + 13 + ## 3. Enforce the setting in the public form runner 14 + 15 + - [x] 3.1 Update public runner navigation logic to block backward route movement when `allowBackNavigation` is false while preserving forward progression, validation, and branching 16 + - [x] 3.2 Update the runner UI so back navigation controls are only shown when the form allows them 17 + - [x] 3.3 Add runner state/component tests covering enabled back navigation, disabled back navigation, and branching after forward-only progression 18 + 19 + ## 4. Honor the setting during saved-progress restore 20 + 21 + - [x] 4.1 Ensure restored runner sessions continue from the saved step while honoring the form's current `allowBackNavigation` mode 22 + - [x] 4.2 Add restore-focused tests proving resumed sessions do not expose backward navigation when the setting is disabled and still allow it when enabled
+18 -3
openspec/specs/anonymous-form-runner/spec.md
··· 12 12 - **THEN** the system does not allow public response collection for that form 13 13 14 14 ### Requirement: Runner presents blocks in a linear one-at-a-time flow 15 - The system SHALL present form blocks one at a time starting from the first saved block and SHALL resolve each next block by evaluating the current block's saved branch rules against the respondent's current answer. If no branch rule matches, the system SHALL continue to the block's configured default next target when present, or otherwise continue to the next block in saved order. Text blocks SHALL remain part of the flow but SHALL NOT collect an answer. The system SHALL allow backward navigation across the respondent's visited path and SHALL recalculate future visited steps when a prior answer changes. 15 + The system SHALL present form blocks one at a time starting from the first saved block and SHALL resolve each next block by evaluating the current block's saved branch rules against the respondent's current answer. If no branch rule matches, the system SHALL continue to the block's configured default next target when present, or otherwise continue to the next block in saved order. Text blocks SHALL remain part of the flow but SHALL NOT collect an answer. When the form allows respondent back navigation, the system SHALL allow backward navigation across the respondent's visited path and SHALL recalculate future visited steps when a prior answer changes. When the form disables respondent back navigation, the system SHALL keep the respondent moving forward only and SHALL NOT expose runner controls that return to earlier visited steps. 16 16 17 17 #### Scenario: Respondent sees a conditional follow-up question 18 18 - **WHEN** a respondent answers a question with a value that matches a saved branch rule pointing to a later follow-up block ··· 22 22 - **WHEN** a respondent answers a question with a value that does not match any saved branch rule 23 23 - **THEN** the system continues to the next block in saved order instead of showing the branched follow-up block 24 24 25 - #### Scenario: Respondent changes an earlier branching answer 26 - - **WHEN** a respondent navigates backward, changes an answer that controls branching, and continues again 25 + #### Scenario: Respondent changes an earlier branching answer when back navigation is enabled 26 + - **WHEN** a respondent navigates backward on a form that allows back navigation, changes an answer that controls branching, and continues again 27 27 - **THEN** the system recalculates the subsequent route from that changed answer and replaces the no-longer-valid future steps 28 + 29 + #### Scenario: Respondent uses a form with back navigation disabled 30 + - **WHEN** a respondent advances past a step on a form that disables back navigation 31 + - **THEN** the system does not provide runner navigation that returns them to an earlier visited step 32 + 33 + ### Requirement: Restored public runner sessions honor the form's back-navigation mode 34 + The system SHALL restore saved public-runner progress only when the saved route remains valid for the current form, and resumed interaction SHALL honor the form's currently configured back-navigation mode. 35 + 36 + #### Scenario: Respondent resumes a form with back navigation disabled 37 + - **WHEN** a respondent restores saved progress for a form whose current settings disable back navigation 38 + - **THEN** the system restores the saved current step and answers without exposing runner navigation that goes back to earlier visited steps 39 + 40 + #### Scenario: Respondent resumes a form with back navigation enabled 41 + - **WHEN** a respondent restores saved progress for a form whose current settings allow back navigation 42 + - **THEN** the system restores the saved current step and continues allowing backward navigation across the restored visited path 28 43 29 44 ### Requirement: Runner shows progress across the full flow 30 45 The system SHALL display progress based on the respondent's current visited route rather than assuming every saved block will appear, and SHALL update the visible step indicator when branching or backtracking changes that route.
+15
openspec/specs/conversational-form-builder/spec.md
··· 144 144 - **WHEN** an authenticated creator opens settings for a form that already has a saved respondent interface locale override 145 145 - **THEN** the system shows the saved override as the current selected value 146 146 147 + ### Requirement: Creator can configure respondent back navigation per form 148 + The system SHALL allow an authenticated creator to configure whether respondents may navigate backward to earlier visited steps in the public form runner for any accessible form. The setting SHALL default to enabled for existing and newly created forms unless the creator explicitly disables it. 149 + 150 + #### Scenario: Creator leaves back navigation enabled 151 + - **WHEN** an authenticated creator keeps the respondent back-navigation setting enabled and saves form settings 152 + - **THEN** the system persists the form with respondent back navigation allowed 153 + 154 + #### Scenario: Creator disables back navigation 155 + - **WHEN** an authenticated creator disables respondent back navigation in form settings and saves 156 + - **THEN** the system persists the form so respondents can continue forward but cannot return to earlier visited steps in the public runner 157 + 158 + #### Scenario: Creator reopens form settings with back navigation disabled 159 + - **WHEN** an authenticated creator opens settings for a form that already has respondent back navigation disabled 160 + - **THEN** the system shows the saved disabled state as the current value 161 + 147 162 ### Requirement: Builder reflects the active workspace context 148 163 The system SHALL show the active workspace context when a creator creates or edits a form so they can tell whether the form belongs to their personal workspace or an organization. 149 164
+2
prisma/migrations/20260415180000_add_form_back_navigation_setting/migration.sql
··· 1 + ALTER TABLE "Form" 2 + ADD COLUMN "allowBackNavigation" BOOLEAN NOT NULL DEFAULT true;
+1
prisma/schema.prisma
··· 97 97 completionLinkUrl String? 98 98 respondentLocale String? 99 99 showProgress Boolean @default(true) 100 + allowBackNavigation Boolean @default(true) 100 101 slug String @unique 101 102 status FormStatus @default(DRAFT) 102 103 user User @relation(fields: [userId], references: [id], onDelete: Cascade)