From 30d05db03a3cbf9b9358e838850bcecf7af0866e Mon Sep 17 00:00:00 2001 From: Philipp Reinking Date: Tue, 13 Oct 2026 02:54:31 +0200 Subject: [PATCH 01/12] feat: duplicate a question from its menu The copy goes right after the question and opens with the cursor in its text. A change still waiting to save in the open question is sent first, so the copy has it. Groups have no Duplicate: the server can't copy them. Co-Authored-By: Claude Opus 5.5 --- resources/js/api/blocks.ts | 7 +++ .../Factory/Document/BlockMenu.test.ts | 18 ++++++ .../components/Factory/Document/BlockMenu.vue | 9 ++- .../components/Factory/Document/blockFocus.ts | 16 +++-- resources/js/stores/form.test.ts | 63 ++++++++++++++++++- resources/js/stores/form.ts | 29 +++++++++ resources/js/stores/workbench.ts | 8 +++ 7 files changed, 142 insertions(+), 8 deletions(-) diff --git a/resources/js/api/blocks.ts b/resources/js/api/blocks.ts index 10b359c0..65029179 100644 --- a/resources/js/api/blocks.ts +++ b/resources/js/api/blocks.ts @@ -63,6 +63,13 @@ export function callUpdateFormBlock( }); } +// the copy goes right after the original; the server renumbers the form +export function callDuplicateFormBlock( + id: number +): Promise> { + return handler.post(window.route("api.blocks.duplicate", { block: id })); +} + export function callDeleteFormBlock(id: number): Promise { return new Promise(async (resolve, reject) => { try { diff --git a/resources/js/components/Factory/Document/BlockMenu.test.ts b/resources/js/components/Factory/Document/BlockMenu.test.ts index df16bedb..d22cf12e 100644 --- a/resources/js/components/Factory/Document/BlockMenu.test.ts +++ b/resources/js/components/Factory/Document/BlockMenu.test.ts @@ -8,6 +8,8 @@ const blocks = [ { id: 2, uuid: "q2", type: "radio" }, ] as FormBlockModel[]; +const group = { id: 3, uuid: "g1", type: "group" } as FormBlockModel; + let wrapper: VueWrapper | undefined; const openMenu = async (block: FormBlockModel) => { @@ -62,4 +64,20 @@ describe("a block's menu", () => { expect(item("Move up")?.getAttribute("aria-disabled")).toBeNull(); expect(item("Move down")?.getAttribute("aria-disabled")).toBe("true"); }); + + it("duplicates a question", async () => { + await openMenu(blocks[0]); + + item("Duplicate")!.click(); + await flushPromises(); + + expect(useForm().duplicateFormBlock).toHaveBeenCalledWith(blocks[0]); + }); + + it("has no Duplicate for a group, which the server can't copy", async () => { + await openMenu(group); + + expect(item("Duplicate")).toBeUndefined(); + expect(item("Add question to this group")).toBeDefined(); + }); }); diff --git a/resources/js/components/Factory/Document/BlockMenu.vue b/resources/js/components/Factory/Document/BlockMenu.vue index 230b0bfe..7c078da6 100644 --- a/resources/js/components/Factory/Document/BlockMenu.vue +++ b/resources/js/components/Factory/Document/BlockMenu.vue @@ -13,6 +13,13 @@ + (); const store = useForm(); const workbench = useWorkbench(); -const { addBlock } = useAddBlock(); +const { addBlock, duplicateBlock } = useAddBlock(); const { canMove, moveBlock } = useMoveBlock(); const item = "w-full min-h-11 text-left text-sm lg:min-h-0"; diff --git a/resources/js/components/Factory/Document/blockFocus.ts b/resources/js/components/Factory/Document/blockFocus.ts index f8dcf4c6..b7a86f77 100644 --- a/resources/js/components/Factory/Document/blockFocus.ts +++ b/resources/js/components/Factory/Document/blockFocus.ts @@ -31,16 +31,14 @@ export async function scrollToBlock( document.getElementById(`row-${uuid}`)?.scrollIntoView({ block }); } -// a new block opens with the cursor in it +// a new block or a copy opens with the cursor in it export function useAddBlock() { const store = useForm(); const workbench = useWorkbench(); - const addBlock = async ( - ...args: Parameters - ) => { + const opens = async (create: () => Promise) => { const before = workbench.block; - await store.createFormBlock(...args); + await create(); // a failed create keeps the open block: leave the cursor where it is if (workbench.block !== before) { @@ -48,5 +46,11 @@ export function useAddBlock() { } }; - return { addBlock }; + const addBlock = (...args: Parameters) => + opens(() => store.createFormBlock(...args)); + + const duplicateBlock = (block: FormBlockModel) => + opens(() => store.duplicateFormBlock(block)); + + return { addBlock, duplicateBlock }; } diff --git a/resources/js/stores/form.test.ts b/resources/js/stores/form.test.ts index 51828c2a..b804e262 100644 --- a/resources/js/stores/form.test.ts +++ b/resources/js/stores/form.test.ts @@ -1,8 +1,11 @@ +import { PiniaDebounce } from "@pinia/plugin-debounce"; import { AxiosError, AxiosResponse } from "axios"; +import debounce from "lodash/debounce"; import { createPinia, setActivePinia } from "pinia"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createApp } from "vue"; import handler from "@/api/handler"; -import { useForm } from "@/stores"; +import { useForm, useWorkbench } from "@/stores"; vi.mock("@/api/handler", () => ({ default: { get: vi.fn(), post: vi.fn() } })); @@ -122,3 +125,61 @@ describe("loading the questions", () => { expect(useForm().blocks).toBeNull(); }); }); + +describe("duplicating a question", () => { + const question = (id: number, uuid: string) => + ({ id, uuid, type: "radio", message: "

Hi

" }) as FormBlockModel; + + let posted: string[]; + + beforeEach(() => { + vi.clearAllMocks(); + // the debounce the app uses (plugins run only once pinia is in an app) + const pinia = createPinia(); + pinia.use(PiniaDebounce(debounce)); + createApp({}).use(pinia); + setActivePinia(pinia); + + window.route = vi.fn( + (name: string) => name, + ) as unknown as typeof window.route; + posted = []; + vi.mocked(handler.post).mockImplementation(async (url: string) => { + posted.push(url); + return { + status: 201, + data: url === "api.blocks.duplicate" ? question(4, "copy") : {}, + } as AxiosResponse; + }); + + useForm().form = { ...saved }; + useForm().blocks = [ + question(1, "q1"), + question(2, "q2"), + question(3, "q3"), + ]; + }); + + it("puts the copy right after the question and opens it", async () => { + await useForm().duplicateFormBlock(useForm().blocks![1]); + + expect(posted).toEqual(["api.blocks.duplicate"]); + expect(useForm().blocks!.map((block) => block.uuid)).toEqual([ + "q1", + "q2", + "copy", + "q3", + ]); + expect(useWorkbench().block?.uuid).toBe("copy"); + }); + + it("saves a change still waiting in the open question first, so the copy has it", async () => { + const workbench = useWorkbench(); + workbench.block = useForm().blocks![1]; + workbench.updateBlock({ message: "

Hi there

" }); + + await useForm().duplicateFormBlock(workbench.block); + + expect(posted).toEqual(["api.blocks.update", "api.blocks.duplicate"]); + }); +}); diff --git a/resources/js/stores/form.ts b/resources/js/stores/form.ts index 0429d4d8..f1335395 100644 --- a/resources/js/stores/form.ts +++ b/resources/js/stores/form.ts @@ -14,6 +14,7 @@ import { callCreateFormBlock, callUpdateBlockSequence, callDeleteFormBlock, + callDuplicateFormBlock, callUpdateFormBlock, } from "@/api/blocks"; import { useWorkbench } from "."; @@ -231,6 +232,34 @@ export const useForm = defineStore("form", { }); }, + // the copy goes right after the question and opens + async duplicateFormBlock(block: FormBlockModel) { + const workbench = useWorkbench(); + + // the copy must have the last change to the open question + if (workbench.block?.id === block.id) { + await workbench.sendWaitingSaves(); + } + + const request = async () => { + const copy = (await callDuplicateFormBlock(block.id)).data; + const index = + this.blocks?.findIndex((item) => item.id === block.id) ?? + -1; + + if (index !== -1) { + this.blocks?.splice(index + 1, 0, copy); + workbench.putOnWorkbench(copy); + } + }; + + await useNotice().send( + `duplicateFormBlock:${block.id}`, + request, + block, + ); + }, + async deleteFormBlock(block: FormBlockModel) { if (!this.form) { return; diff --git a/resources/js/stores/workbench.ts b/resources/js/stores/workbench.ts index adbb47ed..2d61c2a3 100644 --- a/resources/js/stores/workbench.ts +++ b/resources/js/stores/workbench.ts @@ -108,6 +108,14 @@ export const useWorkbench = defineStore("workbench", { ); }, + // sends the saves that wait for their debounce now, and waits for them + async sendWaitingSaves() { + await Promise.all([ + (this.saveBlock as DebouncedFunc).flush(), + (this.saveInteraction as DebouncedFunc).flush(), + ]); + }, + // sends the saves that wait for their debounce now, as the page closes flushSaves() { isLeaving = true; From 96553ea9e26c801ae62e610880b75fd71d223596 Mon Sep 17 00:00:00 2001 From: Philipp Reinking Date: Tue, 13 Oct 2026 02:57:35 +0200 Subject: [PATCH 02/12] feat: ask before deleting a question or group from its menu The menu's Delete opens the delete dialog that names the answers and the rules that stop working. After the delete, focus goes to the next row, else the one above, else Add question; a failed delete keeps it on the row. Co-Authored-By: Claude Opus 5.5 --- .../Factory/Document/BlockMenu.test.ts | 101 ++++++++++++++++++ .../components/Factory/Document/BlockMenu.vue | 34 +++--- .../Factory/Document/FormDocument.vue | 6 +- .../components/Factory/Document/blockFocus.ts | 28 +++++ .../Factory/Shared/useDeleteBlock.ts | 7 +- 5 files changed, 161 insertions(+), 15 deletions(-) diff --git a/resources/js/components/Factory/Document/BlockMenu.test.ts b/resources/js/components/Factory/Document/BlockMenu.test.ts index d22cf12e..4b70fa55 100644 --- a/resources/js/components/Factory/Document/BlockMenu.test.ts +++ b/resources/js/components/Factory/Document/BlockMenu.test.ts @@ -1,8 +1,15 @@ import { flushPromises, mount, VueWrapper } from "@vue/test-utils"; +import { AxiosResponse } from "axios"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { callGetFormBlocks } from "@/api/blocks"; import { useForm, useWorkbench } from "@/stores"; import BlockMenu from "./BlockMenu.vue"; +vi.mock("@/api/blocks", async (importOriginal) => ({ + ...(await importOriginal()), + callGetFormBlocks: vi.fn(), +})); + const blocks = [ { id: 1, uuid: "q1", type: "radio" }, { id: 2, uuid: "q2", type: "radio" }, @@ -81,3 +88,97 @@ describe("a block's menu", () => { expect(item("Add question to this group")).toBeDefined(); }); }); + +describe("deleting from a block's menu", () => { + const q1 = { id: 1, uuid: "q1", type: "radio" } as FormBlockModel; + const g1 = { id: 2, uuid: "g1", type: "group" } as FormBlockModel; + const q3 = { id: 3, uuid: "q3", type: "radio", parent_block: "g1" }; + const q2 = { id: 4, uuid: "q2", type: "radio" } as FormBlockModel; + + // the rows FormDocument shows, and its Add question button + const showRows = (...uuids: string[]) => { + document.body.innerHTML = uuids + .map( + (uuid) => + `
`, + ) + .join(""); + document.body.insertAdjacentHTML( + "beforeend", + '
', + ); + }; + + const deleteFrom = async (block: FormBlockModel) => { + await openMenu(block); + item(block.type === "group" ? "Delete group…" : "Delete…")!.click(); + await flushPromises(); + // the dialog asks first: nothing is deleted yet + expect(useForm().deleteFormBlock).not.toHaveBeenCalled(); + + document + .querySelector("[role=alertdialog] button.bg-red-600")! + .click(); + await flushPromises(); + }; + + beforeEach(() => { + vi.clearAllMocks(); + useWorkbench().$reset(); + const form = useForm(); + form.$reset(); + form.form = { uuid: "form1" } as FormModel; + form.blocks = [q1, g1, q3 as FormBlockModel, q2]; + vi.mocked(callGetFormBlocks).mockResolvedValue({ + data: form.blocks, + } as AxiosResponse); + vi.mocked(form.deleteFormBlock).mockImplementation(async (block) => { + form.blocks = form.blocks!.filter( + (item) => + item.id !== block.id && item.parent_block !== block.uuid, + ); + }); + }); + + afterEach(() => { + wrapper?.unmount(); + document.body.innerHTML = ""; + }); + + it("moves focus to the next row, past a deleted group's questions", async () => { + showRows("q1", "g1", "q3", "q2"); + + await deleteFrom(g1); + + expect(useForm().deleteFormBlock).toHaveBeenCalledWith(g1); + expect(document.activeElement?.textContent).toBe("q2"); + }); + + it("moves focus to the row above when the last one is deleted", async () => { + showRows("q1", "g1", "q3", "q2"); + + await deleteFrom(q2); + + expect(document.activeElement?.closest("[id^=row-]")?.id).toBe( + "row-q3", + ); + }); + + it("moves focus to Add question when no block is left", async () => { + useForm().blocks = [q1]; + showRows("q1"); + + await deleteFrom(q1); + + expect(document.activeElement?.textContent).toBe("Add question"); + }); + + it("keeps focus on the row when the delete fails", async () => { + vi.mocked(useForm().deleteFormBlock).mockResolvedValue(); + showRows("q1", "g1", "q3", "q2"); + + await deleteFrom(q1); + + expect(document.activeElement?.textContent).toBe("q1"); + }); +}); diff --git a/resources/js/components/Factory/Document/BlockMenu.vue b/resources/js/components/Factory/Document/BlockMenu.vue index 7c078da6..ddcbc273 100644 --- a/resources/js/components/Factory/Document/BlockMenu.vue +++ b/resources/js/components/Factory/Document/BlockMenu.vue @@ -65,9 +65,15 @@ as="button" :class="[item, 'text-red-700']" :label="isGroup ? 'Delete group…' : 'Delete…'" - @click="deleteBlock" + @click="ask" /> + + diff --git a/resources/js/components/Factory/Document/FormDocument.vue b/resources/js/components/Factory/Document/FormDocument.vue index fe991a6d..da46d875 100644 --- a/resources/js/components/Factory/Document/FormDocument.vue +++ b/resources/js/components/Factory/Document/FormDocument.vue @@ -47,7 +47,11 @@ description="Add your first question below." /> -
+
+ nodes.flatMap((node) => [node.block, ...shown(node.children)]); + const order = shown(useForm().blocksTree).filter( + (item) => item.parent_block !== block.uuid, + ); + const at = order.findIndex((item) => item.id === block.id); + + return [...order.slice(at + 1), ...order.slice(0, at).reverse()]; +} + +// focuses a block's row: a closed question's button, an open one's text, a group's name +export async function focusRow(block: FormBlockModel): Promise { + if (block.type === "group" || useWorkbench().block?.id === block.id) { + return focusBlock(block); + } + + await nextTick(); + const button = document.querySelector( + `#row-${block.uuid} > button`, + ); + button?.focus(); + + return button !== null; +} diff --git a/resources/js/components/Factory/Shared/useDeleteBlock.ts b/resources/js/components/Factory/Shared/useDeleteBlock.ts index 78993d5c..18819377 100644 --- a/resources/js/components/Factory/Shared/useDeleteBlock.ts +++ b/resources/js/components/Factory/Shared/useDeleteBlock.ts @@ -57,14 +57,17 @@ export function useDeleteBlock(block: FormBlockModel) { check.value = null; }; - const confirm = () => { + // resolves once the server answered, so focus can move after it + const confirm = async () => { const workbench = useWorkbench(); check.value = null; - useForm().deleteFormBlock(block); + const deleting = useForm().deleteFormBlock(block); if (workbench.block?.id === block.id) { workbench.clearWorkbench(); } + + await deleting; }; return { check, ask, cancel, confirm }; From fef51c5c4cec388f0e3a7c343b30debc633242d0 Mon Sep 17 00:00:00 2001 From: Philipp Reinking Date: Tue, 13 Oct 2026 02:59:11 +0200 Subject: [PATCH 03/12] feat: show the save status in the form's top bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The editor's "Saving…", "Saved" and "Not saved" with Retry now sit in the top bar, in its light grey as in the board; under 1280 px only the icons show and Retry is 44 px tall on touch screens. Co-Authored-By: Claude Opus 5.5 --- resources/js/Pages/Forms/Edit.vue | 2 ++ .../js/components/Factory/Shared/SaveStatus.vue | 15 ++++++++------- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/resources/js/Pages/Forms/Edit.vue b/resources/js/Pages/Forms/Edit.vue index 27a60fea..69dc5b44 100644 --- a/resources/js/Pages/Forms/Edit.vue +++ b/resources/js/Pages/Forms/Edit.vue @@ -1,5 +1,6 @@ @@ -8,6 +9,7 @@ import AppLayout from "@/Layouts/AppLayout.vue"; import FormDocument from "@/components/Factory/Document/FormDocument.vue"; import { useForm, useWorkbench } from "@/stores"; +import SaveStatus from "@/components/Factory/Shared/SaveStatus.vue"; import { useSaveOnLeave } from "@/components/Factory/Shared/useSaveOnLeave"; import { onBeforeUnmount, onMounted, ref } from "vue"; diff --git a/resources/js/components/Factory/Shared/SaveStatus.vue b/resources/js/components/Factory/Shared/SaveStatus.vue index eb9ff8ae..0d3ff5ac 100644 --- a/resources/js/components/Factory/Shared/SaveStatus.vue +++ b/resources/js/components/Factory/Shared/SaveStatus.vue @@ -1,5 +1,6 @@