From 0bc5c80c18f7413206d5875bdc67f8cc02de4740 Mon Sep 17 00:00:00 2001 From: Philipp Reinking Date: Tue, 13 Oct 2026 01:27:39 +0200 Subject: [PATCH 01/11] fix: ask in the app's dialog before deleting a question, group, choice or rule The dialog names the question, its answer count and the questions whose rules stop working. A choice nobody picked is deleted at once. Co-Authored-By: Claude Opus 5.5 --- .../Main/Interactions/ClickInteraction.vue | 41 ++- .../Factory/Shared/DeleteBlockDialog.vue | 100 ++++++ .../Factory/Shared/DeleteChoiceDialog.vue | 37 ++ .../Factory/Shared/DeleteDialog.vue | 45 +++ .../Factory/Shared/DeleteDialogs.test.ts | 339 ++++++++++++++++++ .../Factory/Shared/useDeleteBlock.ts | 71 ++++ .../js/components/Factory/Sidebar/Block.vue | 22 +- .../Factory/Sidebar/BlockLogicRule.vue | 29 +- .../js/components/Factory/Sidebar/Group.vue | 24 +- resources/js/types/models.d.ts | 10 + 10 files changed, 675 insertions(+), 43 deletions(-) create mode 100644 resources/js/components/Factory/Shared/DeleteBlockDialog.vue create mode 100644 resources/js/components/Factory/Shared/DeleteChoiceDialog.vue create mode 100644 resources/js/components/Factory/Shared/DeleteDialog.vue create mode 100644 resources/js/components/Factory/Shared/DeleteDialogs.test.ts create mode 100644 resources/js/components/Factory/Shared/useDeleteBlock.ts diff --git a/resources/js/components/Factory/Main/Interactions/ClickInteraction.vue b/resources/js/components/Factory/Main/Interactions/ClickInteraction.vue index ba678267..f71e63d6 100644 --- a/resources/js/components/Factory/Main/Interactions/ClickInteraction.vue +++ b/resources/js/components/Factory/Main/Interactions/ClickInteraction.vue @@ -37,7 +37,13 @@ - + + @@ -45,6 +51,8 @@ import { D9Input } from "@deck9/ui"; import InteractionHoverActions from "@/components/Factory/Main/InteractionHoverActions.vue"; import IndexItem from "@/components/Factory/Shared/IndexItem.vue"; +import DeleteChoiceDialog from "@/components/Factory/Shared/DeleteChoiceDialog.vue"; +import { checkDelete } from "@/components/Factory/Shared/useDeleteBlock"; import { useWorkbench } from "@/stores"; import { Ref, ref, watch } from "vue"; @@ -97,8 +105,7 @@ const keyboardCommands = async (event: KeyboardEvent) => { case "Backspace": if (event.metaKey) { - emit("onDelete", props.index); - await workbench.deleteInteraction(props.item); + await askDelete(); } break; @@ -110,6 +117,34 @@ const keyboardCommands = async (event: KeyboardEvent) => { } }; +// open while the owner decides +const asking = ref(null); +const answers = ref(); +let isChecking = false; + +const deleteNow = async () => { + asking.value = null; + emit("onDelete", props.index); + await workbench.deleteInteraction(props.item); +}; + +// a choice nobody picked goes at once, else the owner confirms first +const askDelete = async () => { + if (isChecking || !workbench.block) { + return; + } + + isChecking = true; + answers.value = (await checkDelete(workbench.block)).choices[props.item.id]; + isChecking = false; + + if (answers.value === 0) { + return deleteNow(); + } + + asking.value = props.item; +}; + const focus = () => { labelElement.value?.focus(); }; diff --git a/resources/js/components/Factory/Shared/DeleteBlockDialog.vue b/resources/js/components/Factory/Shared/DeleteBlockDialog.vue new file mode 100644 index 00000000..501ad030 --- /dev/null +++ b/resources/js/components/Factory/Shared/DeleteBlockDialog.vue @@ -0,0 +1,100 @@ + + + diff --git a/resources/js/components/Factory/Shared/DeleteChoiceDialog.vue b/resources/js/components/Factory/Shared/DeleteChoiceDialog.vue new file mode 100644 index 00000000..51012df9 --- /dev/null +++ b/resources/js/components/Factory/Shared/DeleteChoiceDialog.vue @@ -0,0 +1,37 @@ + + + diff --git a/resources/js/components/Factory/Shared/DeleteDialog.vue b/resources/js/components/Factory/Shared/DeleteDialog.vue new file mode 100644 index 00000000..b14c7783 --- /dev/null +++ b/resources/js/components/Factory/Shared/DeleteDialog.vue @@ -0,0 +1,45 @@ + + + diff --git a/resources/js/components/Factory/Shared/DeleteDialogs.test.ts b/resources/js/components/Factory/Shared/DeleteDialogs.test.ts new file mode 100644 index 00000000..9a388de9 --- /dev/null +++ b/resources/js/components/Factory/Shared/DeleteDialogs.test.ts @@ -0,0 +1,339 @@ +import { flushPromises, mount } from "@vue/test-utils"; +import { AxiosResponse } from "axios"; +import { createPinia, setActivePinia } from "pinia"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { defineComponent } from "vue"; +import DeleteBlockDialog from "./DeleteBlockDialog.vue"; +import DeleteChoiceDialog from "./DeleteChoiceDialog.vue"; +import { checkDelete, useDeleteBlock } from "./useDeleteBlock"; +import ClickInteraction from "@/components/Factory/Main/Interactions/ClickInteraction.vue"; +import BlockLogicRule from "@/components/Factory/Sidebar/BlockLogicRule.vue"; +import { callGetFormBlocks } from "@/api/blocks"; +import { useForm, useLogic, useWorkbench } from "@/stores"; + +vi.mock("@/api/blocks", async (importOriginal) => ({ + ...(await importOriginal()), + callGetFormBlocks: vi.fn(), +})); + +const use = (id: number, block: string): FormBlockRuleUse => ({ + id, + name: `Rule ${id}`, + block, + as: ["source"], +}); + +// the meetup form as the server sends it with includeSubmissions=true +const blocks = (): FormBlockModel[] => + [ + { + id: 1, + uuid: "q1", + type: "radio", + message: "

Will you come to the meetup?

", + session_count: 40, + used_by: [use(1, "q1"), use(2, "q3"), use(6, "g")], + }, + { + id: 2, + uuid: "g", + type: "group", + title: "Food and drinks", + used_by: [{ ...use(3, "q1"), as: ["target"] }], + }, + { + id: 3, + uuid: "q2", + type: "input-short", + parent_block: "g", + message: "

Any allergies?

", + session_count: 0, + used_by: [use(4, "q3"), use(5, "q2")], + }, + { + id: 4, + uuid: "q3", + type: "radio", + message: "", + session_count: 12, + used_by: [], + interactions: [ + { id: 10, label: "No", responses_count: 12 }, + { id: 11, label: "Yes", responses_count: 0 }, + ], + }, + ] as unknown as FormBlockModel[]; + +const block = (uuid: string) => + useForm().blocks!.find((item) => item.uuid === uuid)!; + +const stubs = { teleport: true }; + +describe("asking before a delete", () => { + let pinia: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + // real actions, not the stubs of the global testing pinia + pinia = createPinia(); + setActivePinia(pinia); + useForm().form = { uuid: "form1" } as FormModel; + useForm().blocks = blocks(); + vi.mocked(callGetFormBlocks).mockResolvedValue({ + data: blocks(), + } as AxiosResponse); + }); + + describe("checkDelete", () => { + it("counts a question's answers and the rules on other questions that use it", async () => { + const check = await checkDelete(block("q1")); + + expect(callGetFormBlocks).toHaveBeenCalledWith("form1", true); + expect(check.answers).toBe(40); + expect(check.usedBy.map((rule) => rule.id)).toEqual([2, 6]); + }); + + it("counts a group's questions and the rules outside it that use the group or its questions", async () => { + const check = await checkDelete(block("g")); + + expect(check.questions).toBe(1); + expect(check.usedBy.map((rule) => rule.id)).toEqual([3, 4]); + }); + + it("counts the answers per choice", async () => { + const check = await checkDelete(block("q3")); + + expect(check.choices).toEqual({ 10: 12, 11: 0 }); + }); + + it("still names the rules when the counts can't load", async () => { + vi.mocked(callGetFormBlocks).mockRejectedValue( + new Error("offline"), + ); + // the editor loads blocks without counts + delete block("q1").session_count; + + const check = await checkDelete(block("q1")); + + expect(check.answers).toBeUndefined(); + expect(check.usedBy.map((rule) => rule.id)).toEqual([2, 6]); + }); + }); + + describe("DeleteBlockDialog", () => { + const open = async (uuid: string) => + mount(DeleteBlockDialog, { + props: { + block: block(uuid), + check: await checkDelete(block(uuid)), + }, + global: { plugins: [pinia], stubs }, + }); + + it("names the question, its answers and the questions whose rules stop working", async () => { + const wrapper = await open("q1"); + + expect(wrapper.find("[role=alertdialog]").text()).toContain( + "Delete question?", + ); + expect(wrapper.text()).toContain( + "“Will you come to the meetup?” and its 40 answers will be deleted. This can't be undone.", + ); + expect(wrapper.text()).toContain( + "2 rules use this question and will stop working: question 3, group “Food and drinks”.", + ); + expect(wrapper.find("button.bg-red-600").text()).toBe( + "Delete question", + ); + }); + + it("leaves out answers a question doesn't have", async () => { + const wrapper = await open("q2"); + + expect(wrapper.text()).toContain( + "“Any allergies?” will be deleted. This can't be undone.", + ); + expect(wrapper.text()).toContain( + "1 rule uses this question and will stop working: question 3.", + ); + }); + + it("leaves out rules when none use the question", () => { + const wrapper = mount(DeleteBlockDialog, { + props: { + block: block("q1"), + check: { + answers: 1, + questions: 0, + choices: {}, + usedBy: [], + }, + }, + global: { plugins: [pinia], stubs }, + }); + + expect(wrapper.text()).toContain( + "“Will you come to the meetup?” and its 1 answer will be deleted.", + ); + expect(wrapper.text()).not.toContain("rule"); + }); + + it("names a group and how many questions go with it", async () => { + const wrapper = await open("g"); + + expect(wrapper.text()).toContain("Delete group “Food and drinks”?"); + expect(wrapper.text()).toContain( + "Its 1 question and its answers will be deleted too. This can't be undone.", + ); + expect(wrapper.text()).toContain( + "2 rules use this group and will stop working: question 1, question 3.", + ); + expect(wrapper.find("button.bg-red-600").text()).toBe( + "Delete group", + ); + }); + + it("emits confirm and close from its buttons", async () => { + const wrapper = await open("q1"); + + await wrapper.find("button.bg-red-600").trigger("click"); + await wrapper.findAll("button")[0].trigger("click"); + + expect(wrapper.emitted("confirm")).toHaveLength(1); + expect(wrapper.emitted("close")).toHaveLength(1); + }); + }); + + describe("useDeleteBlock", () => { + it("deletes only after the owner confirms, and clears the workbench", async () => { + const form = useForm(); + const workbench = useWorkbench(); + const remove = vi + .spyOn(form, "deleteFormBlock") + .mockResolvedValue(); + const clear = vi.spyOn(workbench, "clearWorkbench"); + workbench.block = block("q1"); + + const deletion = mount( + defineComponent({ + setup: () => useDeleteBlock(block("q1")), + template: "
", + }), + { global: { plugins: [pinia] } }, + ).vm; + + await deletion.ask(); + expect(deletion.check?.answers).toBe(40); + expect(remove).not.toHaveBeenCalled(); + + deletion.confirm(); + expect(remove).toHaveBeenCalledWith(block("q1")); + expect(clear).toHaveBeenCalled(); + expect(deletion.check).toBeNull(); + }); + }); + + describe("deleting a choice", () => { + const choice = (id: number, index: number) => { + const workbench = useWorkbench(); + workbench.block = block("q3"); + const remove = vi + .spyOn(workbench, "deleteInteraction") + .mockResolvedValue(); + const wrapper = mount(ClickInteraction, { + props: { + index, + item: block("q3").interactions!.find( + (item) => item.id === id, + )!, + }, + global: { plugins: [pinia], stubs }, + }); + + return { wrapper, remove }; + }; + + const pressCmdBackspace = (wrapper: ReturnType) => + wrapper + .find("input[name=label]") + .trigger("keydown", { key: "Backspace", metaKey: true }); + + it("deletes a choice nobody picked at once", async () => { + const { wrapper, remove } = choice(11, 1); + + await pressCmdBackspace(wrapper); + await flushPromises(); + + expect(remove).toHaveBeenCalledWith( + expect.objectContaining({ id: 11 }), + ); + expect(wrapper.find("[role=alertdialog]").exists()).toBe(false); + }); + + it("asks first when people picked the choice", async () => { + const { wrapper, remove } = choice(10, 0); + + await pressCmdBackspace(wrapper); + await flushPromises(); + + expect(remove).not.toHaveBeenCalled(); + expect(wrapper.text()).toContain("Delete “No”?"); + expect(wrapper.text()).toContain( + "12 people picked it. Their answers to it will be deleted.", + ); + + await wrapper.find("button.bg-red-600").trigger("click"); + await flushPromises(); + + expect(remove).toHaveBeenCalledWith( + expect.objectContaining({ id: 10 }), + ); + }); + + it("says 1 person for a single answer", () => { + const wrapper = mount(DeleteChoiceDialog, { + props: { choice: { id: 10, label: "No" }, answers: 1 }, + global: { plugins: [pinia], stubs }, + }); + + expect(wrapper.text()).toContain("1 person picked it."); + expect(wrapper.find("button.bg-red-600").text()).toBe( + "Delete choice", + ); + }); + }); + + describe("deleting a rule", () => { + it("asks in the app's dialog, not the browser's", async () => { + const confirm = vi.spyOn(window, "confirm"); + const remove = vi + .spyOn(useLogic(), "removeRule") + .mockResolvedValue(); + const wrapper = mount(BlockLogicRule, { + props: { + index: 2, + rule: { + name: "Skip", + evaluate: "after", + action: "goto", + action_payload: "q1", + conditions: [], + } as unknown as FormBlockLogic, + }, + global: { plugins: [pinia], stubs }, + }); + + await wrapper + .find("button[aria-label='Delete rule']") + .trigger("click"); + + expect(confirm).not.toHaveBeenCalled(); + expect(wrapper.text()).toContain("Delete rule “Skip”?"); + expect(remove).not.toHaveBeenCalled(); + + await wrapper.find("button.bg-red-600").trigger("click"); + + expect(remove).toHaveBeenCalledWith(2); + }); + }); +}); diff --git a/resources/js/components/Factory/Shared/useDeleteBlock.ts b/resources/js/components/Factory/Shared/useDeleteBlock.ts new file mode 100644 index 00000000..9fd6fffc --- /dev/null +++ b/resources/js/components/Factory/Shared/useDeleteBlock.ts @@ -0,0 +1,71 @@ +import { ref } from "vue"; +import { callGetFormBlocks } from "@/api/blocks"; +import { useForm, useWorkbench } from "@/stores"; + +export interface DeleteCheck { + // answers to the block, missing when the server couldn't say + answers?: number; + // questions that go with a group + questions: number; + // answers per choice id + choices: Record; + // rules on other blocks that stop working + usedBy: FormBlockRuleUse[]; +} + +// What a delete takes with it, asked right before: answer counts need +// includeSubmissions, and the editor's used_by can be older than its rules. +export async function checkDelete(block: FormBlockModel): Promise { + const store = useForm(); + let blocks = store.blocks ?? []; + + try { + blocks = (await callGetFormBlocks(store.form!.uuid, true)).data; + } catch { + // ask anyway, with what the editor has + } + + const gone = blocks.filter( + (item) => item.uuid === block.uuid || item.parent_block === block.uuid, + ); + const goneUuids = gone.map((item) => item.uuid); + const fresh = gone.find((item) => item.uuid === block.uuid); + + return { + answers: fresh?.session_count, + questions: gone.length - 1, + choices: Object.fromEntries( + (fresh?.interactions ?? []) + .filter((item) => item.responses_count !== undefined) + .map((item) => [item.id, item.responses_count]), + ), + usedBy: gone + .flatMap((item) => item.used_by ?? []) + .filter((rule) => !goneUuids.includes(rule.block)), + }; +} + +// asks before a question or group is deleted +export function useDeleteBlock(block: FormBlockModel) { + const check = ref(null); + + const ask = async () => { + check.value = await checkDelete(block); + }; + + const cancel = () => { + check.value = null; + }; + + const confirm = () => { + const workbench = useWorkbench(); + check.value = null; + useForm().deleteFormBlock(block); + + if (workbench.block?.id === block.id) { + workbench.clearWorkbench(); + } + }; + + return { check, ask, cancel, confirm }; +} diff --git a/resources/js/components/Factory/Sidebar/Block.vue b/resources/js/components/Factory/Sidebar/Block.vue index d9be24dd..b5f97014 100644 --- a/resources/js/components/Factory/Sidebar/Block.vue +++ b/resources/js/components/Factory/Sidebar/Block.vue @@ -30,7 +30,7 @@ as="button" class="block w-full text-left" label="Delete" - @click.stop="deleteBlock" + @click.stop="ask" />
@@ -62,6 +62,12 @@ + + @@ -73,6 +79,8 @@ import BlockInteraction from "./BlockInteraction.vue"; import BlockFooter from "./BlockFooter.vue"; import BlockLogicVisualizer from "./BlockLogicVisualizer.vue"; import InsertAfterButton from "./InsertAfterButton.vue"; +import DeleteBlockDialog from "@/components/Factory/Shared/DeleteBlockDialog.vue"; +import { useDeleteBlock } from "@/components/Factory/Shared/useDeleteBlock"; import { useWorkbench, useForm } from "@/stores"; import { D9Menu, D9MenuLink } from "@deck9/ui"; import copy from "copy-text-to-clipboard"; @@ -101,17 +109,7 @@ const isActive = computed((): boolean => { const { cardStyle } = useActiveCard(isActive); -const deleteBlock = () => { - const result = window.confirm("Do you really want to delete this block?"); - - if (result) { - store.deleteFormBlock(props.block); - - if (workbench.block?.id === props.block.id) { - workbench.clearWorkbench(); - } - } -}; +const { check, ask, cancel, confirm } = useDeleteBlock(props.block); const copyId = () => { copy(props.block.uuid); diff --git a/resources/js/components/Factory/Sidebar/BlockLogicRule.vue b/resources/js/components/Factory/Sidebar/BlockLogicRule.vue index dbc94627..9d3f0061 100644 --- a/resources/js/components/Factory/Sidebar/BlockLogicRule.vue +++ b/resources/js/components/Factory/Sidebar/BlockLogicRule.vue @@ -6,15 +6,20 @@ {{ rule.name }} - + +
@@ -61,12 +61,20 @@
+ + diff --git a/resources/js/components/Factory/Shared/DeleteDialogs.test.ts b/resources/js/components/Factory/Shared/DeleteDialogs.test.ts index 9a388de9..f4481633 100644 --- a/resources/js/components/Factory/Shared/DeleteDialogs.test.ts +++ b/resources/js/components/Factory/Shared/DeleteDialogs.test.ts @@ -3,6 +3,7 @@ import { AxiosResponse } from "axios"; import { createPinia, setActivePinia } from "pinia"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { defineComponent } from "vue"; +import DeleteDialog from "./DeleteDialog.vue"; import DeleteBlockDialog from "./DeleteBlockDialog.vue"; import DeleteChoiceDialog from "./DeleteChoiceDialog.vue"; import { checkDelete, useDeleteBlock } from "./useDeleteBlock"; @@ -303,6 +304,31 @@ describe("asking before a delete", () => { }); }); + describe("DeleteDialog", () => { + it("closes only itself on Escape, not the logic panel under it", () => { + const panel = vi.fn(); + window.addEventListener("keydown", panel); + const wrapper = mount(DeleteDialog, { + props: { + show: true, + title: "Delete?", + text: "", + confirmLabel: "Delete", + }, + global: { stubs }, + }); + + document.body.dispatchEvent( + new KeyboardEvent("keydown", { key: "Escape", bubbles: true }), + ); + + expect(wrapper.emitted("close")).toHaveLength(1); + expect(panel).not.toHaveBeenCalled(); + window.removeEventListener("keydown", panel); + wrapper.unmount(); + }); + }); + describe("deleting a rule", () => { it("asks in the app's dialog, not the browser's", async () => { const confirm = vi.spyOn(window, "confirm"); From 2fcfe3b47a27204c992ea06a3858ffc83a203f77 Mon Sep 17 00:00:00 2001 From: Philipp Reinking Date: Tue, 13 Oct 2026 01:33:46 +0200 Subject: [PATCH 03/11] docs: add changelog line for #269 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6b22e50..d42a033b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Lines marked **Upgrade step** need you to act after updating. - Archiving a form now also unpublishes it, so Restore brings it back unpublished. This also goes for forms you archived before this update. (#247) - The form list loads faster when a team has many forms. It now needs the same few database queries for one form or a hundred. Before, every form added 3 queries. (#256) - On screens narrower than 1280 px, a form's editor bar now sits in two rows under the app header: the form with Dashboard and Preview, then Editor, Settings, Integrations and Submissions as tabs. Nothing in it scrolls sideways any more, and the header keeps the logo and your team name. On every screen, keyboard focus in the editor bar and the account menu now shows a blue ring. (#257) +- Deleting a question, a group, an answer choice or a logic rule in the form editor now asks first in the app's own dialog. It names the question, how many answers will be deleted with it, and the questions whose rules will stop working. A choice nobody picked is still deleted at once. (#269) ### Fixed From 5117e88ef51497196ee7a9d27d629b091e7455f2 Mon Sep 17 00:00:00 2001 From: Philipp Reinking Date: Tue, 13 Oct 2026 01:53:18 +0200 Subject: [PATCH 04/11] fix: make the delete dialog buttons 44 px tall on phones Co-Authored-By: Claude Opus 5.5 --- resources/js/components/Factory/Shared/DeleteDialog.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/js/components/Factory/Shared/DeleteDialog.vue b/resources/js/components/Factory/Shared/DeleteDialog.vue index caf5f074..0249a2ed 100644 --- a/resources/js/components/Factory/Shared/DeleteDialog.vue +++ b/resources/js/components/Factory/Shared/DeleteDialog.vue @@ -11,12 +11,12 @@ {{ confirmLabel }} From 8dc5982b16303ee92a9c2d3f06531f23748e8e2e Mon Sep 17 00:00:00 2001 From: Philipp Reinking Date: Tue, 13 Oct 2026 01:53:33 +0200 Subject: [PATCH 05/11] fix: say a question's answers go too when they can't be counted Co-Authored-By: Claude Opus 5.5 --- .../Factory/Shared/DeleteBlockDialog.vue | 10 +++++++--- .../Factory/Shared/DeleteDialogs.test.ts | 14 ++++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/resources/js/components/Factory/Shared/DeleteBlockDialog.vue b/resources/js/components/Factory/Shared/DeleteBlockDialog.vue index 501ad030..309a671c 100644 --- a/resources/js/components/Factory/Shared/DeleteBlockDialog.vue +++ b/resources/js/components/Factory/Shared/DeleteBlockDialog.vue @@ -76,9 +76,13 @@ const text = computed(() => { ? `“${short}”` : `Question ${numberOf(props.block.uuid) || ""}`.trim(); const answers = props.check?.answers; - const withAnswers = answers - ? ` and its ${answers} ${answers === 1 ? "answer" : "answers"}` - : ""; + // unknown when the counts couldn't load: there may still be answers + const withAnswers = + answers === undefined + ? " and its answers" + : answers + ? ` and its ${answers} ${answers === 1 ? "answer" : "answers"}` + : ""; return `${name}${withAnswers} will be deleted. This can't be undone.`; }); diff --git a/resources/js/components/Factory/Shared/DeleteDialogs.test.ts b/resources/js/components/Factory/Shared/DeleteDialogs.test.ts index f4481633..19431480 100644 --- a/resources/js/components/Factory/Shared/DeleteDialogs.test.ts +++ b/resources/js/components/Factory/Shared/DeleteDialogs.test.ts @@ -179,6 +179,20 @@ describe("asking before a delete", () => { expect(wrapper.text()).not.toContain("rule"); }); + it("says the answers go too when it can't count them", () => { + const wrapper = mount(DeleteBlockDialog, { + props: { + block: block("q1"), + check: { questions: 0, choices: {}, usedBy: [] }, + }, + global: { plugins: [pinia], stubs }, + }); + + expect(wrapper.text()).toContain( + "“Will you come to the meetup?” and its answers will be deleted. This can't be undone.", + ); + }); + it("names a group and how many questions go with it", async () => { const wrapper = await open("g"); From 37f7619eb9b8b43090d72f4b2025c4956a8314ad Mon Sep 17 00:00:00 2001 From: Philipp Reinking Date: Tue, 13 Oct 2026 01:54:05 +0200 Subject: [PATCH 06/11] refactor: move the ask-before-deleting-a-choice steps into useDeleteChoice Co-Authored-By: Claude Opus 5.5 --- .../Main/Interactions/ClickInteraction.vue | 35 ++++-------------- .../Factory/Shared/useDeleteBlock.ts | 36 +++++++++++++++++++ 2 files changed, 43 insertions(+), 28 deletions(-) diff --git a/resources/js/components/Factory/Main/Interactions/ClickInteraction.vue b/resources/js/components/Factory/Main/Interactions/ClickInteraction.vue index f71e63d6..13bc9d20 100644 --- a/resources/js/components/Factory/Main/Interactions/ClickInteraction.vue +++ b/resources/js/components/Factory/Main/Interactions/ClickInteraction.vue @@ -52,7 +52,7 @@ import { D9Input } from "@deck9/ui"; import InteractionHoverActions from "@/components/Factory/Main/InteractionHoverActions.vue"; import IndexItem from "@/components/Factory/Shared/IndexItem.vue"; import DeleteChoiceDialog from "@/components/Factory/Shared/DeleteChoiceDialog.vue"; -import { checkDelete } from "@/components/Factory/Shared/useDeleteBlock"; +import { useDeleteChoice } from "@/components/Factory/Shared/useDeleteBlock"; import { useWorkbench } from "@/stores"; import { Ref, ref, watch } from "vue"; @@ -117,33 +117,12 @@ const keyboardCommands = async (event: KeyboardEvent) => { } }; -// open while the owner decides -const asking = ref(null); -const answers = ref(); -let isChecking = false; - -const deleteNow = async () => { - asking.value = null; - emit("onDelete", props.index); - await workbench.deleteInteraction(props.item); -}; - -// a choice nobody picked goes at once, else the owner confirms first -const askDelete = async () => { - if (isChecking || !workbench.block) { - return; - } - - isChecking = true; - answers.value = (await checkDelete(workbench.block)).choices[props.item.id]; - isChecking = false; - - if (answers.value === 0) { - return deleteNow(); - } - - asking.value = props.item; -}; +const { + asking, + answers, + ask: askDelete, + confirm: deleteNow, +} = useDeleteChoice(props.item, () => emit("onDelete", props.index)); const focus = () => { labelElement.value?.focus(); diff --git a/resources/js/components/Factory/Shared/useDeleteBlock.ts b/resources/js/components/Factory/Shared/useDeleteBlock.ts index 9fd6fffc..6ecc276a 100644 --- a/resources/js/components/Factory/Shared/useDeleteBlock.ts +++ b/resources/js/components/Factory/Shared/useDeleteBlock.ts @@ -69,3 +69,39 @@ export function useDeleteBlock(block: FormBlockModel) { return { check, ask, cancel, confirm }; } + +// asks before a choice is deleted; one nobody picked goes at once +export function useDeleteChoice( + item: FormBlockInteractionModel, + onDelete?: () => void, +) { + const workbench = useWorkbench(); + // open while the owner decides + const asking = ref(null); + const answers = ref(); + let isChecking = false; + + const confirm = async () => { + asking.value = null; + onDelete?.(); + await workbench.deleteInteraction(item); + }; + + const ask = async () => { + if (isChecking || !workbench.block) { + return; + } + + isChecking = true; + answers.value = (await checkDelete(workbench.block)).choices[item.id]; + isChecking = false; + + if (answers.value === 0) { + return confirm(); + } + + asking.value = item; + }; + + return { asking, answers, ask, confirm }; +} From 324ca4445859f1fd51c7f63f9567e1b380edf2f5 Mon Sep 17 00:00:00 2001 From: Philipp Reinking Date: Tue, 13 Oct 2026 01:54:24 +0200 Subject: [PATCH 07/11] fix: ask before deleting a consent policy people agreed to Co-Authored-By: Claude Opus 5.5 --- .../Main/Interactions/ConsentInteraction.vue | 17 ++++- .../Factory/Shared/DeleteDialogs.test.ts | 72 +++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/resources/js/components/Factory/Main/Interactions/ConsentInteraction.vue b/resources/js/components/Factory/Main/Interactions/ConsentInteraction.vue index fd8151cb..6caf6f93 100644 --- a/resources/js/components/Factory/Main/Interactions/ConsentInteraction.vue +++ b/resources/js/components/Factory/Main/Interactions/ConsentInteraction.vue @@ -45,7 +45,13 @@ - + + @@ -53,6 +59,8 @@ import { D9Input, D9Textarea, D9Switch } from "@deck9/ui"; import InteractionHoverActions from "@/components/Factory/Main/InteractionHoverActions.vue"; import IndexItem from "@/components/Factory/Shared/IndexItem.vue"; +import DeleteChoiceDialog from "@/components/Factory/Shared/DeleteChoiceDialog.vue"; +import { useDeleteChoice } from "@/components/Factory/Shared/useDeleteBlock"; import { useWorkbench } from "@/stores"; import { Ref, ref, watch } from "vue"; @@ -88,6 +96,13 @@ watch([label, message, isRequired], (newValues) => { workbench.updateInteraction(update); }); +const { + asking, + answers, + ask: askDelete, + confirm: deleteNow, +} = useDeleteChoice(props.item); + const focus = () => { labelElement.value.focus(); }; diff --git a/resources/js/components/Factory/Shared/DeleteDialogs.test.ts b/resources/js/components/Factory/Shared/DeleteDialogs.test.ts index 19431480..138bcca7 100644 --- a/resources/js/components/Factory/Shared/DeleteDialogs.test.ts +++ b/resources/js/components/Factory/Shared/DeleteDialogs.test.ts @@ -8,6 +8,8 @@ import DeleteBlockDialog from "./DeleteBlockDialog.vue"; import DeleteChoiceDialog from "./DeleteChoiceDialog.vue"; import { checkDelete, useDeleteBlock } from "./useDeleteBlock"; import ClickInteraction from "@/components/Factory/Main/Interactions/ClickInteraction.vue"; +import ConsentInteraction from "@/components/Factory/Main/Interactions/ConsentInteraction.vue"; +import InteractionHoverActions from "@/components/Factory/Main/InteractionHoverActions.vue"; import BlockLogicRule from "@/components/Factory/Sidebar/BlockLogicRule.vue"; import { callGetFormBlocks } from "@/api/blocks"; import { useForm, useLogic, useWorkbench } from "@/stores"; @@ -63,6 +65,18 @@ const blocks = (): FormBlockModel[] => { id: 11, label: "Yes", responses_count: 0 }, ], }, + { + id: 5, + uuid: "c1", + type: "consent", + message: "", + session_count: 3, + used_by: [], + interactions: [ + { id: 20, label: "Privacy policy", responses_count: 3 }, + { id: 21, label: "Newsletter", responses_count: 0 }, + ], + }, ] as unknown as FormBlockModel[]; const block = (uuid: string) => @@ -318,6 +332,64 @@ describe("asking before a delete", () => { }); }); + describe("deleting a consent policy", () => { + const policy = (id: number) => { + const workbench = useWorkbench(); + workbench.block = block("c1"); + const remove = vi + .spyOn(workbench, "deleteInteraction") + .mockResolvedValue(); + const wrapper = mount(ConsentInteraction, { + props: { + index: 0, + item: block("c1").interactions!.find( + (item) => item.id === id, + )!, + }, + global: { plugins: [pinia], stubs }, + }); + const pressTrash = async () => { + await wrapper + .findComponent(InteractionHoverActions) + .find("button") + .trigger("click"); + await flushPromises(); + }; + + return { wrapper, remove, pressTrash }; + }; + + it("deletes a policy nobody agreed to at once", async () => { + const { wrapper, remove, pressTrash } = policy(21); + + await pressTrash(); + + expect(remove).toHaveBeenCalledWith( + expect.objectContaining({ id: 21 }), + ); + expect(wrapper.find("[role=alertdialog]").exists()).toBe(false); + }); + + it("asks first when people agreed to the policy", async () => { + const { wrapper, remove, pressTrash } = policy(20); + + await pressTrash(); + + expect(remove).not.toHaveBeenCalled(); + expect(wrapper.text()).toContain("Delete “Privacy policy”?"); + expect(wrapper.text()).toContain( + "3 people picked it. Their answers to it will be deleted.", + ); + + await wrapper.find("button.bg-red-600").trigger("click"); + await flushPromises(); + + expect(remove).toHaveBeenCalledWith( + expect.objectContaining({ id: 20 }), + ); + }); + }); + describe("DeleteDialog", () => { it("closes only itself on Escape, not the logic panel under it", () => { const panel = vi.fn(); From 914475d389e56cdbb002d6812821b012e5650de7 Mon Sep 17 00:00:00 2001 From: Philipp Reinking Date: Tue, 13 Oct 2026 01:54:24 +0200 Subject: [PATCH 08/11] docs: add consent policies to the changelog line for #269 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d42a033b..384d8a4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ Lines marked **Upgrade step** need you to act after updating. - Archiving a form now also unpublishes it, so Restore brings it back unpublished. This also goes for forms you archived before this update. (#247) - The form list loads faster when a team has many forms. It now needs the same few database queries for one form or a hundred. Before, every form added 3 queries. (#256) - On screens narrower than 1280 px, a form's editor bar now sits in two rows under the app header: the form with Dashboard and Preview, then Editor, Settings, Integrations and Submissions as tabs. Nothing in it scrolls sideways any more, and the header keeps the logo and your team name. On every screen, keyboard focus in the editor bar and the account menu now shows a blue ring. (#257) -- Deleting a question, a group, an answer choice or a logic rule in the form editor now asks first in the app's own dialog. It names the question, how many answers will be deleted with it, and the questions whose rules will stop working. A choice nobody picked is still deleted at once. (#269) +- Deleting a question, a group, an answer choice, a consent policy or a logic rule in the form editor now asks first in the app's own dialog. It names the question, how many answers will be deleted with it, and the questions whose rules will stop working. A choice nobody picked, or a policy nobody agreed to, is still deleted at once. (#269) ### Fixed From 58c17162b7bf30c28cbef04e311255d832480b57 Mon Sep 17 00:00:00 2001 From: Philipp Reinking Date: Tue, 13 Oct 2026 02:00:54 +0200 Subject: [PATCH 09/11] fix: say "agreed to" and "Delete policy" when deleting a consent policy Co-Authored-By: Claude Opus 5.5 --- .../Main/Interactions/ConsentInteraction.vue | 1 + .../Factory/Shared/DeleteChoiceDialog.vue | 22 ++++++++++++++---- .../Factory/Shared/DeleteDialogs.test.ts | 23 +++++++++++++++++-- 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/resources/js/components/Factory/Main/Interactions/ConsentInteraction.vue b/resources/js/components/Factory/Main/Interactions/ConsentInteraction.vue index 6caf6f93..961503ab 100644 --- a/resources/js/components/Factory/Main/Interactions/ConsentInteraction.vue +++ b/resources/js/components/Factory/Main/Interactions/ConsentInteraction.vue @@ -47,6 +47,7 @@ @@ -18,6 +18,8 @@ const props = defineProps<{ choice: Pick | null; // missing when the server couldn't say answers?: number; + // a consent policy: people agree to it + policy?: boolean; }>(); const emit = defineEmits<{ @@ -25,13 +27,25 @@ const emit = defineEmits<{ (e: "confirm"): void; }>(); +const title = computed(() => { + const label = props.choice?.label; + + if (props.policy) { + return label ? `Delete policy “${label}”?` : "Delete this policy?"; + } + + return label ? `Delete “${label}”?` : "Delete this choice?"; +}); + const text = computed(() => { + const picked = props.policy ? "agreed to" : "picked"; + if (props.answers === undefined) { - return "The answers that picked it will be deleted."; + return `The answers that ${picked} it will be deleted.`; } const people = props.answers === 1 ? "1 person" : `${props.answers} people`; - return `${people} picked it. Their answers to it will be deleted.`; + return `${people} ${picked} it. Their answers to it will be deleted.`; }); diff --git a/resources/js/components/Factory/Shared/DeleteDialogs.test.ts b/resources/js/components/Factory/Shared/DeleteDialogs.test.ts index 138bcca7..d93ecad9 100644 --- a/resources/js/components/Factory/Shared/DeleteDialogs.test.ts +++ b/resources/js/components/Factory/Shared/DeleteDialogs.test.ts @@ -376,9 +376,12 @@ describe("asking before a delete", () => { await pressTrash(); expect(remove).not.toHaveBeenCalled(); - expect(wrapper.text()).toContain("Delete “Privacy policy”?"); + expect(wrapper.text()).toContain("Delete policy “Privacy policy”?"); expect(wrapper.text()).toContain( - "3 people picked it. Their answers to it will be deleted.", + "3 people agreed to it. Their answers to it will be deleted.", + ); + expect(wrapper.find("button.bg-red-600").text()).toBe( + "Delete policy", ); await wrapper.find("button.bg-red-600").trigger("click"); @@ -390,6 +393,22 @@ describe("asking before a delete", () => { }); }); + describe("DeleteChoiceDialog for a policy", () => { + it("says the answers go when it can't count them", () => { + const wrapper = mount(DeleteChoiceDialog, { + props: { + choice: { id: 20, label: "Privacy policy" }, + policy: true, + }, + global: { plugins: [pinia], stubs }, + }); + + expect(wrapper.text()).toContain( + "The answers that agreed to it will be deleted.", + ); + }); + }); + describe("DeleteDialog", () => { it("closes only itself on Escape, not the logic panel under it", () => { const panel = vi.fn(); From 854bdc97bc0e6a1dde25d7a48b39db4947f9ce34 Mon Sep 17 00:00:00 2001 From: Philipp Reinking Date: Tue, 13 Oct 2026 02:06:32 +0200 Subject: [PATCH 10/11] fix: say people, not answers, picked a choice when the count is unknown Co-Authored-By: Claude Opus 5.5 --- .../Factory/Shared/DeleteChoiceDialog.vue | 2 +- .../Factory/Shared/DeleteDialogs.test.ts | 17 ++++++++++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/resources/js/components/Factory/Shared/DeleteChoiceDialog.vue b/resources/js/components/Factory/Shared/DeleteChoiceDialog.vue index d23a66fa..16b197db 100644 --- a/resources/js/components/Factory/Shared/DeleteChoiceDialog.vue +++ b/resources/js/components/Factory/Shared/DeleteChoiceDialog.vue @@ -41,7 +41,7 @@ const text = computed(() => { const picked = props.policy ? "agreed to" : "picked"; if (props.answers === undefined) { - return `The answers that ${picked} it will be deleted.`; + return `If people ${picked} it, their answers to it will be deleted.`; } const people = props.answers === 1 ? "1 person" : `${props.answers} people`; diff --git a/resources/js/components/Factory/Shared/DeleteDialogs.test.ts b/resources/js/components/Factory/Shared/DeleteDialogs.test.ts index d93ecad9..561360c5 100644 --- a/resources/js/components/Factory/Shared/DeleteDialogs.test.ts +++ b/resources/js/components/Factory/Shared/DeleteDialogs.test.ts @@ -393,8 +393,19 @@ describe("asking before a delete", () => { }); }); - describe("DeleteChoiceDialog for a policy", () => { - it("says the answers go when it can't count them", () => { + describe("DeleteChoiceDialog when it can't count the answers", () => { + it("says a choice's answers go if people picked it", () => { + const wrapper = mount(DeleteChoiceDialog, { + props: { choice: { id: 10, label: "No" } }, + global: { plugins: [pinia], stubs }, + }); + + expect(wrapper.text()).toContain( + "If people picked it, their answers to it will be deleted.", + ); + }); + + it("says a policy's answers go if people agreed to it", () => { const wrapper = mount(DeleteChoiceDialog, { props: { choice: { id: 20, label: "Privacy policy" }, @@ -404,7 +415,7 @@ describe("asking before a delete", () => { }); expect(wrapper.text()).toContain( - "The answers that agreed to it will be deleted.", + "If people agreed to it, their answers to it will be deleted.", ); }); }); From 1f385f3e23105649850e0cfa28e4d3c3b727dbb4 Mon Sep 17 00:00:00 2001 From: Philipp Reinking Date: Tue, 13 Oct 2026 02:06:54 +0200 Subject: [PATCH 11/11] fix: keep a lone last word off its own line in the delete dialogs Co-Authored-By: Claude Opus 5.5 --- resources/js/components/Factory/Shared/DeleteDialog.vue | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/resources/js/components/Factory/Shared/DeleteDialog.vue b/resources/js/components/Factory/Shared/DeleteDialog.vue index 0249a2ed..93e872c9 100644 --- a/resources/js/components/Factory/Shared/DeleteDialog.vue +++ b/resources/js/components/Factory/Shared/DeleteDialog.vue @@ -4,8 +4,12 @@ {{ title }}