From 2de3109fd22b26078fe61f9cea43a8623706447a Mon Sep 17 00:00:00 2001 From: Philipp Reinking Date: Tue, 13 Oct 2026 02:33:02 +0200 Subject: [PATCH 01/14] feat: name and group the answer types, one icon each Short text, Long text, Email, Link, Phone, Number, Hidden text; Single choice, Checkboxes; Rating, Scale; Date, File upload, Consent; Text only. Text only no longer shares the envelope icon with Email. Co-Authored-By: Claude Opus 5.5 --- .../components/Factory/utils/useBlockTypes.ts | 38 ++++++++++++++----- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/resources/js/components/Factory/utils/useBlockTypes.ts b/resources/js/components/Factory/utils/useBlockTypes.ts index d43a7261..025fd96d 100644 --- a/resources/js/components/Factory/utils/useBlockTypes.ts +++ b/resources/js/components/Factory/utils/useBlockTypes.ts @@ -1,24 +1,44 @@ import { ref, Ref } from "vue"; +// the answer types as the type picker groups them, in the order of the list below +export const typeGroups: Array<{ label: string; types: FormBlockType[] }> = [ + { + label: "Text", + types: [ + "input-short", + "input-long", + "input-email", + "input-link", + "input-phone", + "input-number", + "input-secret", + ], + }, + { label: "Choice", types: ["radio", "checkbox"] }, + { label: "Rating", types: ["rating", "scale"] }, + { label: "Other", types: ["date", "input-file", "consent"] }, + { label: "Content", types: ["none"] }, +]; + export const useBlockTypes = (): { types: Ref>; } => { const rawTypes: Array> = [ - { label: "No Input", value: "none", icon: "envelope" }, - { label: "Multiple Choice", value: "radio", icon: "check-circle" }, - { label: "Checkboxes", value: "checkbox", icon: "check-square" }, - { label: "Short Answer", value: "input-short", icon: "pencil" }, - { label: "Long Answer", value: "input-long", icon: "message" }, - { label: "Number", value: "input-number", icon: "sort-numeric-up" }, + { label: "Short text", value: "input-short", icon: "pencil" }, + { label: "Long text", value: "input-long", icon: "message" }, { label: "Email", value: "input-email", icon: "envelope" }, { label: "Link", value: "input-link", icon: "link" }, - { label: "Phone Number", value: "input-phone", icon: "phone" }, - { label: "Secret", value: "input-secret", icon: "lock" }, + { label: "Phone", value: "input-phone", icon: "phone" }, + { label: "Number", value: "input-number", icon: "sort-numeric-up" }, + { label: "Hidden text", value: "input-secret", icon: "eye-slash" }, + { label: "Single choice", value: "radio", icon: "circle-dot" }, + { label: "Checkboxes", value: "checkbox", icon: "check-square" }, { label: "Rating", value: "rating", icon: "star" }, { label: "Scale", value: "scale", icon: "signal" }, { label: "Date", value: "date", icon: "calendar" }, - { label: "File", value: "input-file", icon: "cloud-arrow-up" }, + { label: "File upload", value: "input-file", icon: "cloud-arrow-up" }, { label: "Consent", value: "consent", icon: "user-shield" }, + { label: "Text only", value: "none", icon: "heading" }, ]; const types: Ref> = ref( From 39d3850e88cc2555ff12807da228a5d5e5c63235 Mon Sep 17 00:00:00 2001 From: Philipp Reinking Date: Tue, 13 Oct 2026 02:33:02 +0200 Subject: [PATCH 02/14] feat: add a type picker grouped as Text, Choice, Rating, Other, Content A search field filters the types; arrow keys move, Enter adds, Esc closes. Co-Authored-By: Claude Opus 5.5 --- .../Factory/Document/TypePicker.test.ts | 94 ++++++++++ .../Factory/Document/TypePicker.vue | 162 ++++++++++++++++++ 2 files changed, 256 insertions(+) create mode 100644 resources/js/components/Factory/Document/TypePicker.test.ts create mode 100644 resources/js/components/Factory/Document/TypePicker.vue diff --git a/resources/js/components/Factory/Document/TypePicker.test.ts b/resources/js/components/Factory/Document/TypePicker.test.ts new file mode 100644 index 00000000..df796882 --- /dev/null +++ b/resources/js/components/Factory/Document/TypePicker.test.ts @@ -0,0 +1,94 @@ +import { mount, VueWrapper } from "@vue/test-utils"; +import { afterEach, describe, expect, it } from "vitest"; +import TypePicker from "./TypePicker.vue"; + +let wrapper: VueWrapper | undefined; + +const mountPicker = () => { + wrapper = mount(TypePicker, { + props: { title: "Add a question" }, + global: { + stubs: { + Icon: { props: ["name"], template: `` }, + }, + }, + attachTo: document.body, + }); + return wrapper; +}; +const options = () => wrapper!.findAll("[role=option]"); +const optionTexts = () => options().map((option) => option.text()); + +describe("the answer type picker", () => { + afterEach(() => wrapper?.unmount()); + + it("groups the answer types, with one icon per type", () => { + const groups = mountPicker() + .findAll("[role=listbox] [role=group]") + .map((group) => [ + group.attributes("aria-label"), + group.findAll("[role=option]").map((option) => option.text()), + ]); + + expect(groups).toEqual([ + [ + "Text", + [ + "Short text", + "Long text", + "Email", + "Link", + "Phone", + "Number", + "Hidden text", + ], + ], + ["Choice", ["Single choice", "Checkboxes"]], + ["Rating", ["Rating", "Scale"]], + ["Other", ["Date", "File upload", "Consent"]], + ["Content", ["Text only"]], + ]); + + const icons = options().map((option) => + option.find("[data-icon]").attributes("data-icon"), + ); + expect(new Set(icons).size).toBe(15); + }); + + it("takes the cursor, filters as you type and adds the highlighted type with Enter", async () => { + const input = mountPicker().find("input"); + expect(document.activeElement).toBe(input.element); + + await input.setValue("che"); + expect(optionTexts()).toEqual(["Checkboxes"]); + + await input.setValue(""); + await input.trigger("keydown", { key: "ArrowDown" }); + expect(input.attributes("aria-activedescendant")).toBe( + options()[1].attributes("id"), + ); + expect(options()[1].attributes("aria-selected")).toBe("true"); + + await input.trigger("keydown", { key: "Enter" }); + expect(wrapper!.emitted("pick")).toEqual([["input-long"]]); + }); + + it("adds a type on click and closes on Escape", async () => { + mountPicker(); + + await options() + .find((option) => option.text() === "Single choice")! + .trigger("click"); + expect(wrapper!.emitted("pick")).toEqual([["radio"]]); + + await wrapper!.find("input").trigger("keydown", { key: "Escape" }); + expect(wrapper!.emitted("close")).toHaveLength(1); + }); + + it("says so when no type matches", async () => { + await mountPicker().find("input").setValue("xyz"); + + expect(options()).toHaveLength(0); + expect(wrapper!.text()).toContain("No answer type matches"); + }); +}); diff --git a/resources/js/components/Factory/Document/TypePicker.vue b/resources/js/components/Factory/Document/TypePicker.vue new file mode 100644 index 00000000..49cc9223 --- /dev/null +++ b/resources/js/components/Factory/Document/TypePicker.vue @@ -0,0 +1,162 @@ + + + From 68b2b3f6dd700b280ce26802a42e4f9b7a560cb0 Mon Sep 17 00:00:00 2001 From: Philipp Reinking Date: Tue, 13 Oct 2026 02:33:08 +0200 Subject: [PATCH 03/14] feat: add a question anywhere through the type picker "Add question" adds after the open question; "+ Add question here" between questions (hover and keyboard focus), the + in a question's gutter and "Add question below" add right there. The new question opens with the cursor in its text. An empty form shows "Add your first question" with the picker. Co-Authored-By: Claude Opus 5.5 --- resources/js/Pages/Forms/Edit.test.ts | 8 +-- .../Factory/Document/AddQuestionLine.vue | 30 ++++++++ .../components/Factory/Document/BlockMenu.vue | 5 +- .../Factory/Document/DocumentGroup.vue | 20 ++++-- .../Factory/Document/DocumentList.test.ts | 69 +++++++++++++++++++ .../Factory/Document/DocumentList.vue | 20 +++++- .../Factory/Document/DocumentQuestion.test.ts | 11 +-- .../Factory/Document/DocumentQuestion.vue | 27 ++++++-- .../Factory/Document/FormDocument.test.ts | 56 +++++++++++++++ .../Factory/Document/FormDocument.vue | 41 +++++++++-- .../Factory/Document/addQuestion.ts | 62 +++++++++++++++++ 11 files changed, 319 insertions(+), 30 deletions(-) create mode 100644 resources/js/components/Factory/Document/AddQuestionLine.vue create mode 100644 resources/js/components/Factory/Document/DocumentList.test.ts create mode 100644 resources/js/components/Factory/Document/addQuestion.ts diff --git a/resources/js/Pages/Forms/Edit.test.ts b/resources/js/Pages/Forms/Edit.test.ts index fba82d18..10c11437 100644 --- a/resources/js/Pages/Forms/Edit.test.ts +++ b/resources/js/Pages/Forms/Edit.test.ts @@ -50,7 +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("No questions yet"); + expect(editor.text()).not.toContain("Add your first question"); expect(editor.find("[role=alert]").exists()).toBe(false); }); @@ -60,7 +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("No questions yet"); + expect(editor.text()).not.toContain("Add your first question"); getBlocks.mockImplementation(loads(blocks)); await alert.get("button").trigger("click"); @@ -76,7 +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("No questions yet"); + expect(editor.text()).toContain("Add your first question"); expect(editor.find("[role=status]").exists()).toBe(false); }); @@ -85,7 +85,7 @@ describe("editor loading", () => { const editor = await mountEditor(loads(blocks)); expect(editor.text()).toContain("questions"); - expect(editor.text()).not.toContain("No questions yet"); + expect(editor.text()).not.toContain("Add your first question"); }); it("puts focus on Add question after Try again loads, and back on Try again when it fails again", async () => { diff --git a/resources/js/components/Factory/Document/AddQuestionLine.vue b/resources/js/components/Factory/Document/AddQuestionLine.vue new file mode 100644 index 00000000..c7c3294d --- /dev/null +++ b/resources/js/components/Factory/Document/AddQuestionLine.vue @@ -0,0 +1,30 @@ + + + diff --git a/resources/js/components/Factory/Document/BlockMenu.vue b/resources/js/components/Factory/Document/BlockMenu.vue index 6bfb93ec..f3e8e71d 100644 --- a/resources/js/components/Factory/Document/BlockMenu.vue +++ b/resources/js/components/Factory/Document/BlockMenu.vue @@ -25,7 +25,7 @@ as="button" :class="item" label="Add question below" - @click="addBlock(block)" + @click="openPicker({ key: `after-${block.uuid}`, after: block })" /> (); const store = useForm(); const workbench = useWorkbench(); -const { addBlock } = useAddBlock(); const item = "w-full min-h-11 text-left text-sm lg:min-h-0"; const isGroup = computed(() => props.block.type === "group"); diff --git a/resources/js/components/Factory/Document/DocumentGroup.vue b/resources/js/components/Factory/Document/DocumentGroup.vue index 715801ff..f9b05b49 100644 --- a/resources/js/components/Factory/Document/DocumentGroup.vue +++ b/resources/js/components/Factory/Document/DocumentGroup.vue @@ -66,6 +66,13 @@

+ @@ -75,7 +82,8 @@ import { D9Icon } from "@deck9/ui"; import { useForm, useLogic, useWorkbench } from "@/stores"; import BlockMenu from "./BlockMenu.vue"; import DocumentList from "./DocumentList.vue"; -import { useAddBlock } from "./blockFocus"; +import TypePicker from "./TypePicker.vue"; +import { addAt, closePicker, openPicker, useAddQuestion } from "./addQuestion"; const props = defineProps<{ group: FormBlockModel; @@ -85,7 +93,7 @@ const props = defineProps<{ const store = useForm(); const workbench = useWorkbench(); const logic = useLogic(); -const { addBlock } = useAddBlock(); +const { addQuestion: add } = useAddQuestion(); const id = computed(() => `group-${props.group.uuid}`); const isOpen = computed(() => workbench.block?.id === props.group.id); @@ -111,9 +119,13 @@ const addQuestion = () => { const last = children.value.at(-1); if (last) { - addBlock(last); + openPicker({ key: `after-${last.uuid}`, after: last }); } else { - addBlock(null, null, props.group.uuid); + openPicker({ + key: `group-${props.group.uuid}`, + after: null, + parent: props.group.uuid, + }); } }; diff --git a/resources/js/components/Factory/Document/DocumentList.test.ts b/resources/js/components/Factory/Document/DocumentList.test.ts new file mode 100644 index 00000000..56b1f1c4 --- /dev/null +++ b/resources/js/components/Factory/Document/DocumentList.test.ts @@ -0,0 +1,69 @@ +import { mount, VueWrapper } from "@vue/test-utils"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { useForm, useWorkbench } from "@/stores"; +import DocumentList from "./DocumentList.vue"; +import { addAt } from "./addQuestion"; + +const blocks = [ + { id: 1, uuid: "b1", type: "input-short", message: "

One

" }, + { id: 2, uuid: "b2", type: "input-short", message: "

Two

" }, + { id: 3, uuid: "b3", type: "input-short", message: "

Three

" }, +] as FormBlockModel[]; + +let wrapper: VueWrapper | undefined; + +const mountList = () => { + useForm().blocks = blocks; + wrapper = mount(DocumentList, { + props: { numbers: { b1: 1, b2: 2, b3: 3 } }, + global: { stubs: { QuestionCard: true } }, + attachTo: document.body, + }); + return wrapper; +}; +const option = (name: string) => + wrapper!.findAll("[role=option]").find((item) => item.text() === name)!; + +describe("adding a question inside the document", () => { + beforeEach(() => { + useForm().$reset(); + useWorkbench().$reset(); + addAt.value = null; + }); + + afterEach(() => wrapper?.unmount()); + + it("offers Add question here between questions, and adds there", async () => { + const lines = mountList() + .findAll("button") + .filter((button) => button.text().startsWith("Add question here")); + + expect(lines.map((line) => line.text())).toEqual([ + "Add question here, after question 1", + "Add question here, after question 2", + ]); + + await lines[0].trigger("click"); + await option("Single choice").trigger("click"); + + expect(useForm().createFormBlock).toHaveBeenCalledWith( + blocks[0], + "radio", + null, + ); + expect(wrapper!.find("[role=listbox]").exists()).toBe(false); + }); + + it("adds after a question from the + in its gutter", async () => { + await mountList() + .get('button[aria-label="Add question after question 3"]') + .trigger("click"); + await option("Date").trigger("click"); + + expect(useForm().createFormBlock).toHaveBeenCalledWith( + blocks[2], + "date", + null, + ); + }); +}); diff --git a/resources/js/components/Factory/Document/DocumentList.vue b/resources/js/components/Factory/Document/DocumentList.vue index 5c7f9ba9..20a042df 100644 --- a/resources/js/components/Factory/Document/DocumentList.vue +++ b/resources/js/components/Factory/Document/DocumentList.vue @@ -14,15 +14,27 @@ }" @drop="onDrop" > - -
+ +
+
+
@@ -33,6 +45,9 @@ import { Container, Draggable } from "vue3-smooth-dnd"; import { useForm } from "@/stores"; import DocumentGroup from "./DocumentGroup.vue"; import DocumentQuestion from "./DocumentQuestion.vue"; +import AddQuestionLine from "./AddQuestionLine.vue"; +import TypePicker from "./TypePicker.vue"; +import { addAt, blockName, closePicker, useAddQuestion } from "./addQuestion"; const props = defineProps<{ groupId?: string; @@ -40,6 +55,7 @@ const props = defineProps<{ }>(); const store = useForm(); +const { addQuestion } = useAddQuestion(); const blocks = computed(() => (store.blocks ?? []).filter( diff --git a/resources/js/components/Factory/Document/DocumentQuestion.test.ts b/resources/js/components/Factory/Document/DocumentQuestion.test.ts index 2d94d4ff..dd0cf918 100644 --- a/resources/js/components/Factory/Document/DocumentQuestion.test.ts +++ b/resources/js/components/Factory/Document/DocumentQuestion.test.ts @@ -19,6 +19,9 @@ const question = { ], } as unknown as FormBlockModel; +// the closed question's own button; the gutter's + is named by aria-label +const row = "button:not([aria-label])"; + const mountQuestion = (block = question) => mount(DocumentQuestion, { props: { block, number: 3 }, @@ -29,7 +32,7 @@ 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(); + const text = mountQuestion().find(row).text(); expect(text).toContain("Question 3:"); expect(text).toContain("Which days can you help?"); @@ -43,7 +46,7 @@ describe("a closed question in the document", () => { it("says when a question has no text yet instead of naming it by id", () => { const text = mountQuestion({ ...question, message: "" }) - .find("button") + .find(row) .text(); expect(text).toContain("No question text yet"); @@ -54,12 +57,12 @@ describe("a closed question in the document", () => { const workbench = useWorkbench(); const wrapper = mountQuestion(); - await wrapper.find("button").trigger("click"); + await wrapper.find(row).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); + expect(wrapper.find(row).exists()).toBe(false); }); }); diff --git a/resources/js/components/Factory/Document/DocumentQuestion.vue b/resources/js/components/Factory/Document/DocumentQuestion.vue index 70333659..7bd06507 100644 --- a/resources/js/components/Factory/Document/DocumentQuestion.vue +++ b/resources/js/components/Factory/Document/DocumentQuestion.vue @@ -9,13 +9,25 @@ class="flex h-6 items-center text-[13px] tabular-nums text-grey-600" >{{ number }} - + +
+ + +
@@ -71,6 +83,7 @@ import { useBlockTypes } from "@/components/Factory/utils/useBlockTypes"; import { plainText } from "./questionSummary"; import QuestionCard from "./QuestionCard.vue"; import { cursorFor } from "./blockFocus"; +import { openPicker } from "./addQuestion"; const props = defineProps<{ block: FormBlockModel; diff --git a/resources/js/components/Factory/Document/FormDocument.test.ts b/resources/js/components/Factory/Document/FormDocument.test.ts index 072f8a9f..5ca7c14f 100644 --- a/resources/js/components/Factory/Document/FormDocument.test.ts +++ b/resources/js/components/Factory/Document/FormDocument.test.ts @@ -2,6 +2,8 @@ import { flushPromises, mount, VueWrapper } from "@vue/test-utils"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useForm, useWorkbench } from "@/stores"; import FormDocument from "./FormDocument.vue"; +import { cursorFor } from "./blockFocus"; +import { addAt } from "./addQuestion"; const blocks = [ { id: 1, uuid: "b1", type: "none", message: "

Welcome

" }, @@ -16,6 +18,8 @@ const stubs = { }; let wrapper: VueWrapper | undefined; +const option = (name: string) => + wrapper!.findAll("[role=option]").find((item) => item.text() === name)!; const scrolled = vi.fn(); describe("the form document", () => { @@ -29,6 +33,8 @@ describe("the form document", () => { scrolled.mockClear(); useForm().$reset(); useWorkbench().$reset(); + addAt.value = null; + cursorFor.value = null; }); afterEach(() => wrapper?.unmount()); @@ -63,4 +69,54 @@ describe("the form document", () => { expect(useWorkbench().putOnWorkbench).toHaveBeenCalledWith(blocks[1]); expect(scrolled).toHaveBeenCalledWith("row-b2", "start"); }); + + it("shows the picker on an empty form and adds the first question", async () => { + useForm().blocks = []; + wrapper = mount(FormDocument, { + props: { status: "loaded" }, + global: { stubs }, + attachTo: document.body, + }); + + expect(wrapper.text()).toContain("Add your first question"); + await option("Short text").trigger("click"); + + expect(useForm().createFormBlock).toHaveBeenCalledWith( + null, + "input-short", + null, + ); + }); + + it("adds a question after the open one, with the cursor in its text", async () => { + const added = { + id: 3, + uuid: "b3", + type: "input-email", + } as FormBlockModel; + useForm().blocks = blocks; + useWorkbench().block = blocks[0]; + vi.mocked(useForm().createFormBlock).mockImplementation(async () => { + useWorkbench().block = added; + }); + wrapper = mount(FormDocument, { + props: { status: "loaded" }, + global: { stubs }, + attachTo: document.body, + }); + + await wrapper + .findAll("button") + .find((button) => button.text() === "Add question")! + .trigger("click"); + await option("Email").trigger("click"); + await flushPromises(); + + expect(useForm().createFormBlock).toHaveBeenCalledWith( + blocks[0], + "input-email", + null, + ); + expect(cursorFor.value).toBe("b3"); + }); }); diff --git a/resources/js/components/Factory/Document/FormDocument.vue b/resources/js/components/Factory/Document/FormDocument.vue index 1348e8b5..457d7154 100644 --- a/resources/js/components/Factory/Document/FormDocument.vue +++ b/resources/js/components/Factory/Document/FormDocument.vue @@ -41,20 +41,27 @@ - -
+
+ or type / in an empty question
+
@@ -102,11 +123,18 @@ import { computed, nextTick, ref, watch } from "vue"; import { Dialog, DialogPanel } from "@headlessui/vue"; import { D9Button, D9Icon } from "@deck9/ui"; import { useForm, useWorkbench } from "@/stores"; -import EmptyState from "@/components/EmptyState.vue"; import QuestionsLoadState from "@/components/Factory/Shared/QuestionsLoadState.vue"; import BlockLogicEditor from "@/components/Factory/Sidebar/BlockLogicEditor.vue"; import DocumentList from "./DocumentList.vue"; import DocumentOutline from "./DocumentOutline.vue"; +import TypePicker from "./TypePicker.vue"; +import { + addAt, + blockName, + closePicker, + openPicker, + useAddQuestion, +} from "./addQuestion"; import { questionNumbers } from "./questionSummary"; import { cursorFor, @@ -123,6 +151,7 @@ const emit = defineEmits<{ retry: [] }>(); const store = useForm(); const workbench = useWorkbench(); const { addBlock } = useAddBlock(); +const { addQuestion } = useAddQuestion(); const root = ref(null); const addButtons = ref(null); diff --git a/resources/js/components/Factory/Document/addQuestion.ts b/resources/js/components/Factory/Document/addQuestion.ts new file mode 100644 index 00000000..ab2432be --- /dev/null +++ b/resources/js/components/Factory/Document/addQuestion.ts @@ -0,0 +1,62 @@ +import { ref } from "vue"; +import { useForm, useWorkbench } from "@/stores"; +import { focusBlock, useAddBlock } from "./blockFocus"; + +// where the type picker shows (key), and where its question goes: after a block, or last in an empty group +type AddSpot = { key: string; after: FormBlockModel | null; parent?: string }; + +export const addAt = ref(null); + +// the button that opened the picker gets the cursor back +let opener: HTMLElement | null = null; + +// names a block in a label: "question 3", "the group Contact" +export function blockName( + block: FormBlockModel, + numbers: Record, +) { + return block.type === "group" + ? `the group ${block.title ?? ""}`.trim() + : `question ${numbers[block.uuid]}`; +} + +export function openPicker(spot: AddSpot) { + opener = document.activeElement as HTMLElement | null; + addAt.value = spot; +} + +export function closePicker() { + const spot = addAt.value; + addAt.value = null; + + if (opener?.isConnected) { + opener.focus(); + } else if (spot) { + focusBlock(spot.after); + } +} + +export function useAddQuestion() { + const store = useForm(); + const workbench = useWorkbench(); + const { addBlock } = useAddBlock(); + + const addQuestion = async (type: FormBlockType) => { + const spot = addAt.value; + const before = workbench.block; + addAt.value = null; + + // the store places it by object, and a save may have swapped that object + const after = + store.blocks?.find((block) => block.uuid === spot?.after?.uuid) ?? + null; + await addBlock(after, type, spot?.parent ?? null); + + // a failed create opens nothing + if (spot && workbench.block === before && opener?.isConnected) { + opener.focus(); + } + }; + + return { addQuestion }; +} From 60690719b40feaea38b4d9f5ff878c7a260db25e Mon Sep 17 00:00:00 2001 From: Philipp Reinking Date: Tue, 13 Oct 2026 02:33:08 +0200 Subject: [PATCH 04/14] feat: type / in an empty question to pick its answer type Co-Authored-By: Claude Opus 5.5 --- .../Factory/Document/QuestionCard.test.ts | 34 ++++++++++++++++++ .../Factory/Document/QuestionCard.vue | 36 ++++++++++++++++++- .../js/components/Factory/Shared/Editor.vue | 4 ++- 3 files changed, 72 insertions(+), 2 deletions(-) diff --git a/resources/js/components/Factory/Document/QuestionCard.test.ts b/resources/js/components/Factory/Document/QuestionCard.test.ts index 5858d483..cb56c493 100644 --- a/resources/js/components/Factory/Document/QuestionCard.test.ts +++ b/resources/js/components/Factory/Document/QuestionCard.test.ts @@ -131,4 +131,38 @@ describe("an open question", () => { ).toBe(true); expect(document.getElementById("question-q4-settings")).not.toBeNull(); }); + + describe("typing / in its text", () => { + const editorStub = { + template: `
`, + methods: { focus() {} }, + }; + const slash = async (message: string | null) => { + useWorkbench().block = { ...block, message }; + mountCard(false, { ...stubs, Editor: editorStub }); + const typed = await wrapper! + .find("[contenteditable]") + .trigger("keydown", { key: "/" }); + return typed; + }; + + it("opens the picker in an empty question, and a pick changes its type", async () => { + await slash(null); + const option = wrapper! + .findAll("[role=option]") + .find((item) => item.text() === "Single choice")!; + await option.trigger("click"); + + expect(useWorkbench().updateBlock).toHaveBeenCalledWith({ + type: "radio", + }); + expect(wrapper!.find("[role=listbox]").exists()).toBe(false); + }); + + it("stays a / in a question with text", async () => { + await slash("

Your name?

"); + + expect(wrapper!.find("[role=listbox]").exists()).toBe(false); + }); + }); }); diff --git a/resources/js/components/Factory/Document/QuestionCard.vue b/resources/js/components/Factory/Document/QuestionCard.vue index 3a499a66..30d8b19f 100644 --- a/resources/js/components/Factory/Document/QuestionCard.vue +++ b/resources/js/components/Factory/Document/QuestionCard.vue @@ -5,7 +5,20 @@ Question {{ number }} text - + + @@ -133,7 +146,9 @@ import ConfigureConsents from "@/components/Factory/Main/ConfigureConsents.vue"; import { useBlockTypes } from "@/components/Factory/utils/useBlockTypes"; import BlockMenu from "./BlockMenu.vue"; import QuestionSettings from "./QuestionSettings.vue"; +import TypePicker from "./TypePicker.vue"; import { cursorFor } from "./blockFocus"; +import { plainText } from "./questionSummary"; const props = defineProps<{ block: FormBlockModel; @@ -166,6 +181,25 @@ watch(type, (value) => { } }); +// "/" in an empty question picks its answer type +const typesOpen = ref(false); +const openTypes = (event: KeyboardEvent) => { + const inText = (event.target as HTMLElement).closest("[contenteditable]"); + + if (event.key === "/" && inText && !plainText(message.value)) { + event.preventDefault(); + typesOpen.value = true; + } +}; +const closeTypes = () => { + typesOpen.value = false; + editor.value?.focus(); +}; +const changeType = (value: FormBlockType) => { + type.value = types.value.find((item) => item.value === value); + closeTypes(); +}; + const takeCursor = () => { if (cursorFor.value === props.block.uuid) { cursorFor.value = null; diff --git a/resources/js/components/Factory/Shared/Editor.vue b/resources/js/components/Factory/Shared/Editor.vue index e0660f38..e4b18abb 100644 --- a/resources/js/components/Factory/Shared/Editor.vue +++ b/resources/js/components/Factory/Shared/Editor.vue @@ -137,6 +137,7 @@ const { counter = false, bare = false, id, + placeholder = "Type your message here...", } = defineProps<{ modelValue?: string; counter?: boolean; @@ -144,6 +145,7 @@ const { bare?: boolean; // names the text box after the D9Label with the same id id?: string; + placeholder?: string; }>(); const content = ref(""); @@ -181,7 +183,7 @@ const editor = useEditor({ extensions: [ FontFamily, Placeholder.configure({ - placeholder: "Type your message here...", + placeholder, }), StarterKit.configure({ heading: { From e710ffdd1ac4b51b3fbf5fc3aa3d2c580984e3ef Mon Sep 17 00:00:00 2001 From: Philipp Reinking Date: Tue, 13 Oct 2026 02:42:30 +0200 Subject: [PATCH 05/14] docs: add the type picker to the changelog (#276) Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ba371a3..e9ae3a85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Lines marked **Upgrade step** need you to act after updating. - The API can copy a question (`POST /api/blocks/{block}/duplicate`) with its text, settings, answers and Required, but not its rules. The copy goes right after the original. (#262) - The form's team can try a form in preview mode, also while it's a draft. Answers in preview mode are never stored or counted and send no webhooks or emails. (#266) - Add `?preview=1` to your form's link to try it as a member of its team. Nothing is stored, files aren't uploaded and the form doesn't redirect at the end. (#270) +- Add a question right where you need it: between questions, from a question's menu or with "/" in an empty question. A picker groups the answer types as Text, Choice, Rating, Other and Content, and the new question opens ready to type. The answer types have new names, like Single choice, Short text and Text only. (#276) ### Changed From bfb6428c44e601ccc98a3fccb513be91cc7d6a38 Mon Sep 17 00:00:00 2001 From: Philipp Reinking Date: Tue, 13 Oct 2026 02:56:56 +0200 Subject: [PATCH 06/14] docs: say that / picks the empty question's type 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 c2799b9a..4b0b3a78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ Lines marked **Upgrade step** need you to act after updating. - The API can copy a question (`POST /api/blocks/{block}/duplicate`) with its text, settings, answers and Required, but not its rules. The copy goes right after the original. (#262) - The form's team can try a form in preview mode, also while it's a draft. Answers in preview mode are never stored or counted and send no webhooks or emails. (#266) - Add `?preview=1` to your form's link to try it as a member of its team. Nothing is stored, files aren't uploaded and the form doesn't redirect at the end. (#270) -- Add a question right where you need it: between questions, from a question's menu or with "/" in an empty question. A picker groups the answer types as Text, Choice, Rating, Other and Content, and the new question opens ready to type. The answer types have new names, like Single choice, Short text and Text only. (#276) +- Add a question right where you need it: between questions or from a question's menu, and type "/" in an empty question to pick its answer type. A picker groups the answer types as Text, Choice, Rating, Other and Content, and the new question opens ready to type. The answer types have new names, like Single choice, Short text and Text only. (#276) ### Changed From 21be8501227ae0dc9265c1d2c7594c2b6547c477 Mon Sep 17 00:00:00 2001 From: Philipp Reinking Date: Tue, 13 Oct 2026 02:58:03 +0200 Subject: [PATCH 07/14] docs: use the new answer type names and the picker steps Multiple Choice is Single choice, Secret is Hidden text, Information Block is Text only, Phone Number is Phone. Adding a question names the add line, the menu and the picker. Page links stay the same. Co-Authored-By: Claude Opus 5.5 --- .vitepress/config.mts | 12 ++++++------ docs/input-types/checkboxes.md | 2 +- docs/input-types/multiplechoice.md | 8 ++++---- docs/input-types/no-interaction.md | 12 ++++++------ docs/input-types/phone.md | 4 ++-- docs/input-types/secret.md | 12 ++++++------ docs/quick-start.md | 4 ++-- docs/workbench/form-blocks.md | 20 +++++++++++--------- docs/workbench/workbench.md | 2 +- 9 files changed, 39 insertions(+), 37 deletions(-) diff --git a/.vitepress/config.mts b/.vitepress/config.mts index d1dee7d2..f85b6807 100644 --- a/.vitepress/config.mts +++ b/.vitepress/config.mts @@ -43,26 +43,26 @@ export default defineConfig({ collapsed: true, items: [ { - text: "Information Block 📝", + text: "Text only 📝", link: "/input-types/no-interaction", }, { - text: "Multiple Choice 🔘", + text: "Single choice 🔘", link: "/input-types/multiplechoice", }, { text: "Checkboxes ✅", link: "/input-types/checkboxes" }, - { text: "Short Text 💬", link: "/input-types/short-text" }, - { text: "Long Text 📄", link: "/input-types/long-text" }, + { text: "Short text 💬", link: "/input-types/short-text" }, + { text: "Long text 📄", link: "/input-types/long-text" }, { text: "Number 🔢", link: "/input-types/number" }, { text: "Email 📧", link: "/input-types/email" }, { text: "Link 🔗", link: "/input-types/link" }, { text: "Phone ☎️", link: "/input-types/phone" }, { text: "Date 📅", link: "/input-types/date" }, - { text: "Secret 🔒", link: "/input-types/secret" }, + { text: "Hidden text 🔒", link: "/input-types/secret" }, { text: "Rating ⭐", link: "/input-types/rating" }, { text: "Scale 📊", link: "/input-types/scale" }, { - text: "File Upload 📁", + text: "File upload 📁", link: "/input-types/file-upload", }, { text: "Consent ✓", link: "/input-types/consent" }, diff --git a/docs/input-types/checkboxes.md b/docs/input-types/checkboxes.md index 97ea775d..a7ef4565 100644 --- a/docs/input-types/checkboxes.md +++ b/docs/input-types/checkboxes.md @@ -36,5 +36,5 @@ Checkboxes allow users to select multiple options from a list, making them perfe - When you want to offer comprehensive option coverage ::: warning Note -If you want users to select only one option from a list, use the **Multiple Choice (Radio)** input type instead. +If you want users to select only one option from a list, use the **Single choice** input type instead. ::: diff --git a/docs/input-types/multiplechoice.md b/docs/input-types/multiplechoice.md index 1ef81308..7e7e4a4b 100644 --- a/docs/input-types/multiplechoice.md +++ b/docs/input-types/multiplechoice.md @@ -1,10 +1,10 @@ -# Multiple Choice 🔘 +# Single choice 🔘 This input type allows users to select one option from a list of choices, perfect for questions with mutually exclusive answers. ## How to use: -1. Choose **Multiple Choice (Radio)** as the input type +1. Choose **Single choice** as the input type 2. Enter the question or prompt 3. Add your answer options, one per line 4. Toggle the "Required" option if necessary @@ -28,7 +28,7 @@ This input type allows users to select one option from a list of choices, perfec 🌟 Keep options mutually exclusive and limit the number of choices to prevent overwhelming users. Consider adding an "Other" option for unexpected responses, and arrange options in a logical order (alphabetical, numerical, or by relevance). ::: -## When to use Multiple Choice: +## When to use Single choice: - When users should select only one option from a list - For questions with mutually exclusive answers @@ -40,4 +40,4 @@ This input type allows users to select one option from a list of choices, perfec If users might need to select more than one option, use the **Checkboxes** input type instead. ::: -![Multiple Choice Example](/assets/multiplechoice.gif) +![Single choice example](/assets/multiplechoice.gif) diff --git a/docs/input-types/no-interaction.md b/docs/input-types/no-interaction.md index 169035f2..af3f24c7 100644 --- a/docs/input-types/no-interaction.md +++ b/docs/input-types/no-interaction.md @@ -1,10 +1,10 @@ -# Information Block 📝 +# Text only 📝 -The Information Block displays information without requiring any user input. It's perfect for instructions, introductions, explanations, or content that doesn't need a response. +A Text only block displays information without requiring any user input. It's perfect for instructions, introductions, explanations, or content that doesn't need a response. ## How to use: -1. Choose **Information Block** as the block type +1. Choose **Text only** as the block type 2. Enter the content you want to display 3. Format your text using markdown for headings, lists, links, and emphasis 4. Add buttons if you want users to acknowledge the information or make a choice @@ -26,10 +26,10 @@ The Information Block displays information without requiring any user input. It' - **Thank You Messages**: Acknowledgments for participation ::: tip Best Practice -🌟 Keep information blocks concise and visually structured. Use headings, bullet points, and formatting to make content scannable and easy to understand. +🌟 Keep Text only blocks concise and visually structured. Use headings, bullet points, and formatting to make content scannable and easy to understand. ::: -## When to use Information Blocks: +## When to use Text only blocks: - When you need to provide context before asking questions - For introductory or concluding information @@ -38,5 +38,5 @@ The Information Block displays information without requiring any user input. It' - For any informational content that doesn't require user input ::: warning Note -Since Information Blocks don't collect any data, they won't appear in your submission results. They're purely for communication with your users. +Since Text only blocks don't collect any data, they won't appear in your submission results. They're purely for communication with your users. ::: diff --git a/docs/input-types/phone.md b/docs/input-types/phone.md index 5ca4b158..e22fbb09 100644 --- a/docs/input-types/phone.md +++ b/docs/input-types/phone.md @@ -4,7 +4,7 @@ This input type is specifically designed for collecting phone numbers with appro ## How to use: -1. Select **Phone Number** as the input type +1. Select **Phone** as the input type 2. Enter the prompt asking for the phone number 3. Optionally, add a placeholder showing the expected format 4. Configure validation requirements if needed @@ -28,7 +28,7 @@ This input type is specifically designed for collecting phone numbers with appro 🌟 Always specify what the phone number will be used for and include a privacy statement. For international forms, include example formatting or country code instructions. ::: -## When to use Phone Number input: +## When to use Phone input: - When you need to contact users by phone or SMS - For authentication or verification purposes diff --git a/docs/input-types/secret.md b/docs/input-types/secret.md index e75ac30d..737c06d8 100644 --- a/docs/input-types/secret.md +++ b/docs/input-types/secret.md @@ -1,15 +1,15 @@ -# Secret 🔒 +# Hidden text 🔒 -The Secret input type lets you securely collect sensitive information from users. It works like a password field, masking the entered text to protect it from view. +The Hidden text input type lets you securely collect sensitive information from users. It works like a password field, masking the entered text to protect it from view. ## How to use: -1. Choose **Secret** as the input type +1. Choose **Hidden text** as the input type 2. Enter the question or prompt that asks for sensitive information 3. Optionally, add a placeholder text to guide users on what to enter ::: tip Best Practice -< Only use Secret inputs when necessary for sensitive information like passwords, access codes, or PIN numbers. +< Only use Hidden text inputs when necessary for sensitive information like passwords, access codes, or PIN numbers. ::: ## Features: @@ -25,7 +25,7 @@ The Secret input type lets you securely collect sensitive information from users - **Access Codes**: For private invitation codes or tokens - **Private Information**: Any sensitive data that shouldn't be visible on screen -## When to use Secret inputs: +## When to use Hidden text inputs: - When collecting information that should remain private - When users are in public spaces and don't want others to see what they're typing @@ -33,5 +33,5 @@ The Secret input type lets you securely collect sensitive information from users - When compliance or security requirements mandate protected input fields ::: warning Security Note -While Secret inputs hide information on screen, remember that this is a visual protection only. For truly sensitive information, ensure you have proper encryption and security measures in place for data transmission and storage. +While Hidden text inputs hide information on screen, remember that this is a visual protection only. For truly sensitive information, ensure you have proper encryption and security measures in place for data transmission and storage. ::: diff --git a/docs/quick-start.md b/docs/quick-start.md index 96c8dcd8..a01571f7 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -110,8 +110,8 @@ Once you have Input up and running, follow these steps to create your first form 5. Preview your form to ensure it looks and works as expected 6. Publish your form when it's ready to share -![Multiple Choice Example](/assets/multiplechoice.gif) -_Example: Adding a multiple choice question to your form_ +![Single choice example](/assets/multiplechoice.gif) +_Example: Adding a single choice question to your form_ ## Troubleshooting Common Issues diff --git a/docs/workbench/form-blocks.md b/docs/workbench/form-blocks.md index 5422a717..591a82fa 100644 --- a/docs/workbench/form-blocks.md +++ b/docs/workbench/form-blocks.md @@ -8,20 +8,20 @@ Input offers a variety of block types to gather different kinds of information: ### Information Blocks -- [**Information Block**](/input-types/no-interaction): Displays text without requiring user input, useful for instructions or explanations +- [**Text only**](/input-types/no-interaction): Displays text without requiring user input, useful for instructions or explanations ### Text Input Blocks -- [**Short Text**](/input-types/short-text): For collecting brief responses like names or titles -- [**Long Text**](/input-types/long-text): For gathering detailed responses, comments, or feedback +- [**Short text**](/input-types/short-text): For collecting brief responses like names or titles +- [**Long text**](/input-types/long-text): For gathering detailed responses, comments, or feedback - [**Email**](/input-types/email): Specifically for email addresses, with built-in validation - [**Phone**](/input-types/phone): For phone numbers, with optional formatting - [**Link**](/input-types/link): For collecting website URLs -- [**Secret**](/input-types/secret): For sensitive information like passwords, with masked input +- [**Hidden text**](/input-types/secret): For sensitive information like passwords, with masked input ### Choice Blocks -- [**Multiple Choice**](/input-types/multiplechoice): Allows selection of one option from several choices +- [**Single choice**](/input-types/multiplechoice): Allows selection of one option from several choices - [**Checkboxes**](/input-types/checkboxes): Allows selection of multiple options from a list - [**Scale**](/input-types/scale): Presents a numerical scale (e.g., 1-10) for rating questions - [**Rating**](/input-types/rating): Star-based rating system for satisfaction or quality assessment @@ -30,16 +30,18 @@ Input offers a variety of block types to gather different kinds of information: - [**Number**](/input-types/number): For numerical input with optional min/max validation - [**Date**](/input-types/date): For collecting calendar dates -- [**File Upload**](/input-types/file-upload): Allows users to upload files or images +- [**File upload**](/input-types/file-upload): Allows users to upload files or images - [**Consent**](/input-types/consent): For collecting explicit consent with terms or policies ## Adding Blocks to Your Form To add a new block to your form: -1. Click the "+" button at the bottom of an existing block -2. Choose the block type from the dropdown menu -3. The new block will be inserted below the current block +1. Point between two questions and click "Add question here", or choose "Add question below" in a question's menu +2. Choose the type in the picker, or type to search for it +3. The new question opens right there, ready to type + +To change the type of an empty question, type "/" in its text. ## Block Groups diff --git a/docs/workbench/workbench.md b/docs/workbench/workbench.md index 53cf41eb..dcd6eba3 100644 --- a/docs/workbench/workbench.md +++ b/docs/workbench/workbench.md @@ -23,7 +23,7 @@ The building blocks of your form are called "blocks." Each block represents a qu When you select a block, you can configure its properties in the main workbench area: -1. **Block Type** - Choose the type of interaction (text, multiple choice, etc.) +1. **Block Type** - Choose the type of interaction (text, single choice, etc.) 2. **Question Text** - The main prompt or question for the user 3. **Description** - Optional additional information about the question 4. **Required Setting** - Toggle whether the user must complete this block From 2f9036d1c84b90c55268f14b21a971422ae2828a Mon Sep 17 00:00:00 2001 From: Philipp Reinking Date: Tue, 13 Oct 2026 03:00:25 +0200 Subject: [PATCH 08/14] feat: Add question at the end of the document adds at the end The line, the gutter + and the menu add after a given question. The end button always adds after the last block, also while a question is open. Co-Authored-By: Claude Opus 5.5 --- .../js/components/Factory/Document/FormDocument.test.ts | 5 +++-- .../js/components/Factory/Document/FormDocument.vue | 9 ++------- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/resources/js/components/Factory/Document/FormDocument.test.ts b/resources/js/components/Factory/Document/FormDocument.test.ts index 65384a49..a7909ecf 100644 --- a/resources/js/components/Factory/Document/FormDocument.test.ts +++ b/resources/js/components/Factory/Document/FormDocument.test.ts @@ -89,7 +89,7 @@ describe("the form document", () => { ); }); - it("adds a question after the open one, with the cursor in its text", async () => { + it("adds a question at the end, also with one open, with the cursor in its text", async () => { const added = { id: 3, uuid: "b3", @@ -110,11 +110,12 @@ describe("the form document", () => { .findAll("button") .find((button) => button.text() === "Add question")! .trigger("click"); + expect(wrapper.text()).toContain("Add a question at the end"); await option("Email").trigger("click"); await flushPromises(); expect(useForm().createFormBlock).toHaveBeenCalledWith( - blocks[0], + null, "input-email", null, ); diff --git a/resources/js/components/Factory/Document/FormDocument.vue b/resources/js/components/Factory/Document/FormDocument.vue index 2bc8f4b6..d6da8aa2 100644 --- a/resources/js/components/Factory/Document/FormDocument.vue +++ b/resources/js/components/Factory/Document/FormDocument.vue @@ -61,7 +61,7 @@ icon="plus" icon-position="left" :aria-expanded="addAt?.key === 'end'" - @click="openPicker({ key: 'end', after: workbench.block })" + @click="openPicker({ key: 'end', after: null })" /> @@ -131,7 +127,6 @@ import DocumentOutline from "./DocumentOutline.vue"; import TypePicker from "./TypePicker.vue"; import { addAt, - blockName, closePicker, openPicker, useAddQuestion, From 9b5a38db20dbacb6c63d09ef82bbe5fac32954c1 Mon Sep 17 00:00:00 2001 From: Philipp Reinking Date: Tue, 13 Oct 2026 03:00:27 +0200 Subject: [PATCH 09/14] fix: an empty form doesn't say 0 questions Co-Authored-By: Claude Opus 5.5 --- .../js/components/Factory/Document/FormDocument.test.ts | 1 + resources/js/components/Factory/Document/FormDocument.vue | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/resources/js/components/Factory/Document/FormDocument.test.ts b/resources/js/components/Factory/Document/FormDocument.test.ts index a7909ecf..c7b5225d 100644 --- a/resources/js/components/Factory/Document/FormDocument.test.ts +++ b/resources/js/components/Factory/Document/FormDocument.test.ts @@ -80,6 +80,7 @@ describe("the form document", () => { }); expect(wrapper.text()).toContain("Add your first question"); + expect(wrapper.text()).not.toContain("0 questions"); await option("Short text").trigger("click"); expect(useForm().createFormBlock).toHaveBeenCalledWith( diff --git a/resources/js/components/Factory/Document/FormDocument.vue b/resources/js/components/Factory/Document/FormDocument.vue index d6da8aa2..a1aa8560 100644 --- a/resources/js/components/Factory/Document/FormDocument.vue +++ b/resources/js/components/Factory/Document/FormDocument.vue @@ -24,11 +24,13 @@