diff --git a/CHANGELOG.md b/CHANGELOG.md index 34100282..43ac60c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Lines marked **Upgrade step** need you to act after updating. - 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) - A form's pages have one top bar instead of two: the form's name with its menu, a Draft or Live label, the pages, Preview, Publish and your account. Publish first checks for questions without text, rules that point to a deleted question and a missing privacy link. Once the form is live, Share has its link, Open form and Unpublish. On a phone the pages sit in a tab bar at the bottom. (#265) +- The form editor shows your whole form as one column. Each question shows its number, answer type, text, and if it is required, has rules or is hidden. The question you work on opens right there, also on a phone, where you couldn't edit questions before. Hiding a question, random order and custom answers are now under the question's Settings. (#268) - 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 diff --git a/resources/js/Pages/Forms/Edit.test.ts b/resources/js/Pages/Forms/Edit.test.ts index 698a0c87..fba82d18 100644 --- a/resources/js/Pages/Forms/Edit.test.ts +++ b/resources/js/Pages/Forms/Edit.test.ts @@ -11,9 +11,8 @@ const blocks = [ const stubs = { AppLayout: { props: ["title"], template: "
" }, - BlockContainer: { template: "
  1. questions
" }, + DocumentList: { template: "
  1. questions
" }, BlockLogicEditor: true, - Workbench: true, }; let wrapper: VueWrapper | undefined; @@ -21,10 +20,16 @@ let wrapper: VueWrapper | undefined; // the test pinia stubs actions: getBlocks does what each test tells it to const mountEditor = async (load: () => Promise) => { vi.mocked(useForm().getBlocks).mockImplementation(load); - wrapper = mount(Edit, { props: { form }, global: { stubs } }); + wrapper = mount(Edit, { + props: { form }, + global: { stubs }, + attachTo: document.body, + }); await flushPromises(); return wrapper; }; +const focused = () => + document.activeElement?.closest("button")?.textContent?.trim(); const loads = (result: FormBlockModel[]) => async () => { useForm().blocks = result; }; @@ -45,8 +50,7 @@ describe("editor loading", () => { const editor = await mountEditor(() => new Promise(() => {})); expect(editor.get("[role=status]").text()).toBe("Loading questions"); - expect(editor.text()).not.toContain("Welcome to the Editor"); - expect(editor.text()).not.toContain("No blocks found"); + expect(editor.text()).not.toContain("No questions yet"); expect(editor.find("[role=alert]").exists()).toBe(false); }); @@ -56,8 +60,7 @@ describe("editor loading", () => { const alert = editor.get("[role=alert]"); expect(alert.text()).toContain("Couldn't load the questions."); - expect(editor.text()).not.toContain("Welcome to the Editor"); - expect(editor.text()).not.toContain("No blocks found"); + expect(editor.text()).not.toContain("No questions yet"); getBlocks.mockImplementation(loads(blocks)); await alert.get("button").trigger("click"); @@ -73,8 +76,7 @@ describe("editor loading", () => { it("shows the empty-form text for a loaded form without questions", async () => { const editor = await mountEditor(loads([])); - expect(editor.text()).toContain("Welcome to the Editor"); - expect(editor.text()).toContain("No blocks found"); + expect(editor.text()).toContain("No questions yet"); expect(editor.find("[role=status]").exists()).toBe(false); }); @@ -83,6 +85,36 @@ describe("editor loading", () => { const editor = await mountEditor(loads(blocks)); expect(editor.text()).toContain("questions"); - expect(editor.text()).not.toContain("Welcome to the Editor"); + expect(editor.text()).not.toContain("No questions yet"); + }); + + it("puts focus on Add question after Try again loads, and back on Try again when it fails again", async () => { + const getBlocks = vi.mocked(useForm().getBlocks); + const editor = await mountEditor(() => Promise.reject(new Error("500"))); + + await editor.get("[role=alert] button").trigger("click"); + await flushPromises(); + expect(focused()).toBe("Try again"); + + // putOnWorkbench is a stub, so no question is open to take the focus + getBlocks.mockImplementation(loads(blocks)); + await editor.get("[role=alert] button").trigger("click"); + await flushPromises(); + expect(focused()).toBe("Add question"); + }); + + it("opens no question when the questions arrive after the editor closed", async () => { + let arrive!: () => void; + const editor = await mountEditor( + () => new Promise((resolve) => (arrive = resolve)), + ); + + editor.unmount(); + wrapper = undefined; + useForm().blocks = blocks; + arrive(); + await flushPromises(); + + expect(useWorkbench().putOnWorkbench).not.toHaveBeenCalled(); }); }); diff --git a/resources/js/Pages/Forms/Edit.vue b/resources/js/Pages/Forms/Edit.vue index e3de9026..27a60fea 100644 --- a/resources/js/Pages/Forms/Edit.vue +++ b/resources/js/Pages/Forms/Edit.vue @@ -1,37 +1,14 @@ diff --git a/resources/js/components/Factory/Document/BlockMenu.vue b/resources/js/components/Factory/Document/BlockMenu.vue new file mode 100644 index 00000000..6c351edb --- /dev/null +++ b/resources/js/components/Factory/Document/BlockMenu.vue @@ -0,0 +1,99 @@ + + + diff --git a/resources/js/components/Factory/Document/DocumentGroup.vue b/resources/js/components/Factory/Document/DocumentGroup.vue new file mode 100644 index 00000000..d6462098 --- /dev/null +++ b/resources/js/components/Factory/Document/DocumentGroup.vue @@ -0,0 +1,118 @@ + + + diff --git a/resources/js/components/Factory/Document/DocumentList.vue b/resources/js/components/Factory/Document/DocumentList.vue new file mode 100644 index 00000000..5c7f9ba9 --- /dev/null +++ b/resources/js/components/Factory/Document/DocumentList.vue @@ -0,0 +1,64 @@ + + + diff --git a/resources/js/components/Factory/Document/DocumentQuestion.test.ts b/resources/js/components/Factory/Document/DocumentQuestion.test.ts new file mode 100644 index 00000000..2d94d4ff --- /dev/null +++ b/resources/js/components/Factory/Document/DocumentQuestion.test.ts @@ -0,0 +1,65 @@ +import { mount } from "@vue/test-utils"; +import { beforeEach, describe, expect, it } from "vitest"; +import { useWorkbench } from "@/stores"; +import DocumentQuestion from "./DocumentQuestion.vue"; + +const question = { + id: 7, + uuid: "hSV2gq81LmQyTz0a", + type: "checkbox", + message: "

Which days can you help?

", + is_required: true, + is_disabled: true, + title: "days", + logics: [{ id: 1 }], + interactions: [ + { type: "button", is_editable: true }, + { type: "button", is_editable: true }, + { type: "button", is_editable: false }, + ], +} as unknown as FormBlockModel; + +const mountQuestion = (block = question) => + mount(DocumentQuestion, { + props: { block, number: 3 }, + global: { stubs: { QuestionCard: { template: "
card
" } } }, + }); + +describe("a closed question in the document", () => { + beforeEach(() => useWorkbench().$reset()); + + it("shows its number, text and a meta line, never its random id", () => { + const text = mountQuestion().find("button").text(); + + expect(text).toContain("Question 3:"); + expect(text).toContain("Which days can you help?"); + expect(text).toContain("Checkboxes · 2 choices"); + expect(text).toContain("Required"); + expect(text).toContain("1 rule"); + expect(text).toContain("Hidden"); + expect(text).toContain("days"); + expect(text).not.toContain(question.uuid); + }); + + it("says when a question has no text yet instead of naming it by id", () => { + const text = mountQuestion({ ...question, message: "" }) + .find("button") + .text(); + + expect(text).toContain("No question text yet"); + expect(text).not.toContain(question.uuid); + }); + + it("opens as a card when clicked", async () => { + const workbench = useWorkbench(); + const wrapper = mountQuestion(); + + await wrapper.find("button").trigger("click"); + expect(workbench.putOnWorkbench).toHaveBeenCalledWith(question); + + workbench.block = question; + await wrapper.vm.$nextTick(); + expect(wrapper.text()).toContain("card"); + expect(wrapper.find("button").exists()).toBe(false); + }); +}); diff --git a/resources/js/components/Factory/Document/DocumentQuestion.vue b/resources/js/components/Factory/Document/DocumentQuestion.vue new file mode 100644 index 00000000..37f8d992 --- /dev/null +++ b/resources/js/components/Factory/Document/DocumentQuestion.vue @@ -0,0 +1,99 @@ + + + diff --git a/resources/js/components/Factory/Document/FormDocument.vue b/resources/js/components/Factory/Document/FormDocument.vue new file mode 100644 index 00000000..58ac1ce4 --- /dev/null +++ b/resources/js/components/Factory/Document/FormDocument.vue @@ -0,0 +1,113 @@ + + + diff --git a/resources/js/components/Factory/Document/QuestionCard.test.ts b/resources/js/components/Factory/Document/QuestionCard.test.ts new file mode 100644 index 00000000..6b058951 --- /dev/null +++ b/resources/js/components/Factory/Document/QuestionCard.test.ts @@ -0,0 +1,75 @@ +import { flushPromises, mount, VueWrapper } from "@vue/test-utils"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useForm, useWorkbench } from "@/stores"; +import QuestionCard from "./QuestionCard.vue"; + +const block = { + id: 4, + uuid: "q4", + type: "input-short", + message: "

Your name?

", + interactions: [], +} as unknown as FormBlockModel; + +let wrapper: VueWrapper | undefined; + +describe("an open question", () => { + beforeEach(() => { + useWorkbench().$reset(); + useForm().$reset(); + }); + + afterEach(() => wrapper?.unmount()); + + it("makes its answer field without opening Settings", async () => { + const workbench = useWorkbench(); + useForm().mapping = { "input-short": "input" } as Record< + FormBlockType, + FormBlockInteractionType + >; + workbench.block = { ...block, interactions: [] }; + vi.mocked(workbench.createInteraction).mockResolvedValue({ + id: 9, + type: "input", + label: "", + } as unknown as FormBlockInteractionModel); + + wrapper = mount(QuestionCard, { + props: { block: workbench.block, number: 4 }, + global: { stubs: { Editor: true, BlockMenu: true } }, + }); + await flushPromises(); + + expect(workbench.createInteraction).toHaveBeenCalledWith("input"); + }); + + it("shows Required only when the question takes an answer", () => { + const stubs = { Editor: true, BlockMenu: true, QuestionSettings: true }; + wrapper = mount(QuestionCard, { + props: { block: { ...block, type: "none" }, number: 1 }, + global: { stubs }, + }); + expect(wrapper.text()).not.toContain("Required"); + + wrapper.unmount(); + wrapper = mount(QuestionCard, { + props: { block, number: 4 }, + global: { stubs }, + }); + expect(wrapper.text()).toContain("Required"); + }); + + it("points the Settings button at its panel", () => { + wrapper = mount(QuestionCard, { + props: { block, number: 4 }, + global: { stubs: { Editor: true, BlockMenu: true } }, + }); + const button = wrapper + .findAll("button") + .find((b) => b.text() === "Settings")!; + + const panel = wrapper.find(`#${button.attributes("aria-controls")}`); + expect(panel.exists()).toBe(true); + expect(panel.attributes("role")).toBe("group"); + }); +}); diff --git a/resources/js/components/Factory/Document/QuestionCard.vue b/resources/js/components/Factory/Document/QuestionCard.vue new file mode 100644 index 00000000..f48cbae1 --- /dev/null +++ b/resources/js/components/Factory/Document/QuestionCard.vue @@ -0,0 +1,127 @@ + + + diff --git a/resources/js/components/Factory/Document/QuestionSettings.vue b/resources/js/components/Factory/Document/QuestionSettings.vue new file mode 100644 index 00000000..675c6149 --- /dev/null +++ b/resources/js/components/Factory/Document/QuestionSettings.vue @@ -0,0 +1,92 @@ + + + diff --git a/resources/js/components/Factory/Document/blockFocus.test.ts b/resources/js/components/Factory/Document/blockFocus.test.ts new file mode 100644 index 00000000..6b243339 --- /dev/null +++ b/resources/js/components/Factory/Document/blockFocus.test.ts @@ -0,0 +1,35 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useForm, useWorkbench } from "@/stores"; +import { cursorFor, useAddBlock } from "./blockFocus"; + +const open = { id: 3, uuid: "q3", type: "radio" } as FormBlockModel; +const added = { id: 4, uuid: "q4", type: "radio" } as FormBlockModel; + +describe("adding a question", () => { + beforeEach(() => { + useWorkbench().$reset(); + cursorFor.value = null; + }); + + it("puts the cursor in the new question", async () => { + const workbench = useWorkbench(); + workbench.block = open; + vi.mocked(useForm().createFormBlock).mockImplementation(async () => { + workbench.block = added; + }); + + await useAddBlock().addBlock(open); + + expect(cursorFor.value).toBe("q4"); + }); + + it("leaves the cursor alone when the question can't be made", async () => { + const workbench = useWorkbench(); + workbench.block = open; + vi.mocked(useForm().createFormBlock).mockResolvedValue(undefined); + + await useAddBlock().addBlock(open); + + expect(cursorFor.value).toBeNull(); + }); +}); diff --git a/resources/js/components/Factory/Document/blockFocus.ts b/resources/js/components/Factory/Document/blockFocus.ts new file mode 100644 index 00000000..3cf67911 --- /dev/null +++ b/resources/js/components/Factory/Document/blockFocus.ts @@ -0,0 +1,43 @@ +import { nextTick, ref } from "vue"; +import { useForm, useWorkbench } from "@/stores"; + +// the question whose card takes the cursor once it shows: its text editor isn't there at once +export const cursorFor = ref(null); + +// puts the cursor in a question's text, or in a group's name +export async function focusBlock(block: FormBlockModel | null): Promise { + if (!block) { + return false; + } + + if (block.type !== "group") { + cursorFor.value = block.uuid; + return true; + } + + await nextTick(); + const name = document.getElementById(`group-${block.uuid}-name`); + name?.focus(); + + return name !== null; +} + +// a new block opens with the cursor in it +export function useAddBlock() { + const store = useForm(); + const workbench = useWorkbench(); + + const addBlock = async ( + ...args: Parameters + ) => { + const before = workbench.block; + await store.createFormBlock(...args); + + // a failed create keeps the open block: leave the cursor where it is + if (workbench.block !== before) { + await focusBlock(workbench.block); + } + }; + + return { addBlock }; +} diff --git a/resources/js/components/Factory/Document/questionSummary.test.ts b/resources/js/components/Factory/Document/questionSummary.test.ts new file mode 100644 index 00000000..309eccd6 --- /dev/null +++ b/resources/js/components/Factory/Document/questionSummary.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { plainText, questionNumbers } from "./questionSummary"; + +const block = (uuid: string, type = "radio", parent_block: string | null = null) => + ({ uuid, type, parent_block }) as FormBlockModel; + +describe("plain question text", () => { + it("drops tags and keeps a space between paragraphs", () => { + expect( + plainText("

Hi & welcome

How was it?

"), + ).toBe("Hi & welcome How was it?"); + }); + + it("is empty for no text or only an image", () => { + expect(plainText(null)).toBe(""); + expect(plainText('')).toBe(""); + }); +}); + +describe("question numbers", () => { + it("counts on through groups and skips the group itself", () => { + const numbers = questionNumbers([ + block("a"), + block("g", "group"), + block("c"), + block("b", "radio", "g"), + block("b2", "date", "g"), + ]); + + expect(numbers).toEqual({ a: 1, b: 2, b2: 3, c: 4 }); + }); + + it("gives an empty group nothing", () => { + expect(questionNumbers([block("g", "group"), block("a")])).toEqual({ + a: 1, + }); + }); +}); diff --git a/resources/js/components/Factory/Document/questionSummary.ts b/resources/js/components/Factory/Document/questionSummary.ts new file mode 100644 index 00000000..a34648a0 --- /dev/null +++ b/resources/js/components/Factory/Document/questionSummary.ts @@ -0,0 +1,32 @@ +// the text a closed question shows: no HTML, one space where a paragraph ended +export function plainText(html: string | null): string { + if (!html) { + return ""; + } + + const spaced = html.replace(/<\/(p|h[1-6]|li|blockquote)>|/gi, "$& "); + const doc = new DOMParser().parseFromString(spaced, "text/html"); + + return (doc.body.textContent ?? "").replace(/\s+/g, " ").trim(); +} + +// questions count on through groups; a group itself gets no number +export function questionNumbers( + blocks: FormBlockModel[], +): Record { + const numbers: Record = {}; + const inScope = (parent: string | null) => + blocks.filter((block) => (block.parent_block ?? null) === parent); + let next = 1; + + for (const block of inScope(null)) { + const questions = + block.type === "group" ? inScope(block.uuid) : [block]; + + for (const question of questions) { + numbers[question.uuid] = next++; + } + } + + return numbers; +} diff --git a/resources/js/components/Factory/Main/BlockInteractions.vue b/resources/js/components/Factory/Main/BlockInteractions.vue deleted file mode 100644 index 36828819..00000000 --- a/resources/js/components/Factory/Main/BlockInteractions.vue +++ /dev/null @@ -1,26 +0,0 @@ - - - diff --git a/resources/js/components/Factory/Main/BlockMessage.vue b/resources/js/components/Factory/Main/BlockMessage.vue deleted file mode 100644 index 71061471..00000000 --- a/resources/js/components/Factory/Main/BlockMessage.vue +++ /dev/null @@ -1,22 +0,0 @@ - - - diff --git a/resources/js/components/Factory/Main/BlockMetaHeader.vue b/resources/js/components/Factory/Main/BlockMetaHeader.vue deleted file mode 100644 index 597ba426..00000000 --- a/resources/js/components/Factory/Main/BlockMetaHeader.vue +++ /dev/null @@ -1,34 +0,0 @@ - - - diff --git a/resources/js/components/Factory/Main/BlockType.vue b/resources/js/components/Factory/Main/BlockType.vue deleted file mode 100644 index 2de35c6b..00000000 --- a/resources/js/components/Factory/Main/BlockType.vue +++ /dev/null @@ -1,65 +0,0 @@ - - - diff --git a/resources/js/components/Factory/Main/ChoiceRowActions.vue b/resources/js/components/Factory/Main/ChoiceRowActions.vue new file mode 100644 index 00000000..0c8b21dc --- /dev/null +++ b/resources/js/components/Factory/Main/ChoiceRowActions.vue @@ -0,0 +1,33 @@ + + + diff --git a/resources/js/components/Factory/Main/ConfigureClicks.test.ts b/resources/js/components/Factory/Main/ConfigureClicks.test.ts new file mode 100644 index 00000000..356af4fe --- /dev/null +++ b/resources/js/components/Factory/Main/ConfigureClicks.test.ts @@ -0,0 +1,147 @@ +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 ConfigureClicks from "./ConfigureClicks.vue"; + +vi.mock("@/api/blocks", async (importOriginal) => ({ + ...(await importOriginal()), + callGetFormBlocks: vi.fn(), +})); + +const choice = (id: number, label: string) => + ({ + id, + type: "button", + label, + sequence: id, + is_editable: true, + }) as FormBlockInteractionModel; + +let wrapper: VueWrapper | undefined; + +const mountChoices = (...labels: string[]) => { + return mountList(labels.map((label, i) => choice(i + 1, label))); +}; + +// a custom answer ("Other") is a hidden row in the same list +const custom = (sequence: number) => + ({ + id: 99, + type: "button", + label: "Other", + sequence, + is_editable: false, + }) as FormBlockInteractionModel; + +const mountList = (interactions: FormBlockInteractionModel[]) => { + const workbench = useWorkbench(); + useForm().mapping = { radio: "button" } as Record< + FormBlockType, + FormBlockInteractionType + >; + workbench.block = { + id: 1, + uuid: "q1", + type: "radio", + interactions, + } as unknown as FormBlockModel; + vi.mocked(workbench.deleteInteraction).mockImplementation( + async ({ id }) => { + const list = workbench.block!.interactions!; + list.splice( + list.findIndex((item) => item.id === id), + 1, + ); + }, + ); + + // nobody picked these choices yet, so a delete doesn't ask first + useForm().form = { uuid: "form1" } as FormModel; + vi.mocked(callGetFormBlocks).mockResolvedValue({ + data: [ + { + ...workbench.block, + interactions: interactions.map((item) => ({ + ...item, + responses_count: 0, + })), + }, + ], + } as unknown as AxiosResponse); + + wrapper = mount(ConfigureClicks, { attachTo: document.body }); + return wrapper; +}; + +const deleteButton = (label: string) => + wrapper!.find(`button[aria-label='Delete choice “${label}”']`); + +describe("deleting a choice in its row", () => { + beforeEach(() => { + useWorkbench().$reset(); + useForm().$reset(); + }); + + afterEach(() => wrapper?.unmount()); + + it("moves focus to the choice above", async () => { + mountChoices("Yes", "No", "Maybe"); + + await deleteButton("No").trigger("click"); + await flushPromises(); + + expect(document.activeElement?.id).toBe("1_label"); + }); + + it("moves focus to the new first choice when the first is gone", async () => { + mountChoices("Yes", "No"); + + await deleteButton("Yes").trigger("click"); + await flushPromises(); + + expect(document.activeElement?.id).toBe("2_label"); + }); + + it("skips a custom answer row when it moves focus", async () => { + mountList([custom(0), choice(1, "Yes"), choice(2, "No")]); + + await deleteButton("No").trigger("click"); + await flushPromises(); + + expect(document.activeElement?.id).toBe("1_label"); + }); + + it("moves focus to Add choice when no choice is left", async () => { + mountChoices("Yes"); + + await deleteButton("Yes").trigger("click"); + await flushPromises(); + + const focused = document.activeElement; + expect(focused?.tagName).toBe("BUTTON"); + expect(focused?.textContent).toContain("Add choice"); + }); +}); + +describe("Enter on the last choice", () => { + beforeEach(() => { + useWorkbench().$reset(); + useForm().$reset(); + }); + + afterEach(() => wrapper?.unmount()); + + it("adds a choice, also with a custom answer last", async () => { + mountList([choice(1, "Yes"), choice(2, "No"), custom(3)]); + vi.mocked(useWorkbench().createInteraction).mockResolvedValue( + undefined, + ); + + await wrapper!.find("[id='2_label']").trigger("keyup", { key: "Enter" }); + await flushPromises(); + + expect(useWorkbench().createInteraction).toHaveBeenCalledWith("button"); + }); +}); diff --git a/resources/js/components/Factory/Main/ConfigureClicks.vue b/resources/js/components/Factory/Main/ConfigureClicks.vue index 140dbd03..2c34a4b0 100644 --- a/resources/js/components/Factory/Main/ConfigureClicks.vue +++ b/resources/js/components/Factory/Main/ConfigureClicks.vue @@ -3,13 +3,13 @@ @@ -21,54 +21,46 @@ @next="focusNextItem" @nextSoft="focusNextItemSoft" @previous="focusPreviousItem" - @onDelete="focusNeighborItem" + @onDelete="deleteItem" /> -
- -
- - - - + diff --git a/resources/js/components/Factory/Main/ConfigureConsents.vue b/resources/js/components/Factory/Main/ConfigureConsents.vue index 34b4916e..ae69b019 100644 --- a/resources/js/components/Factory/Main/ConfigureConsents.vue +++ b/resources/js/components/Factory/Main/ConfigureConsents.vue @@ -9,7 +9,7 @@ @@ -21,11 +21,12 @@ v-bind="{ item, index }" :key="`${item.id}-${index}`" :ref="bindTemplateRefsForTraversables.bind(index)" + @onDelete="deleteItem" /> -
+
(null); const onDrop = ({ removedIndex, addedIndex }: any): void => { if (removedIndex === null || addedIndex === null) { @@ -78,7 +80,21 @@ const createConsentPolicy = async () => { } }; -const { bindTemplateRefsForTraversables, focusLastItem } = +const deleteItem = async (index: number) => { + const item = currentInteractions.value?.[index]; + if (!item) return; + + await workbench.deleteInteraction(item); + + // a failed delete keeps the policy, and focus stays in it + if (currentInteractions.value?.includes(item)) return; + + if (!(await focusNeighborItem(index))) { + addRow.value?.querySelector("button")?.focus(); + } +}; + +const { bindTemplateRefsForTraversables, focusLastItem, focusNeighborItem } = useKeyboardNavigation(currentInteractions, () => { return; }); diff --git a/resources/js/components/Factory/Main/GroupSettings.vue b/resources/js/components/Factory/Main/GroupSettings.vue deleted file mode 100644 index d67fd920..00000000 --- a/resources/js/components/Factory/Main/GroupSettings.vue +++ /dev/null @@ -1,24 +0,0 @@ - - - diff --git a/resources/js/components/Factory/Main/InteractionHoverActions.vue b/resources/js/components/Factory/Main/InteractionHoverActions.vue deleted file mode 100644 index cde6c160..00000000 --- a/resources/js/components/Factory/Main/InteractionHoverActions.vue +++ /dev/null @@ -1,24 +0,0 @@ - - - diff --git a/resources/js/components/Factory/Main/Interactions/ClickInteraction.vue b/resources/js/components/Factory/Main/Interactions/ClickInteraction.vue index 13bc9d20..579b9143 100644 --- a/resources/js/components/Factory/Main/Interactions/ClickInteraction.vue +++ b/resources/js/components/Factory/Main/Interactions/ClickInteraction.vue @@ -1,43 +1,43 @@