diff --git a/components/form-builder.test.tsx b/components/form-builder.test.tsx new file mode 100644 index 0000000..a2aba5a --- /dev/null +++ b/components/form-builder.test.tsx @@ -0,0 +1,300 @@ +import { describe, expect, test } from "bun:test"; +import { cleanup, fireEvent, render, waitFor } from "@testing-library/react"; + +import { I18nProvider } from "@/components/i18n-provider"; +import { getDefaultBlockConfig } from "@/lib/blocks"; +import type { BuilderBlock, BuilderForm } from "@/lib/form-types"; +import { installTestDom } from "@/test/install-dom"; + +const router = { + push: () => {}, + refresh: () => {}, +}; +const { mock } = (await import("bun:test")) as unknown as { + mock: { + module: (specifier: string, factory: () => Record) => void; + }; +}; + +mock.module("next/navigation", () => ({ + useRouter: () => router, +})); + +const { FormBuilder } = await import("@/components/form-builder"); + +function createBlock( + overrides: Partial & + Pick, +): BuilderBlock { + const now = new Date("2026-04-14T12:00:00.000Z"); + + return { + formId: "form-1", + title: "", + description: "", + required: false, + createdAt: now, + updatedAt: now, + config: getDefaultBlockConfig(overrides.type), + ...overrides, + } as BuilderBlock; +} + +function createForm(blocks: BuilderBlock[]): BuilderForm { + return { + id: "form-1", + title: "Form title", + description: "", + completionTitle: "Done", + completionMessage: "Thanks", + completionLinkLabel: null, + completionLinkUrl: null, + showProgress: true, + slug: "demo-form", + status: "DRAFT", + updatedAt: "2026-04-14T12:00:00.000Z", + responseCount: 0, + workspace: { + kind: "personal", + label: "Personal workspace", + }, + blocks, + }; +} + +function renderBuilder(initialForm: BuilderForm) { + return render( + + + , + ); +} + +describe("FormBuilder unsaved block protection", () => { + test("switches blocks immediately when there are no unsaved edits", async () => { + const restoreDom = installTestDom(); + const firstBlock = createBlock({ + id: "block-1", + type: "SHORT_TEXT", + position: 0, + title: "First question", + }); + const secondBlock = createBlock({ + id: "block-2", + type: "SHORT_TEXT", + position: 1, + title: "Second question", + }); + + const view = renderBuilder(createForm([firstBlock, secondBlock])); + + fireEvent.click(view.getByRole("button", { name: "Second question" })); + + await waitFor(() => { + expect(view.getByDisplayValue("Second question") !== null).toBe(true); + }); + expect(view.queryByText("builder.unsavedBlockChangesTitle")).toBe(null); + + cleanup(); + restoreDom(); + }); + + test("opens form settings immediately when there are no unsaved edits", async () => { + const restoreDom = installTestDom(); + const firstBlock = createBlock({ + id: "block-1", + type: "SHORT_TEXT", + position: 0, + title: "First question", + }); + + const view = renderBuilder(createForm([firstBlock])); + + fireEvent.click(view.getByRole("button", { name: "builder.settings" })); + + await waitFor(() => { + expect(view.getByText("builder.settingsTitle") !== null).toBe(true); + }); + expect(view.queryByText("builder.unsavedBlockChangesTitle")).toBe(null); + + cleanup(); + restoreDom(); + }); + + test("lets the creator cancel or discard unsaved changes before opening form settings", async () => { + const restoreDom = installTestDom(); + const firstBlock = createBlock({ + id: "block-1", + type: "SHORT_TEXT", + position: 0, + title: "First question", + }); + + const view = renderBuilder(createForm([firstBlock])); + const requiredToggle = view.getAllByRole("checkbox")[0] as HTMLInputElement; + fireEvent.click(requiredToggle); + + await waitFor(() => { + expect(requiredToggle.checked).toBe(true); + }); + + fireEvent.click(view.getByRole("button", { name: "builder.settings" })); + + await waitFor(() => { + expect(view.getByText("builder.unsavedBlockChangesTitle") !== null).toBe( + true, + ); + }); + + fireEvent.click( + view.getByRole("button", { name: "builder.backFromUnsavedBlockChanges" }), + ); + + await waitFor(() => { + expect(view.queryByText("builder.unsavedBlockChangesTitle")).toBe(null); + }); + expect(requiredToggle.checked).toBe(true); + + fireEvent.click(view.getByRole("button", { name: "builder.settings" })); + + await waitFor(() => { + expect(view.getByText("builder.unsavedBlockChangesTitle") !== null).toBe( + true, + ); + }); + + fireEvent.click( + view.getByRole("button", { name: "builder.cancelBlockChanges" }), + ); + + await waitFor(() => { + expect(view.getByText("builder.settingsTitle") !== null).toBe(true); + }); + + cleanup(); + restoreDom(); + }); + + test("saves the active block before switching when the creator chooses save changes", async () => { + const restoreDom = installTestDom(); + const firstBlock = createBlock({ + id: "block-1", + type: "SHORT_TEXT", + position: 0, + title: "First question", + }); + const secondBlock = createBlock({ + id: "block-2", + type: "SHORT_TEXT", + position: 1, + title: "Second 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 () => ({ + block: { + ...firstBlock, + required: true, + }, + }), + } as Response; + }) as typeof fetch; + + const view = renderBuilder(createForm([firstBlock, secondBlock])); + const requiredToggle = view.getAllByRole("checkbox")[0] as HTMLInputElement; + fireEvent.click(requiredToggle); + + await waitFor(() => { + expect(requiredToggle.checked).toBe(true); + }); + + fireEvent.click(view.getByRole("button", { name: "Second question" })); + + await waitFor(() => { + expect(view.getByText("builder.unsavedBlockChangesTitle") !== null).toBe( + true, + ); + }); + + fireEvent.click( + view.getByRole("button", { name: "builder.saveBlockChanges" }), + ); + + await waitFor(() => { + expect(fetchCalls.length).toBe(1); + expect(view.getByDisplayValue("Second question") !== null).toBe(true); + }); + + expect(fetchCalls[0]?.url).toBe("/api/forms/form-1/blocks/block-1"); + expect(fetchCalls[0]?.init?.method).toBe("PATCH"); + expect(fetchCalls[0]?.init?.body).toContain('"required":true'); + + globalThis.fetch = previousFetch; + cleanup(); + restoreDom(); + }); + + test("keeps the unsaved-changes dialog open when saving fails", async () => { + const restoreDom = installTestDom(); + const firstBlock = createBlock({ + id: "block-1", + type: "SHORT_TEXT", + position: 0, + title: "First question", + }); + const secondBlock = createBlock({ + id: "block-2", + type: "SHORT_TEXT", + position: 1, + title: "Second question", + }); + const previousFetch = globalThis.fetch; + + globalThis.fetch = (async () => + ({ + ok: false, + json: async () => ({ error: "Could not save block." }), + }) as Response) as typeof fetch; + + const view = renderBuilder(createForm([firstBlock, secondBlock])); + const requiredToggle = view.getAllByRole("checkbox")[0] as HTMLInputElement; + fireEvent.click(requiredToggle); + + await waitFor(() => { + expect(requiredToggle.checked).toBe(true); + }); + + fireEvent.click(view.getByRole("button", { name: "Second question" })); + + await waitFor(() => { + expect(view.getByText("builder.unsavedBlockChangesTitle") !== null).toBe( + true, + ); + }); + + fireEvent.click( + view.getByRole("button", { name: "builder.saveBlockChanges" }), + ); + + await waitFor(() => { + expect(view.getByText("builder.unsavedBlockChangesTitle") !== null).toBe( + true, + ); + expect(view.getByText("Could not save block.") !== null).toBe(true); + }); + expect(view.queryByDisplayValue("Second question")).toBe(null); + + globalThis.fetch = previousFetch; + cleanup(); + restoreDom(); + }); +}); diff --git a/components/form-builder.tsx b/components/form-builder.tsx index 559ecff..ad19a68 100644 --- a/components/form-builder.tsx +++ b/components/form-builder.tsx @@ -58,6 +58,7 @@ import { import { createBranchRuleDrafts, createChoiceOptionDrafts, + hasBuilderBlockDraftChanges, serializeBranchRuleDrafts, type BranchRuleDraft, type ChoiceOptionDraft, @@ -68,6 +69,10 @@ import { cn } from "@/lib/utils"; type BlockType = BuilderBlock["type"]; type Selection = { kind: "form" } | { kind: "block"; blockId: string }; +type PendingNavigation = + | { kind: "form" } + | { kind: "block"; blockId: string } + | { kind: "add-block"; blockType: BlockType }; const blockIcons: Record = { TEXT: Type, @@ -258,6 +263,8 @@ export function FormBuilder({ initialForm }: { initialForm: BuilderForm }) { const [isBlockMenuOpen, setIsBlockMenuOpen] = useState(false); const [isDeleteFormDialogOpen, setIsDeleteFormDialogOpen] = useState(false); + const [pendingNavigation, setPendingNavigation] = + useState(null); const blockMenuRef = useRef(null); const selectedBlock = useMemo( @@ -288,6 +295,23 @@ export function FormBuilder({ initialForm }: { initialForm: BuilderForm }) { : [], ); const SelectedBlockIcon = blockDraft ? blockIcons[blockDraft.type] : null; + const hasUnsavedBlockChanges = useMemo( + () => + selection.kind === "block" && + hasBuilderBlockDraftChanges({ + savedBlock: selectedBlock, + blockDraft, + choiceOptionsDraft, + branchRulesDraft, + }), + [ + blockDraft, + branchRulesDraft, + choiceOptionsDraft, + selectedBlock, + selection.kind, + ], + ); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 4 } }), @@ -359,12 +383,14 @@ export function FormBuilder({ initialForm }: { initialForm: BuilderForm }) { try { await runner(); + return true; } catch (caughtError) { const nextError = caughtError instanceof Error ? caughtError.message : t("builder.toasts.genericError"); showToast(nextError, "error"); + return false; } finally { setBusy(null); } @@ -385,8 +411,8 @@ export function FormBuilder({ initialForm }: { initialForm: BuilderForm }) { }); } - async function addBlock(type: BlockType) { - await withTask(`add-${type}`, async () => { + async function addBlockNow(type: BlockType) { + return withTask(`add-${type}`, async () => { const payload = await fetchJson<{ block: BuilderBlock }>( `/api/forms/${form.id}/blocks`, { @@ -411,10 +437,10 @@ export function FormBuilder({ initialForm }: { initialForm: BuilderForm }) { async function saveBlock() { if (!blockDraft) { - return; + return false; } - await withTask(`block-${blockDraft.id}`, async () => { + return withTask(`block-${blockDraft.id}`, async () => { const serializedBranchRules = serializeBranchRuleDrafts(branchRulesDraft); const payload = await fetchJson<{ block: BuilderBlock }>( `/api/forms/${form.id}/blocks/${blockDraft.id}`, @@ -452,6 +478,70 @@ export function FormBuilder({ initialForm }: { initialForm: BuilderForm }) { }); } + async function executePendingNavigation(navigation: PendingNavigation) { + switch (navigation.kind) { + case "form": { + setSelection({ kind: "form" }); + break; + } + case "block": { + setSelection({ kind: "block", blockId: navigation.blockId }); + break; + } + case "add-block": { + await addBlockNow(navigation.blockType); + break; + } + } + } + + function requestPendingNavigation(navigation: PendingNavigation) { + if (navigation.kind === "form" && selection.kind === "form") { + return; + } + + if ( + navigation.kind === "block" && + selection.kind === "block" && + selection.blockId === navigation.blockId + ) { + return; + } + + if (hasUnsavedBlockChanges) { + setPendingNavigation(navigation); + return; + } + + void executePendingNavigation(navigation); + } + + async function confirmPendingNavigationWithSave() { + if (!pendingNavigation) { + return; + } + + const navigation = pendingNavigation; + const didSave = await saveBlock(); + + if (!didSave) { + return; + } + + setPendingNavigation(null); + await executePendingNavigation(navigation); + } + + async function confirmPendingNavigationWithoutSave() { + if (!pendingNavigation) { + return; + } + + const navigation = pendingNavigation; + setPendingNavigation(null); + await executePendingNavigation(navigation); + } + async function deleteBlock(blockId: string) { await withTask(`delete-${blockId}`, async () => { await fetchJson(`/api/forms/${form.id}/blocks/${blockId}`, { @@ -573,13 +663,52 @@ export function FormBuilder({ initialForm }: { initialForm: BuilderForm }) { pending={busy === "delete-form"} onConfirm={confirmDeleteForm} /> + setPendingNavigation(null)} + title={t("builder.unsavedBlockChangesTitle")} + description={t("builder.unsavedBlockChangesDescription")} + confirmLabel={t("builder.saveBlockChanges")} + cancelLabel={t("ui.cancel")} + pending={Boolean(busy?.startsWith("block-"))} + actions={ +
+ + + +
+ } + />
setSelection({ kind: "form" })} + onOpenSettings={() => requestPendingNavigation({ kind: "form" })} onPublish={() => setPublished(true)} onUnpublish={() => setPublished(false)} onCopyShareLink={copyShareLink} @@ -668,7 +797,12 @@ export function FormBuilder({ initialForm }: { initialForm: BuilderForm }) { key={type} type="button" 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)]" - onClick={() => addBlock(type)} + onClick={() => + requestPendingNavigation({ + kind: "add-block", + blockType: type, + }) + } > {busy === `add-${type}` ? ( @@ -706,7 +840,7 @@ export function FormBuilder({ initialForm }: { initialForm: BuilderForm }) { selection.blockId === block.id } onSelect={(blockId) => - setSelection({ kind: "block", blockId }) + requestPendingNavigation({ kind: "block", blockId }) } /> ))} @@ -722,7 +856,7 @@ export function FormBuilder({ initialForm }: { initialForm: BuilderForm }) { selection.blockId === block.id } onSelect={(blockId) => - setSelection({ kind: "block", blockId }) + requestPendingNavigation({ kind: "block", blockId }) } /> )) @@ -761,7 +895,14 @@ export function FormBuilder({ initialForm }: { initialForm: BuilderForm }) { busy={busy} /> ) : ( - addBlock("SHORT_TEXT")} /> + + requestPendingNavigation({ + kind: "add-block", + blockType: "SHORT_TEXT", + }) + } + /> )}
diff --git a/lib/form-builder-drafts.test.ts b/lib/form-builder-drafts.test.ts index 9cfb3c1..84e92a6 100644 --- a/lib/form-builder-drafts.test.ts +++ b/lib/form-builder-drafts.test.ts @@ -1,11 +1,33 @@ import { describe, expect, test } from "bun:test"; import { + createBranchRuleDrafts, createChoiceOptionDrafts, createDefaultBranchRuleDraft, + hasBuilderBlockDraftChanges, serializeBranchRuleDrafts, serializeChoiceOptionDrafts, } from "@/lib/form-builder-drafts"; +import { getDefaultBlockConfig } from "@/lib/blocks"; +import type { BuilderBlock } from "@/lib/form-types"; + +function createBlock( + overrides: Partial & + Pick, +): BuilderBlock { + const now = new Date("2026-04-14T12:00:00.000Z"); + + return { + formId: "form-1", + title: "", + description: "", + required: false, + createdAt: now, + updatedAt: now, + config: getDefaultBlockConfig(overrides.type), + ...overrides, + } as BuilderBlock; +} describe("form builder draft helpers", () => { test("preserves choice option order when drafts are saved back into block config", () => { @@ -47,4 +69,84 @@ describe("form builder draft helpers", () => { expect(draft.value).toBe("agreed"); expect(draft.targetBlockId).toBe("accepted"); }); + + test("treats choice drafts with regenerated ids as unchanged when the saved content matches", () => { + const savedBlock = createBlock({ + id: "choice-1", + type: "SINGLE_CHOICE", + position: 0, + title: "Pick one", + config: { + ...getDefaultBlockConfig("SINGLE_CHOICE"), + options: ["Yes", "No"], + }, + }); + + expect( + hasBuilderBlockDraftChanges({ + savedBlock, + blockDraft: savedBlock, + choiceOptionsDraft: createChoiceOptionDrafts(["Yes", "No"]), + branchRulesDraft: [], + }), + ).toBe(false); + + expect( + hasBuilderBlockDraftChanges({ + savedBlock, + blockDraft: savedBlock, + choiceOptionsDraft: createChoiceOptionDrafts(["Yes", "Maybe"]), + branchRulesDraft: [], + }), + ).toBe(true); + }); + + test("detects branching edits from serialized rule content instead of draft ids", () => { + const savedBlock = createBlock({ + id: "question-1", + type: "SHORT_TEXT", + position: 0, + title: "Why?", + config: { + ...getDefaultBlockConfig("SHORT_TEXT"), + branchRules: [ + { + operator: "contains", + value: "vip", + targetBlockId: "priority-review", + }, + ], + }, + }); + + expect( + hasBuilderBlockDraftChanges({ + savedBlock, + blockDraft: savedBlock, + choiceOptionsDraft: [], + branchRulesDraft: createBranchRuleDrafts([ + { + operator: "contains", + value: "vip", + targetBlockId: "priority-review", + }, + ]), + }), + ).toBe(false); + + expect( + hasBuilderBlockDraftChanges({ + savedBlock, + blockDraft: savedBlock, + choiceOptionsDraft: [], + branchRulesDraft: createBranchRuleDrafts([ + { + operator: "contains", + value: "vip-only", + targetBlockId: "priority-review", + }, + ]), + }), + ).toBe(true); + }); }); diff --git a/lib/form-builder-drafts.ts b/lib/form-builder-drafts.ts index 2e18d21..ec2ddb7 100644 --- a/lib/form-builder-drafts.ts +++ b/lib/form-builder-drafts.ts @@ -3,9 +3,14 @@ import type { FormBlockType as PrismaFormBlockType } from "@prisma/client"; import { AGREEMENT_ANSWER_VALUES, getVisibleBranchOperators, + isChoiceBlock, + isQuestionBlock, + type BlockConfig, type BranchOperator, type BranchRule, } from "@/lib/blocks"; +import { normalizeBlockConfig } from "@/lib/block-config-normalization"; +import type { BuilderBlock } from "@/lib/form-types"; export type ChoiceOptionDraft = { id: string; @@ -69,3 +74,71 @@ export function createDefaultBranchRuleDraft(args: { targetBlockId, }); } + +type ComparableBuilderBlockDraft = Pick< + BuilderBlock, + "id" | "type" | "title" | "description" | "required" +> & { + config: BlockConfig; +}; + +function createComparableBuilderBlock( + block: BuilderBlock, +): ComparableBuilderBlockDraft { + return { + id: block.id, + type: block.type, + title: block.title, + description: block.description, + required: block.required, + config: normalizeBlockConfig(block.type, block.config), + }; +} + +export function normalizeBuilderBlockDraft(args: { + blockDraft: BuilderBlock; + choiceOptionsDraft: ChoiceOptionDraft[]; + branchRulesDraft: BranchRuleDraft[]; +}): ComparableBuilderBlockDraft { + const { blockDraft, choiceOptionsDraft, branchRulesDraft } = args; + const config = { + ...(blockDraft.config as Record), + }; + + if (isChoiceBlock(blockDraft.type)) { + config.options = serializeChoiceOptionDrafts(choiceOptionsDraft); + } + + if (isQuestionBlock(blockDraft.type)) { + config.branchRules = serializeBranchRuleDrafts(branchRulesDraft); + } + + return createComparableBuilderBlock({ + ...blockDraft, + config: normalizeBlockConfig(blockDraft.type, config), + }); +} + +export function hasBuilderBlockDraftChanges(args: { + savedBlock: BuilderBlock | null; + blockDraft: BuilderBlock | null; + choiceOptionsDraft: ChoiceOptionDraft[]; + branchRulesDraft: BranchRuleDraft[]; +}) { + const { savedBlock, blockDraft, choiceOptionsDraft, branchRulesDraft } = args; + + if (!savedBlock || !blockDraft || savedBlock.id !== blockDraft.id) { + return false; + } + + return ( + JSON.stringify(createComparableBuilderBlock(savedBlock)) !== + JSON.stringify( + normalizeBuilderBlockDraft({ + blockDraft, + choiceOptionsDraft, + branchRulesDraft, + }), + ) + ); +} diff --git a/locales/en.yml b/locales/en.yml index ca1fa73..7734b5b 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -222,6 +222,11 @@ builder: requiredToggle: Required deleteBlock: Delete block saveBlock: Save block + unsavedBlockChangesTitle: Save changes before leaving this block? + unsavedBlockChangesDescription: You have unsaved edits in this block. Save them before switching blocks or opening form settings, or cancel the changes and continue. + saveBlockChanges: Save + cancelBlockChanges: Reset + backFromUnsavedBlockChanges: Back nothingSelected: Nothing selected nothingSelectedTitle: Select a block or open form settings. nothingSelectedDescription: Use the left panel to choose what you want to edit. diff --git a/locales/ru.yml b/locales/ru.yml index d74f225..2cef591 100644 --- a/locales/ru.yml +++ b/locales/ru.yml @@ -222,6 +222,11 @@ builder: requiredToggle: Обязательный deleteBlock: Удалить блок saveBlock: Сохранить блок + unsavedBlockChangesTitle: Сохранить изменения перед выходом из блока? + unsavedBlockChangesDescription: В этом блоке есть несохранённые правки. Сохраните их перед переключением на другой блок или настройками формы либо отмените изменения и продолжайте. + saveBlockChanges: Сохранить + cancelBlockChanges: Сбросить + backFromUnsavedBlockChanges: Назад nothingSelected: Ничего не выбрано nothingSelectedTitle: Выберите блок или откройте настройки формы. nothingSelectedDescription: Используйте левую панель, чтобы выбрать, что вы хотите редактировать. diff --git a/openspec/changes/archive/2026-04-14-prompt-save-before-leaving-edited-block/.openspec.yaml b/openspec/changes/archive/2026-04-14-prompt-save-before-leaving-edited-block/.openspec.yaml new file mode 100644 index 0000000..76a85e8 --- /dev/null +++ b/openspec/changes/archive/2026-04-14-prompt-save-before-leaving-edited-block/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-14 diff --git a/openspec/changes/archive/2026-04-14-prompt-save-before-leaving-edited-block/design.md b/openspec/changes/archive/2026-04-14-prompt-save-before-leaving-edited-block/design.md new file mode 100644 index 0000000..fc63d90 --- /dev/null +++ b/openspec/changes/archive/2026-04-14-prompt-save-before-leaving-edited-block/design.md @@ -0,0 +1,87 @@ +## Context + +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. + +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. + +## Goals / Non-Goals + +**Goals:** +- Detect whether the active block editor has unsaved changes compared with the last saved block state. +- Intercept builder actions that would replace the active block editor. +- Offer clear resolution paths: save changes and continue, discard changes and continue, or cancel and stay on the current block. +- Reuse the existing explicit save flow so saved data still comes from the same block PATCH request. + +**Non-Goals:** +- Introduce autosave for block edits. +- Warn on every page navigation or browser tab close. +- Add unsaved-change protection for form settings fields in this change. +- Change block validation, publishing, or storage behavior. + +## Decisions + +### 1. Compute dirty state from saved block data versus serialized editor draft +The builder will derive `hasUnsavedBlockChanges` from the currently selected saved block in `form.blocks` and the active editor draft state. + +Rationale: +- `selectedBlock` already reflects the last saved server state. +- Comparing serialized values avoids false positives from ephemeral draft IDs used for choice options and branch rules. +- Dirty detection stays local to the builder and does not require extra server reads. + +Alternatives considered: +- Maintain a manual dirty flag on every input change. Rejected because it is easier to desynchronize when selection resets or saved data is reloaded. +- Deep-compare raw draft objects including generated IDs. Rejected because choice and branch draft IDs are intentionally unstable. + +### 2. Gate editor-exit actions through a single pending-navigation workflow +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. + +Covered actions: +- selecting a different block +- switching from a block to form settings +- creating a new block when the UI would switch focus to that new block + +Rationale: +- Centralizing the guard keeps selection behavior consistent. +- The same workflow can support more editor-exit actions later without duplicating prompt logic. + +Alternatives considered: +- Add separate confirmation logic inside each click handler. Rejected because it spreads stateful behavior across the component and makes later maintenance harder. + +### 3. Save-before-leave reuses the existing `saveBlock` request path +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. + +Rationale: +- Preserves one server write path for block updates. +- Avoids divergent payload shapes or duplicated mutation logic. +- Matches creator expectations: “Save” in the dialog behaves exactly like “Save block”. + +Alternatives considered: +- 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. + +### 4. Use a three-action modal built on the existing dialog primitive +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. + +Rationale: +- Keeps the UI consistent with the existing deletion confirmation pattern. +- Minimizes implementation surface area. +- Supports the required third action without changing the component contract significantly. + +Alternatives considered: +- 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. + +## Risks / Trade-offs + +- 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. +- Save-then-navigate can feel slow on poor connections → Reuse the existing pending state and disable repeated dialog actions while save is in flight. +- Pending navigation could run against stale selection state after async save → Store navigation intent explicitly and clear it only after execution or cancellation. +- 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. + +## Migration Plan + +- No data migration is required. +- Ship as a client-side builder update. +- Rollback is a normal code rollback because no persisted shape changes are involved. + +## Open Questions + +- None for proposal scope. The default behavior will be to guard only actions that leave the active block editor inside the builder experience. diff --git a/openspec/changes/archive/2026-04-14-prompt-save-before-leaving-edited-block/proposal.md b/openspec/changes/archive/2026-04-14-prompt-save-before-leaving-edited-block/proposal.md new file mode 100644 index 0000000..45da50a --- /dev/null +++ b/openspec/changes/archive/2026-04-14-prompt-save-before-leaving-edited-block/proposal.md @@ -0,0 +1,24 @@ +## Why + +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. + +## What Changes + +- Detect unsaved edits in the active block editor by comparing the local block draft against the last saved block state. +- Prompt the creator before actions that would leave the active block editor with unsaved changes. +- Let the creator save changes, discard changes, or cancel the navigation so they can stay on the current block. +- Preserve the existing explicit save flow for block edits instead of introducing background autosave. + +## Capabilities + +### New Capabilities +- None. + +### Modified Capabilities +- `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. + +## Impact + +- Affected UI: `components/form-builder.tsx`, `components/form-builder-panels.tsx`, and shared confirmation dialog patterns. +- Affected behavior: block selection changes, switching from a block to form settings, and other builder actions that replace the active block editor. +- No API or database schema changes are expected. diff --git a/openspec/changes/archive/2026-04-14-prompt-save-before-leaving-edited-block/specs/conversational-form-builder/spec.md b/openspec/changes/archive/2026-04-14-prompt-save-before-leaving-edited-block/specs/conversational-form-builder/spec.md new file mode 100644 index 0000000..4dd632c --- /dev/null +++ b/openspec/changes/archive/2026-04-14-prompt-save-before-leaving-edited-block/specs/conversational-form-builder/spec.md @@ -0,0 +1,28 @@ +## MODIFIED Requirements + +### Requirement: Builder exposes block editing through a master-detail layout +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. + +#### Scenario: Creator selects a block with no unsaved edits +- **WHEN** an authenticated creator selects a block in the block list and the current block editor has no unsaved edits +- **THEN** the system displays that block's editable settings in the editor panel + +#### Scenario: Creator saves changes while switching blocks +- **WHEN** an authenticated creator selects a different block while the current block editor has unsaved edits and chooses to save changes +- **THEN** the system saves the current block changes and opens the newly selected block in the editor panel + +#### Scenario: Creator discards changes while switching blocks +- **WHEN** an authenticated creator selects a different block while the current block editor has unsaved edits and chooses to discard changes +- **THEN** the system leaves the saved block unchanged and opens the newly selected block in the editor panel + +#### Scenario: Creator cancels block switching +- **WHEN** an authenticated creator selects a different block while the current block editor has unsaved edits and chooses to cancel +- **THEN** the system keeps the current block editor open with the unsaved edits intact + +#### Scenario: Creator opens form settings with unsaved block edits +- **WHEN** an authenticated creator switches from a block editor to the form settings panel while the current block has unsaved edits +- **THEN** the system requires the creator to save changes, discard changes, or cancel before replacing the block editor panel + +#### Scenario: Creator works on a long form +- **WHEN** an authenticated creator opens a form with many blocks +- **THEN** the builder keeps the block list navigable without requiring the full page height to expand indefinitely with the left column diff --git a/openspec/changes/archive/2026-04-14-prompt-save-before-leaving-edited-block/tasks.md b/openspec/changes/archive/2026-04-14-prompt-save-before-leaving-edited-block/tasks.md new file mode 100644 index 0000000..c8ffd17 --- /dev/null +++ b/openspec/changes/archive/2026-04-14-prompt-save-before-leaving-edited-block/tasks.md @@ -0,0 +1,18 @@ +## 1. Dirty-state detection + +- [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. +- [x] 1.2 Derive `hasUnsavedBlockChanges` in `components/form-builder.tsx` by comparing the normalized active draft against the currently selected saved block. +- [x] 1.3 Add unit coverage for the normalization and dirty-state comparison logic, including choice and branching edits. + +## 2. Guard editor-exit actions + +- [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. +- [x] 2.2 Add an unsaved-changes dialog with cancel, discard, and save actions using the existing dialog primitive and builder-specific copy. +- [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. +- [x] 2.4 Update discard and cancel handling so discard proceeds with navigation without saving and cancel keeps the current block draft intact. + +## 3. UX polish and verification + +- [x] 3.1 Add any required translation strings for the unsaved-changes dialog and button labels. +- [x] 3.2 Add component or interaction coverage for switching blocks and opening form settings with and without unsaved edits. +- [x] 3.3 Manually verify the builder flows for save, discard, cancel, and save-failure behavior before closing the change. diff --git a/openspec/specs/conversational-form-builder/spec.md b/openspec/specs/conversational-form-builder/spec.md index 8c8d48f..a305330 100644 --- a/openspec/specs/conversational-form-builder/spec.md +++ b/openspec/specs/conversational-form-builder/spec.md @@ -31,12 +31,28 @@ The system SHALL allow an authenticated creator to add, select, edit, reorder, a - **THEN** the system removes that block from the current form structure ### Requirement: Builder exposes block editing through a master-detail layout -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. +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. -#### Scenario: Creator selects a block -- **WHEN** an authenticated creator selects a block in the block list +#### Scenario: Creator selects a block with no unsaved edits +- **WHEN** an authenticated creator selects a block in the block list and the current block editor has no unsaved edits - **THEN** the system displays that block's editable settings in the editor panel +#### Scenario: Creator saves changes while switching blocks +- **WHEN** an authenticated creator selects a different block while the current block editor has unsaved edits and chooses to save changes +- **THEN** the system saves the current block changes and opens the newly selected block in the editor panel + +#### Scenario: Creator discards changes while switching blocks +- **WHEN** an authenticated creator selects a different block while the current block editor has unsaved edits and chooses to discard changes +- **THEN** the system leaves the saved block unchanged and opens the newly selected block in the editor panel + +#### Scenario: Creator cancels block switching +- **WHEN** an authenticated creator selects a different block while the current block editor has unsaved edits and chooses to cancel +- **THEN** the system keeps the current block editor open with the unsaved edits intact + +#### Scenario: Creator opens form settings with unsaved block edits +- **WHEN** an authenticated creator switches from a block editor to the form settings panel while the current block has unsaved edits +- **THEN** the system requires the creator to save changes, discard changes, or cancel before replacing the block editor panel + #### Scenario: Creator works on a long form - **WHEN** an authenticated creator opens a form with many blocks - **THEN** the builder keeps the block list navigable without requiring the full page height to expand indefinitely with the left column