this repo has no description
0
fork

Configure Feed

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

feat(builder): prompt before leaving unsaved block edits

+813 -12
+300
components/form-builder.test.tsx
··· 1 + import { describe, expect, test } from "bun:test"; 2 + import { cleanup, fireEvent, render, waitFor } from "@testing-library/react"; 3 + 4 + import { I18nProvider } from "@/components/i18n-provider"; 5 + import { getDefaultBlockConfig } from "@/lib/blocks"; 6 + import type { BuilderBlock, BuilderForm } from "@/lib/form-types"; 7 + import { installTestDom } from "@/test/install-dom"; 8 + 9 + const router = { 10 + push: () => {}, 11 + refresh: () => {}, 12 + }; 13 + const { mock } = (await import("bun:test")) as unknown as { 14 + mock: { 15 + module: (specifier: string, factory: () => Record<string, unknown>) => void; 16 + }; 17 + }; 18 + 19 + mock.module("next/navigation", () => ({ 20 + useRouter: () => router, 21 + })); 22 + 23 + const { FormBuilder } = await import("@/components/form-builder"); 24 + 25 + function createBlock( 26 + overrides: Partial<BuilderBlock> & 27 + Pick<BuilderBlock, "id" | "type" | "position">, 28 + ): BuilderBlock { 29 + const now = new Date("2026-04-14T12:00:00.000Z"); 30 + 31 + return { 32 + formId: "form-1", 33 + title: "", 34 + description: "", 35 + required: false, 36 + createdAt: now, 37 + updatedAt: now, 38 + config: getDefaultBlockConfig(overrides.type), 39 + ...overrides, 40 + } as BuilderBlock; 41 + } 42 + 43 + function createForm(blocks: BuilderBlock[]): BuilderForm { 44 + return { 45 + id: "form-1", 46 + title: "Form title", 47 + description: "", 48 + completionTitle: "Done", 49 + completionMessage: "Thanks", 50 + completionLinkLabel: null, 51 + completionLinkUrl: null, 52 + showProgress: true, 53 + slug: "demo-form", 54 + status: "DRAFT", 55 + updatedAt: "2026-04-14T12:00:00.000Z", 56 + responseCount: 0, 57 + workspace: { 58 + kind: "personal", 59 + label: "Personal workspace", 60 + }, 61 + blocks, 62 + }; 63 + } 64 + 65 + function renderBuilder(initialForm: BuilderForm) { 66 + return render( 67 + <I18nProvider locale="en" messages={{}}> 68 + <FormBuilder initialForm={initialForm} /> 69 + </I18nProvider>, 70 + ); 71 + } 72 + 73 + describe("FormBuilder unsaved block protection", () => { 74 + test("switches blocks immediately when there are no unsaved edits", async () => { 75 + const restoreDom = installTestDom(); 76 + const firstBlock = createBlock({ 77 + id: "block-1", 78 + type: "SHORT_TEXT", 79 + position: 0, 80 + title: "First question", 81 + }); 82 + const secondBlock = createBlock({ 83 + id: "block-2", 84 + type: "SHORT_TEXT", 85 + position: 1, 86 + title: "Second question", 87 + }); 88 + 89 + const view = renderBuilder(createForm([firstBlock, secondBlock])); 90 + 91 + fireEvent.click(view.getByRole("button", { name: "Second question" })); 92 + 93 + await waitFor(() => { 94 + expect(view.getByDisplayValue("Second question") !== null).toBe(true); 95 + }); 96 + expect(view.queryByText("builder.unsavedBlockChangesTitle")).toBe(null); 97 + 98 + cleanup(); 99 + restoreDom(); 100 + }); 101 + 102 + test("opens form settings immediately when there are no unsaved edits", async () => { 103 + const restoreDom = installTestDom(); 104 + const firstBlock = createBlock({ 105 + id: "block-1", 106 + type: "SHORT_TEXT", 107 + position: 0, 108 + title: "First question", 109 + }); 110 + 111 + const view = renderBuilder(createForm([firstBlock])); 112 + 113 + fireEvent.click(view.getByRole("button", { name: "builder.settings" })); 114 + 115 + await waitFor(() => { 116 + expect(view.getByText("builder.settingsTitle") !== null).toBe(true); 117 + }); 118 + expect(view.queryByText("builder.unsavedBlockChangesTitle")).toBe(null); 119 + 120 + cleanup(); 121 + restoreDom(); 122 + }); 123 + 124 + test("lets the creator cancel or discard unsaved changes before opening form settings", async () => { 125 + const restoreDom = installTestDom(); 126 + const firstBlock = createBlock({ 127 + id: "block-1", 128 + type: "SHORT_TEXT", 129 + position: 0, 130 + title: "First question", 131 + }); 132 + 133 + const view = renderBuilder(createForm([firstBlock])); 134 + const requiredToggle = view.getAllByRole("checkbox")[0] as HTMLInputElement; 135 + fireEvent.click(requiredToggle); 136 + 137 + await waitFor(() => { 138 + expect(requiredToggle.checked).toBe(true); 139 + }); 140 + 141 + fireEvent.click(view.getByRole("button", { name: "builder.settings" })); 142 + 143 + await waitFor(() => { 144 + expect(view.getByText("builder.unsavedBlockChangesTitle") !== null).toBe( 145 + true, 146 + ); 147 + }); 148 + 149 + fireEvent.click( 150 + view.getByRole("button", { name: "builder.backFromUnsavedBlockChanges" }), 151 + ); 152 + 153 + await waitFor(() => { 154 + expect(view.queryByText("builder.unsavedBlockChangesTitle")).toBe(null); 155 + }); 156 + expect(requiredToggle.checked).toBe(true); 157 + 158 + fireEvent.click(view.getByRole("button", { name: "builder.settings" })); 159 + 160 + await waitFor(() => { 161 + expect(view.getByText("builder.unsavedBlockChangesTitle") !== null).toBe( 162 + true, 163 + ); 164 + }); 165 + 166 + fireEvent.click( 167 + view.getByRole("button", { name: "builder.cancelBlockChanges" }), 168 + ); 169 + 170 + await waitFor(() => { 171 + expect(view.getByText("builder.settingsTitle") !== null).toBe(true); 172 + }); 173 + 174 + cleanup(); 175 + restoreDom(); 176 + }); 177 + 178 + test("saves the active block before switching when the creator chooses save changes", async () => { 179 + const restoreDom = installTestDom(); 180 + const firstBlock = createBlock({ 181 + id: "block-1", 182 + type: "SHORT_TEXT", 183 + position: 0, 184 + title: "First question", 185 + }); 186 + const secondBlock = createBlock({ 187 + id: "block-2", 188 + type: "SHORT_TEXT", 189 + position: 1, 190 + title: "Second question", 191 + }); 192 + const fetchCalls: Array<{ url: string; init?: RequestInit }> = []; 193 + const previousFetch = globalThis.fetch; 194 + 195 + globalThis.fetch = (async ( 196 + url: string | URL | Request, 197 + init?: RequestInit, 198 + ) => { 199 + fetchCalls.push({ url: String(url), init }); 200 + 201 + return { 202 + ok: true, 203 + json: async () => ({ 204 + block: { 205 + ...firstBlock, 206 + required: true, 207 + }, 208 + }), 209 + } as Response; 210 + }) as typeof fetch; 211 + 212 + const view = renderBuilder(createForm([firstBlock, secondBlock])); 213 + const requiredToggle = view.getAllByRole("checkbox")[0] as HTMLInputElement; 214 + fireEvent.click(requiredToggle); 215 + 216 + await waitFor(() => { 217 + expect(requiredToggle.checked).toBe(true); 218 + }); 219 + 220 + fireEvent.click(view.getByRole("button", { name: "Second question" })); 221 + 222 + await waitFor(() => { 223 + expect(view.getByText("builder.unsavedBlockChangesTitle") !== null).toBe( 224 + true, 225 + ); 226 + }); 227 + 228 + fireEvent.click( 229 + view.getByRole("button", { name: "builder.saveBlockChanges" }), 230 + ); 231 + 232 + await waitFor(() => { 233 + expect(fetchCalls.length).toBe(1); 234 + expect(view.getByDisplayValue("Second question") !== null).toBe(true); 235 + }); 236 + 237 + expect(fetchCalls[0]?.url).toBe("/api/forms/form-1/blocks/block-1"); 238 + expect(fetchCalls[0]?.init?.method).toBe("PATCH"); 239 + expect(fetchCalls[0]?.init?.body).toContain('"required":true'); 240 + 241 + globalThis.fetch = previousFetch; 242 + cleanup(); 243 + restoreDom(); 244 + }); 245 + 246 + test("keeps the unsaved-changes dialog open when saving fails", async () => { 247 + const restoreDom = installTestDom(); 248 + const firstBlock = createBlock({ 249 + id: "block-1", 250 + type: "SHORT_TEXT", 251 + position: 0, 252 + title: "First question", 253 + }); 254 + const secondBlock = createBlock({ 255 + id: "block-2", 256 + type: "SHORT_TEXT", 257 + position: 1, 258 + title: "Second question", 259 + }); 260 + const previousFetch = globalThis.fetch; 261 + 262 + globalThis.fetch = (async () => 263 + ({ 264 + ok: false, 265 + json: async () => ({ error: "Could not save block." }), 266 + }) as Response) as typeof fetch; 267 + 268 + const view = renderBuilder(createForm([firstBlock, secondBlock])); 269 + const requiredToggle = view.getAllByRole("checkbox")[0] as HTMLInputElement; 270 + fireEvent.click(requiredToggle); 271 + 272 + await waitFor(() => { 273 + expect(requiredToggle.checked).toBe(true); 274 + }); 275 + 276 + fireEvent.click(view.getByRole("button", { name: "Second question" })); 277 + 278 + await waitFor(() => { 279 + expect(view.getByText("builder.unsavedBlockChangesTitle") !== null).toBe( 280 + true, 281 + ); 282 + }); 283 + 284 + fireEvent.click( 285 + view.getByRole("button", { name: "builder.saveBlockChanges" }), 286 + ); 287 + 288 + await waitFor(() => { 289 + expect(view.getByText("builder.unsavedBlockChangesTitle") !== null).toBe( 290 + true, 291 + ); 292 + expect(view.getByText("Could not save block.") !== null).toBe(true); 293 + }); 294 + expect(view.queryByDisplayValue("Second question")).toBe(null); 295 + 296 + globalThis.fetch = previousFetch; 297 + cleanup(); 298 + restoreDom(); 299 + }); 300 + });
+150 -9
components/form-builder.tsx
··· 58 58 import { 59 59 createBranchRuleDrafts, 60 60 createChoiceOptionDrafts, 61 + hasBuilderBlockDraftChanges, 61 62 serializeBranchRuleDrafts, 62 63 type BranchRuleDraft, 63 64 type ChoiceOptionDraft, ··· 68 69 type BlockType = BuilderBlock["type"]; 69 70 70 71 type Selection = { kind: "form" } | { kind: "block"; blockId: string }; 72 + type PendingNavigation = 73 + | { kind: "form" } 74 + | { kind: "block"; blockId: string } 75 + | { kind: "add-block"; blockType: BlockType }; 71 76 72 77 const blockIcons: Record<BlockType, typeof Type> = { 73 78 TEXT: Type, ··· 258 263 259 264 const [isBlockMenuOpen, setIsBlockMenuOpen] = useState(false); 260 265 const [isDeleteFormDialogOpen, setIsDeleteFormDialogOpen] = useState(false); 266 + const [pendingNavigation, setPendingNavigation] = 267 + useState<PendingNavigation | null>(null); 261 268 const blockMenuRef = useRef<HTMLDivElement | null>(null); 262 269 263 270 const selectedBlock = useMemo( ··· 288 295 : [], 289 296 ); 290 297 const SelectedBlockIcon = blockDraft ? blockIcons[blockDraft.type] : null; 298 + const hasUnsavedBlockChanges = useMemo( 299 + () => 300 + selection.kind === "block" && 301 + hasBuilderBlockDraftChanges({ 302 + savedBlock: selectedBlock, 303 + blockDraft, 304 + choiceOptionsDraft, 305 + branchRulesDraft, 306 + }), 307 + [ 308 + blockDraft, 309 + branchRulesDraft, 310 + choiceOptionsDraft, 311 + selectedBlock, 312 + selection.kind, 313 + ], 314 + ); 291 315 292 316 const sensors = useSensors( 293 317 useSensor(PointerSensor, { activationConstraint: { distance: 4 } }), ··· 359 383 360 384 try { 361 385 await runner(); 386 + return true; 362 387 } catch (caughtError) { 363 388 const nextError = 364 389 caughtError instanceof Error 365 390 ? caughtError.message 366 391 : t("builder.toasts.genericError"); 367 392 showToast(nextError, "error"); 393 + return false; 368 394 } finally { 369 395 setBusy(null); 370 396 } ··· 385 411 }); 386 412 } 387 413 388 - async function addBlock(type: BlockType) { 389 - await withTask(`add-${type}`, async () => { 414 + async function addBlockNow(type: BlockType) { 415 + return withTask(`add-${type}`, async () => { 390 416 const payload = await fetchJson<{ block: BuilderBlock }>( 391 417 `/api/forms/${form.id}/blocks`, 392 418 { ··· 411 437 412 438 async function saveBlock() { 413 439 if (!blockDraft) { 414 - return; 440 + return false; 415 441 } 416 442 417 - await withTask(`block-${blockDraft.id}`, async () => { 443 + return withTask(`block-${blockDraft.id}`, async () => { 418 444 const serializedBranchRules = serializeBranchRuleDrafts(branchRulesDraft); 419 445 const payload = await fetchJson<{ block: BuilderBlock }>( 420 446 `/api/forms/${form.id}/blocks/${blockDraft.id}`, ··· 452 478 }); 453 479 } 454 480 481 + async function executePendingNavigation(navigation: PendingNavigation) { 482 + switch (navigation.kind) { 483 + case "form": { 484 + setSelection({ kind: "form" }); 485 + break; 486 + } 487 + case "block": { 488 + setSelection({ kind: "block", blockId: navigation.blockId }); 489 + break; 490 + } 491 + case "add-block": { 492 + await addBlockNow(navigation.blockType); 493 + break; 494 + } 495 + } 496 + } 497 + 498 + function requestPendingNavigation(navigation: PendingNavigation) { 499 + if (navigation.kind === "form" && selection.kind === "form") { 500 + return; 501 + } 502 + 503 + if ( 504 + navigation.kind === "block" && 505 + selection.kind === "block" && 506 + selection.blockId === navigation.blockId 507 + ) { 508 + return; 509 + } 510 + 511 + if (hasUnsavedBlockChanges) { 512 + setPendingNavigation(navigation); 513 + return; 514 + } 515 + 516 + void executePendingNavigation(navigation); 517 + } 518 + 519 + async function confirmPendingNavigationWithSave() { 520 + if (!pendingNavigation) { 521 + return; 522 + } 523 + 524 + const navigation = pendingNavigation; 525 + const didSave = await saveBlock(); 526 + 527 + if (!didSave) { 528 + return; 529 + } 530 + 531 + setPendingNavigation(null); 532 + await executePendingNavigation(navigation); 533 + } 534 + 535 + async function confirmPendingNavigationWithoutSave() { 536 + if (!pendingNavigation) { 537 + return; 538 + } 539 + 540 + const navigation = pendingNavigation; 541 + setPendingNavigation(null); 542 + await executePendingNavigation(navigation); 543 + } 544 + 455 545 async function deleteBlock(blockId: string) { 456 546 await withTask(`delete-${blockId}`, async () => { 457 547 await fetchJson(`/api/forms/${form.id}/blocks/${blockId}`, { ··· 573 663 pending={busy === "delete-form"} 574 664 onConfirm={confirmDeleteForm} 575 665 /> 666 + <ConfirmDialog 667 + open={pendingNavigation !== null} 668 + onClose={() => setPendingNavigation(null)} 669 + title={t("builder.unsavedBlockChangesTitle")} 670 + description={t("builder.unsavedBlockChangesDescription")} 671 + confirmLabel={t("builder.saveBlockChanges")} 672 + cancelLabel={t("ui.cancel")} 673 + pending={Boolean(busy?.startsWith("block-"))} 674 + actions={ 675 + <div className="mt-6 flex items-center justify-end gap-2 whitespace-nowrap"> 676 + <Button 677 + size="sm" 678 + variant="secondary" 679 + onClick={() => setPendingNavigation(null)} 680 + disabled={Boolean(busy?.startsWith("block-"))} 681 + > 682 + {t("builder.backFromUnsavedBlockChanges")} 683 + </Button> 684 + <Button 685 + size="sm" 686 + variant="danger" 687 + onClick={() => void confirmPendingNavigationWithoutSave()} 688 + disabled={Boolean(busy?.startsWith("block-"))} 689 + > 690 + {t("builder.cancelBlockChanges")} 691 + </Button> 692 + <Button 693 + size="sm" 694 + onClick={() => void confirmPendingNavigationWithSave()} 695 + disabled={Boolean(busy?.startsWith("block-"))} 696 + > 697 + {busy?.startsWith("block-") ? ( 698 + <LoaderCircle className="size-4 animate-spin" /> 699 + ) : null} 700 + {t("builder.saveBlockChanges")} 701 + </Button> 702 + </div> 703 + } 704 + /> 576 705 <div className="space-y-6"> 577 706 <BuilderHeader 578 707 form={form} 579 708 settingsSelected={selection.kind === "form"} 580 709 busy={busy} 581 710 shareHref={shareHref} 582 - onOpenSettings={() => setSelection({ kind: "form" })} 711 + onOpenSettings={() => requestPendingNavigation({ kind: "form" })} 583 712 onPublish={() => setPublished(true)} 584 713 onUnpublish={() => setPublished(false)} 585 714 onCopyShareLink={copyShareLink} ··· 668 797 key={type} 669 798 type="button" 670 799 className="flex items-center gap-2 rounded-[12px] px-3 py-2 text-left text-sm font-medium text-[var(--ink)] transition hover:bg-[var(--surface)]" 671 - onClick={() => addBlock(type)} 800 + onClick={() => 801 + requestPendingNavigation({ 802 + kind: "add-block", 803 + blockType: type, 804 + }) 805 + } 672 806 > 673 807 {busy === `add-${type}` ? ( 674 808 <LoaderCircle className="size-4 animate-spin" /> ··· 706 840 selection.blockId === block.id 707 841 } 708 842 onSelect={(blockId) => 709 - setSelection({ kind: "block", blockId }) 843 + requestPendingNavigation({ kind: "block", blockId }) 710 844 } 711 845 /> 712 846 ))} ··· 722 856 selection.blockId === block.id 723 857 } 724 858 onSelect={(blockId) => 725 - setSelection({ kind: "block", blockId }) 859 + requestPendingNavigation({ kind: "block", blockId }) 726 860 } 727 861 /> 728 862 )) ··· 761 895 busy={busy} 762 896 /> 763 897 ) : ( 764 - <EmptyEditorState onAddShortText={() => addBlock("SHORT_TEXT")} /> 898 + <EmptyEditorState 899 + onAddShortText={() => 900 + requestPendingNavigation({ 901 + kind: "add-block", 902 + blockType: "SHORT_TEXT", 903 + }) 904 + } 905 + /> 765 906 )} 766 907 </section> 767 908 </div>
+102
lib/form-builder-drafts.test.ts
··· 1 1 import { describe, expect, test } from "bun:test"; 2 2 3 3 import { 4 + createBranchRuleDrafts, 4 5 createChoiceOptionDrafts, 5 6 createDefaultBranchRuleDraft, 7 + hasBuilderBlockDraftChanges, 6 8 serializeBranchRuleDrafts, 7 9 serializeChoiceOptionDrafts, 8 10 } from "@/lib/form-builder-drafts"; 11 + import { getDefaultBlockConfig } from "@/lib/blocks"; 12 + import type { BuilderBlock } from "@/lib/form-types"; 13 + 14 + function createBlock( 15 + overrides: Partial<BuilderBlock> & 16 + Pick<BuilderBlock, "id" | "type" | "position">, 17 + ): BuilderBlock { 18 + const now = new Date("2026-04-14T12:00:00.000Z"); 19 + 20 + return { 21 + formId: "form-1", 22 + title: "", 23 + description: "", 24 + required: false, 25 + createdAt: now, 26 + updatedAt: now, 27 + config: getDefaultBlockConfig(overrides.type), 28 + ...overrides, 29 + } as BuilderBlock; 30 + } 9 31 10 32 describe("form builder draft helpers", () => { 11 33 test("preserves choice option order when drafts are saved back into block config", () => { ··· 46 68 expect(draft.operator).toBe("equals"); 47 69 expect(draft.value).toBe("agreed"); 48 70 expect(draft.targetBlockId).toBe("accepted"); 71 + }); 72 + 73 + test("treats choice drafts with regenerated ids as unchanged when the saved content matches", () => { 74 + const savedBlock = createBlock({ 75 + id: "choice-1", 76 + type: "SINGLE_CHOICE", 77 + position: 0, 78 + title: "Pick one", 79 + config: { 80 + ...getDefaultBlockConfig("SINGLE_CHOICE"), 81 + options: ["Yes", "No"], 82 + }, 83 + }); 84 + 85 + expect( 86 + hasBuilderBlockDraftChanges({ 87 + savedBlock, 88 + blockDraft: savedBlock, 89 + choiceOptionsDraft: createChoiceOptionDrafts(["Yes", "No"]), 90 + branchRulesDraft: [], 91 + }), 92 + ).toBe(false); 93 + 94 + expect( 95 + hasBuilderBlockDraftChanges({ 96 + savedBlock, 97 + blockDraft: savedBlock, 98 + choiceOptionsDraft: createChoiceOptionDrafts(["Yes", "Maybe"]), 99 + branchRulesDraft: [], 100 + }), 101 + ).toBe(true); 102 + }); 103 + 104 + test("detects branching edits from serialized rule content instead of draft ids", () => { 105 + const savedBlock = createBlock({ 106 + id: "question-1", 107 + type: "SHORT_TEXT", 108 + position: 0, 109 + title: "Why?", 110 + config: { 111 + ...getDefaultBlockConfig("SHORT_TEXT"), 112 + branchRules: [ 113 + { 114 + operator: "contains", 115 + value: "vip", 116 + targetBlockId: "priority-review", 117 + }, 118 + ], 119 + }, 120 + }); 121 + 122 + expect( 123 + hasBuilderBlockDraftChanges({ 124 + savedBlock, 125 + blockDraft: savedBlock, 126 + choiceOptionsDraft: [], 127 + branchRulesDraft: createBranchRuleDrafts([ 128 + { 129 + operator: "contains", 130 + value: "vip", 131 + targetBlockId: "priority-review", 132 + }, 133 + ]), 134 + }), 135 + ).toBe(false); 136 + 137 + expect( 138 + hasBuilderBlockDraftChanges({ 139 + savedBlock, 140 + blockDraft: savedBlock, 141 + choiceOptionsDraft: [], 142 + branchRulesDraft: createBranchRuleDrafts([ 143 + { 144 + operator: "contains", 145 + value: "vip-only", 146 + targetBlockId: "priority-review", 147 + }, 148 + ]), 149 + }), 150 + ).toBe(true); 49 151 }); 50 152 });
+73
lib/form-builder-drafts.ts
··· 3 3 import { 4 4 AGREEMENT_ANSWER_VALUES, 5 5 getVisibleBranchOperators, 6 + isChoiceBlock, 7 + isQuestionBlock, 8 + type BlockConfig, 6 9 type BranchOperator, 7 10 type BranchRule, 8 11 } from "@/lib/blocks"; 12 + import { normalizeBlockConfig } from "@/lib/block-config-normalization"; 13 + import type { BuilderBlock } from "@/lib/form-types"; 9 14 10 15 export type ChoiceOptionDraft = { 11 16 id: string; ··· 69 74 targetBlockId, 70 75 }); 71 76 } 77 + 78 + type ComparableBuilderBlockDraft = Pick< 79 + BuilderBlock, 80 + "id" | "type" | "title" | "description" | "required" 81 + > & { 82 + config: BlockConfig; 83 + }; 84 + 85 + function createComparableBuilderBlock( 86 + block: BuilderBlock, 87 + ): ComparableBuilderBlockDraft { 88 + return { 89 + id: block.id, 90 + type: block.type, 91 + title: block.title, 92 + description: block.description, 93 + required: block.required, 94 + config: normalizeBlockConfig(block.type, block.config), 95 + }; 96 + } 97 + 98 + export function normalizeBuilderBlockDraft(args: { 99 + blockDraft: BuilderBlock; 100 + choiceOptionsDraft: ChoiceOptionDraft[]; 101 + branchRulesDraft: BranchRuleDraft[]; 102 + }): ComparableBuilderBlockDraft { 103 + const { blockDraft, choiceOptionsDraft, branchRulesDraft } = args; 104 + const config = { 105 + ...(blockDraft.config as Record<string, unknown>), 106 + }; 107 + 108 + if (isChoiceBlock(blockDraft.type)) { 109 + config.options = serializeChoiceOptionDrafts(choiceOptionsDraft); 110 + } 111 + 112 + if (isQuestionBlock(blockDraft.type)) { 113 + config.branchRules = serializeBranchRuleDrafts(branchRulesDraft); 114 + } 115 + 116 + return createComparableBuilderBlock({ 117 + ...blockDraft, 118 + config: normalizeBlockConfig(blockDraft.type, config), 119 + }); 120 + } 121 + 122 + export function hasBuilderBlockDraftChanges(args: { 123 + savedBlock: BuilderBlock | null; 124 + blockDraft: BuilderBlock | null; 125 + choiceOptionsDraft: ChoiceOptionDraft[]; 126 + branchRulesDraft: BranchRuleDraft[]; 127 + }) { 128 + const { savedBlock, blockDraft, choiceOptionsDraft, branchRulesDraft } = args; 129 + 130 + if (!savedBlock || !blockDraft || savedBlock.id !== blockDraft.id) { 131 + return false; 132 + } 133 + 134 + return ( 135 + JSON.stringify(createComparableBuilderBlock(savedBlock)) !== 136 + JSON.stringify( 137 + normalizeBuilderBlockDraft({ 138 + blockDraft, 139 + choiceOptionsDraft, 140 + branchRulesDraft, 141 + }), 142 + ) 143 + ); 144 + }
+5
locales/en.yml
··· 222 222 requiredToggle: Required 223 223 deleteBlock: Delete block 224 224 saveBlock: Save block 225 + unsavedBlockChangesTitle: Save changes before leaving this block? 226 + unsavedBlockChangesDescription: You have unsaved edits in this block. Save them before switching blocks or opening form settings, or cancel the changes and continue. 227 + saveBlockChanges: Save 228 + cancelBlockChanges: Reset 229 + backFromUnsavedBlockChanges: Back 225 230 nothingSelected: Nothing selected 226 231 nothingSelectedTitle: Select a block or open form settings. 227 232 nothingSelectedDescription: Use the left panel to choose what you want to edit.
+5
locales/ru.yml
··· 222 222 requiredToggle: Обязательный 223 223 deleteBlock: Удалить блок 224 224 saveBlock: Сохранить блок 225 + unsavedBlockChangesTitle: Сохранить изменения перед выходом из блока? 226 + unsavedBlockChangesDescription: В этом блоке есть несохранённые правки. Сохраните их перед переключением на другой блок или настройками формы либо отмените изменения и продолжайте. 227 + saveBlockChanges: Сохранить 228 + cancelBlockChanges: Сбросить 229 + backFromUnsavedBlockChanges: Назад 225 230 nothingSelected: Ничего не выбрано 226 231 nothingSelectedTitle: Выберите блок или откройте настройки формы. 227 232 nothingSelectedDescription: Используйте левую панель, чтобы выбрать, что вы хотите редактировать.
+2
openspec/changes/archive/2026-04-14-prompt-save-before-leaving-edited-block/.openspec.yaml
··· 1 + schema: spec-driven 2 + created: 2026-04-14
+87
openspec/changes/archive/2026-04-14-prompt-save-before-leaving-edited-block/design.md
··· 1 + ## Context 2 + 3 + The builder keeps form-level data and saved blocks in `components/form-builder.tsx`, but block edits live in local draft state (`blockDraft`, `choiceOptionsDraft`, and `branchRulesDraft`) until the creator presses **Save block**. The current selection model immediately swaps the editor when the creator selects another block or opens form settings, so unsaved edits are replaced by the newly selected data with no warning. 4 + 5 + This change is limited to the creator builder. It does not require API, persistence, or schema changes because the existing block save endpoint already supports the data that must be preserved. 6 + 7 + ## Goals / Non-Goals 8 + 9 + **Goals:** 10 + - Detect whether the active block editor has unsaved changes compared with the last saved block state. 11 + - Intercept builder actions that would replace the active block editor. 12 + - Offer clear resolution paths: save changes and continue, discard changes and continue, or cancel and stay on the current block. 13 + - Reuse the existing explicit save flow so saved data still comes from the same block PATCH request. 14 + 15 + **Non-Goals:** 16 + - Introduce autosave for block edits. 17 + - Warn on every page navigation or browser tab close. 18 + - Add unsaved-change protection for form settings fields in this change. 19 + - Change block validation, publishing, or storage behavior. 20 + 21 + ## Decisions 22 + 23 + ### 1. Compute dirty state from saved block data versus serialized editor draft 24 + The builder will derive `hasUnsavedBlockChanges` from the currently selected saved block in `form.blocks` and the active editor draft state. 25 + 26 + Rationale: 27 + - `selectedBlock` already reflects the last saved server state. 28 + - Comparing serialized values avoids false positives from ephemeral draft IDs used for choice options and branch rules. 29 + - Dirty detection stays local to the builder and does not require extra server reads. 30 + 31 + Alternatives considered: 32 + - Maintain a manual dirty flag on every input change. Rejected because it is easier to desynchronize when selection resets or saved data is reloaded. 33 + - Deep-compare raw draft objects including generated IDs. Rejected because choice and branch draft IDs are intentionally unstable. 34 + 35 + ### 2. Gate editor-exit actions through a single pending-navigation workflow 36 + Actions that replace the active block editor will route through one helper that either executes immediately or opens an unsaved-changes dialog when `hasUnsavedBlockChanges` is true. The pending action will be stored as a callback or discriminated state object and executed only after the creator chooses save or discard. 37 + 38 + Covered actions: 39 + - selecting a different block 40 + - switching from a block to form settings 41 + - creating a new block when the UI would switch focus to that new block 42 + 43 + Rationale: 44 + - Centralizing the guard keeps selection behavior consistent. 45 + - The same workflow can support more editor-exit actions later without duplicating prompt logic. 46 + 47 + Alternatives considered: 48 + - Add separate confirmation logic inside each click handler. Rejected because it spreads stateful behavior across the component and makes later maintenance harder. 49 + 50 + ### 3. Save-before-leave reuses the existing `saveBlock` request path 51 + The dialog's primary action will call the same save logic used by the existing **Save block** button, then continue the pending navigation only after the save succeeds. If the save fails, the builder will keep the current editor open and surface the existing error toast. 52 + 53 + Rationale: 54 + - Preserves one server write path for block updates. 55 + - Avoids divergent payload shapes or duplicated mutation logic. 56 + - Matches creator expectations: “Save” in the dialog behaves exactly like “Save block”. 57 + 58 + Alternatives considered: 59 + - Perform a lighter-weight temporary save or local stash before navigating. Rejected because it adds a second persistence model and changes the product behavior beyond the request. 60 + 61 + ### 4. Use a three-action modal built on the existing dialog primitive 62 + The existing `ConfirmDialog` already supports custom `actions`, so the builder can present **Cancel**, **Discard changes**, and **Save changes** without introducing a second modal framework. New i18n strings will be added for the dialog title, body copy, and action labels. 63 + 64 + Rationale: 65 + - Keeps the UI consistent with the existing deletion confirmation pattern. 66 + - Minimizes implementation surface area. 67 + - Supports the required third action without changing the component contract significantly. 68 + 69 + Alternatives considered: 70 + - Replace the dialog with inline warnings inside the editor panel. Rejected because the creator may already have initiated navigation, and the system needs an immediate blocking choice. 71 + 72 + ## Risks / Trade-offs 73 + 74 + - Dirty detection misses a field or compares the wrong normalized value → Add a dedicated helper for block-draft serialization and cover it with unit tests. 75 + - Save-then-navigate can feel slow on poor connections → Reuse the existing pending state and disable repeated dialog actions while save is in flight. 76 + - Pending navigation could run against stale selection state after async save → Store navigation intent explicitly and clear it only after execution or cancellation. 77 + - Scope remains limited to block editors, so unsaved form settings can still be lost → Document that form settings protection is out of scope for this change. 78 + 79 + ## Migration Plan 80 + 81 + - No data migration is required. 82 + - Ship as a client-side builder update. 83 + - Rollback is a normal code rollback because no persisted shape changes are involved. 84 + 85 + ## Open Questions 86 + 87 + - None for proposal scope. The default behavior will be to guard only actions that leave the active block editor inside the builder experience.
+24
openspec/changes/archive/2026-04-14-prompt-save-before-leaving-edited-block/proposal.md
··· 1 + ## Why 2 + 3 + The form builder keeps block edits in a local draft until the creator explicitly saves them. Today, selecting another block or switching away from the editor silently drops those unsaved changes, which makes the builder feel unreliable and increases the risk of accidental data loss. 4 + 5 + ## What Changes 6 + 7 + - Detect unsaved edits in the active block editor by comparing the local block draft against the last saved block state. 8 + - Prompt the creator before actions that would leave the active block editor with unsaved changes. 9 + - Let the creator save changes, discard changes, or cancel the navigation so they can stay on the current block. 10 + - Preserve the existing explicit save flow for block edits instead of introducing background autosave. 11 + 12 + ## Capabilities 13 + 14 + ### New Capabilities 15 + - None. 16 + 17 + ### Modified Capabilities 18 + - `conversational-form-builder`: require the builder to warn before switching away from a block that has unsaved edits and provide save/discard/cancel resolution paths. 19 + 20 + ## Impact 21 + 22 + - Affected UI: `components/form-builder.tsx`, `components/form-builder-panels.tsx`, and shared confirmation dialog patterns. 23 + - Affected behavior: block selection changes, switching from a block to form settings, and other builder actions that replace the active block editor. 24 + - No API or database schema changes are expected.
+28
openspec/changes/archive/2026-04-14-prompt-save-before-leaving-edited-block/specs/conversational-form-builder/spec.md
··· 1 + ## MODIFIED Requirements 2 + 3 + ### Requirement: Builder exposes block editing through a master-detail layout 4 + The system SHALL present a builder interface with an ordered block list for navigation and reordering and a separate editor panel for the selected block's settings. For long forms, the builder SHALL keep the navigation list usable without making the entire page grow unbounded from the block column alone. When an authenticated creator attempts to replace the active block editor with another block editor or the form settings panel while the current block has unsaved edits, the system SHALL prompt the creator to save changes, discard changes, or cancel the navigation. 5 + 6 + #### Scenario: Creator selects a block with no unsaved edits 7 + - **WHEN** an authenticated creator selects a block in the block list and the current block editor has no unsaved edits 8 + - **THEN** the system displays that block's editable settings in the editor panel 9 + 10 + #### Scenario: Creator saves changes while switching blocks 11 + - **WHEN** an authenticated creator selects a different block while the current block editor has unsaved edits and chooses to save changes 12 + - **THEN** the system saves the current block changes and opens the newly selected block in the editor panel 13 + 14 + #### Scenario: Creator discards changes while switching blocks 15 + - **WHEN** an authenticated creator selects a different block while the current block editor has unsaved edits and chooses to discard changes 16 + - **THEN** the system leaves the saved block unchanged and opens the newly selected block in the editor panel 17 + 18 + #### Scenario: Creator cancels block switching 19 + - **WHEN** an authenticated creator selects a different block while the current block editor has unsaved edits and chooses to cancel 20 + - **THEN** the system keeps the current block editor open with the unsaved edits intact 21 + 22 + #### Scenario: Creator opens form settings with unsaved block edits 23 + - **WHEN** an authenticated creator switches from a block editor to the form settings panel while the current block has unsaved edits 24 + - **THEN** the system requires the creator to save changes, discard changes, or cancel before replacing the block editor panel 25 + 26 + #### Scenario: Creator works on a long form 27 + - **WHEN** an authenticated creator opens a form with many blocks 28 + - **THEN** the builder keeps the block list navigable without requiring the full page height to expand indefinitely with the left column
+18
openspec/changes/archive/2026-04-14-prompt-save-before-leaving-edited-block/tasks.md
··· 1 + ## 1. Dirty-state detection 2 + 3 + - [x] 1.1 Add a builder helper that normalizes the active block draft, choice option drafts, and branch rule drafts into the same shape as the saved block. 4 + - [x] 1.2 Derive `hasUnsavedBlockChanges` in `components/form-builder.tsx` by comparing the normalized active draft against the currently selected saved block. 5 + - [x] 1.3 Add unit coverage for the normalization and dirty-state comparison logic, including choice and branching edits. 6 + 7 + ## 2. Guard editor-exit actions 8 + 9 + - [x] 2.1 Add pending-navigation state in `components/form-builder.tsx` so block-selection changes, form-settings selection, and new-block creation can be intercepted consistently. 10 + - [x] 2.2 Add an unsaved-changes dialog with cancel, discard, and save actions using the existing dialog primitive and builder-specific copy. 11 + - [x] 2.3 Update the save flow so the dialog's save action reuses the existing block save request and continues navigation only after a successful save. 12 + - [x] 2.4 Update discard and cancel handling so discard proceeds with navigation without saving and cancel keeps the current block draft intact. 13 + 14 + ## 3. UX polish and verification 15 + 16 + - [x] 3.1 Add any required translation strings for the unsaved-changes dialog and button labels. 17 + - [x] 3.2 Add component or interaction coverage for switching blocks and opening form settings with and without unsaved edits. 18 + - [x] 3.3 Manually verify the builder flows for save, discard, cancel, and save-failure behavior before closing the change.
+19 -3
openspec/specs/conversational-form-builder/spec.md
··· 31 31 - **THEN** the system removes that block from the current form structure 32 32 33 33 ### Requirement: Builder exposes block editing through a master-detail layout 34 - The system SHALL present a builder interface with an ordered block list for navigation and reordering and a separate editor panel for the selected block's settings. For long forms, the builder SHALL keep the navigation list usable without making the entire page grow unbounded from the block column alone. 34 + The system SHALL present a builder interface with an ordered block list for navigation and reordering and a separate editor panel for the selected block's settings. For long forms, the builder SHALL keep the navigation list usable without making the entire page grow unbounded from the block column alone. When an authenticated creator attempts to replace the active block editor with another block editor or the form settings panel while the current block has unsaved edits, the system SHALL prompt the creator to save changes, discard changes, or cancel the navigation. 35 35 36 - #### Scenario: Creator selects a block 37 - - **WHEN** an authenticated creator selects a block in the block list 36 + #### Scenario: Creator selects a block with no unsaved edits 37 + - **WHEN** an authenticated creator selects a block in the block list and the current block editor has no unsaved edits 38 38 - **THEN** the system displays that block's editable settings in the editor panel 39 + 40 + #### Scenario: Creator saves changes while switching blocks 41 + - **WHEN** an authenticated creator selects a different block while the current block editor has unsaved edits and chooses to save changes 42 + - **THEN** the system saves the current block changes and opens the newly selected block in the editor panel 43 + 44 + #### Scenario: Creator discards changes while switching blocks 45 + - **WHEN** an authenticated creator selects a different block while the current block editor has unsaved edits and chooses to discard changes 46 + - **THEN** the system leaves the saved block unchanged and opens the newly selected block in the editor panel 47 + 48 + #### Scenario: Creator cancels block switching 49 + - **WHEN** an authenticated creator selects a different block while the current block editor has unsaved edits and chooses to cancel 50 + - **THEN** the system keeps the current block editor open with the unsaved edits intact 51 + 52 + #### Scenario: Creator opens form settings with unsaved block edits 53 + - **WHEN** an authenticated creator switches from a block editor to the form settings panel while the current block has unsaved edits 54 + - **THEN** the system requires the creator to save changes, discard changes, or cancel before replacing the block editor panel 39 55 40 56 #### Scenario: Creator works on a long form 41 57 - **WHEN** an authenticated creator opens a form with many blocks