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/CHANGELOG.md b/CHANGELOG.md index 80654d07..4b0b3a78 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 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 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/file-upload.md b/docs/input-types/file-upload.md index 2ed4154f..af5bc001 100644 --- a/docs/input-types/file-upload.md +++ b/docs/input-types/file-upload.md @@ -1,10 +1,10 @@ -# File Upload ๐Ÿ“ +# File upload ๐Ÿ“ -The File Upload input type allows users to attach files to their form responses. It supports multiple file types and offers a user-friendly drag-and-drop interface. +The File upload input type allows users to attach files to their form responses. It supports multiple file types and offers a user-friendly drag-and-drop interface. ## How to use: -1. Choose **File Upload** as the input type +1. Choose **File upload** as the input type 2. Enter instructions for what files users should upload 3. Configure the upload settings: - **Maximum Files**: Set how many files users can upload @@ -40,7 +40,7 @@ The File Upload input type allows users to attach files to their form responses. - **Document Collection**: ID verification or signed forms - **Media Sharing**: Audio or video uploads for feedback -## When to use File Upload: +## When to use File upload: - When you need users to submit existing files rather than typing information - For collecting visual or media information that can't be entered as text diff --git a/docs/input-types/long-text.md b/docs/input-types/long-text.md index 7c0d2a8f..ca5e5ad0 100644 --- a/docs/input-types/long-text.md +++ b/docs/input-types/long-text.md @@ -1,10 +1,10 @@ -# Long Text ๐Ÿ“„ +# Long text ๐Ÿ“„ -The Long Text input type provides a multi-line text area for collecting detailed, open-ended responses from users. It's ideal for comments, feedback, descriptions, and any situation where users need to enter more than a brief phrase. +The Long text input type provides a multi-line text area for collecting detailed, open-ended responses from users. It's ideal for comments, feedback, descriptions, and any situation where users need to enter more than a brief phrase. ## How to use: -1. Select **Long Text** as the input type +1. Select **Long text** as the input type 2. Enter the question or prompt 3. Configure the text area options: - **Rows**: Number of visible text rows (1-10, default: 5) @@ -35,7 +35,7 @@ The Long Text input type provides a multi-line text area for collecting detailed ๐Ÿ’ก Set an appropriate character limit based on the expected response length. For short paragraphs, 500 characters is usually sufficient, while detailed feedback might require 1000+ characters. Add clear instructions about the level of detail you're looking for to guide users. ::: -## When to use Long Text: +## When to use Long text: - When you need more than a single line of text - For qualitative, open-ended responses @@ -45,7 +45,7 @@ The Long Text input type provides a multi-line text area for collecting detailed - For feedback that requires elaboration ::: warning Note -While Long Text fields are great for detailed responses, very long responses may be difficult for users to type, especially on mobile devices. For extremely detailed information, consider breaking your questions into smaller, more focused parts. +While Long text fields are great for detailed responses, very long responses may be difficult for users to type, especially on mobile devices. For extremely detailed information, consider breaking your questions into smaller, more focused parts. ::: ## Technical Details: @@ -53,5 +53,5 @@ While Long Text fields are great for detailed responses, very long responses may - Text can include line breaks and paragraphs - The character counter updates in real-time as users type - Maximum storage capacity is virtually unlimited -- Long Text fields cannot include rich formatting (bold, italics, etc.) +- Long text fields cannot include rich formatting (bold, italics, etc.) 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/number.md b/docs/input-types/number.md index 4cdbcb6f..1ac92c3d 100644 --- a/docs/input-types/number.md +++ b/docs/input-types/number.md @@ -45,7 +45,7 @@ The Number input type is specifically designed for collecting numerical data wit - When you want proper numeric formatting (thousands separators) ::: warning Note -Number inputs only accept numeric values. If users might need to add any text or explanations with their numbers, consider using a Short Text input instead. +Number inputs only accept numeric values. If users might need to add any text or explanations with their numbers, consider using a Short text input instead. ::: ## Technical Details: 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/input-types/short-text.md b/docs/input-types/short-text.md index 0dbef05a..1b42b140 100644 --- a/docs/input-types/short-text.md +++ b/docs/input-types/short-text.md @@ -1,10 +1,10 @@ -# Short Text ๐Ÿ’ฌ +# Short text ๐Ÿ’ฌ -The Short Text input type provides a single-line text field for collecting brief responses from users. It's perfect for names, titles, brief answers, and any situation where responses are concise and fit on one line. +The Short text input type provides a single-line text field for collecting brief responses from users. It's perfect for names, titles, brief answers, and any situation where responses are concise and fit on one line. ## How to use: -1. Choose **Short Text** as the input type +1. Choose **Short text** as the input type 2. Enter the question or prompt 3. Configure the input options: - **Placeholder**: Hint text shown when the field is empty @@ -29,10 +29,10 @@ The Short Text input type provides a single-line text field for collecting brief - **Simple Data**: "What city do you live in?" ::: tip Best Practice -๐ŸŒŸ Keep your questions specific to encourage brief responses. For any question where you expect answers might exceed a few words or require multiple sentences, use the Long Text input type instead. +๐ŸŒŸ Keep your questions specific to encourage brief responses. For any question where you expect answers might exceed a few words or require multiple sentences, use the Long text input type instead. ::: -## When to use Short Text: +## When to use Short text: - When you need a brief, concise response - For single-line information collection @@ -42,7 +42,7 @@ The Short Text input type provides a single-line text field for collecting brief - When inputs should be quick and straightforward ::: warning Note -Short Text fields have a hard limit of 100 characters. If users might need more space, consider using a Long Text input instead, which can accommodate more detailed responses. +Short text fields have a hard limit of 100 characters. If users might need more space, consider using a Long text input instead, which can accommodate more detailed responses. ::: ## Technical Details: @@ -50,4 +50,4 @@ Short Text fields have a hard limit of 100 characters. If users might need more - Maximum character limit is fixed at 100 characters - Input is single-line only (pressing Enter submits the form) - Character counter updates in real-time as users type -- Unlike specialized inputs (email, phone, etc.), Short Text has no format validation \ No newline at end of file +- Unlike specialized inputs (email, phone, etc.), Short text has no format validation \ No newline at end of file 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..3510e9a0 100644 --- a/docs/workbench/form-blocks.md +++ b/docs/workbench/form-blocks.md @@ -6,22 +6,22 @@ Form blocks are the fundamental building components of any form in Input. Each b Input offers a variety of block types to gather different kinds of information: -### Information Blocks +### Content 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 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 230b0bfe..af61706d 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 { 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/DocumentGroup.vue b/resources/js/components/Factory/Document/DocumentGroup.vue index 36a11dd4..e3dc636a 100644 --- a/resources/js/components/Factory/Document/DocumentGroup.vue +++ b/resources/js/components/Factory/Document/DocumentGroup.vue @@ -67,6 +67,13 @@

+ @@ -76,7 +83,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"; import { useMoveBlock } from "./blockMove"; const props = defineProps<{ @@ -87,7 +95,7 @@ const props = defineProps<{ const store = useForm(); const workbench = useWorkbench(); const logic = useLogic(); -const { addBlock } = useAddBlock(); +const { addQuestion: add } = useAddQuestion(); const { onMoveKey } = useMoveBlock(); const id = computed(() => `group-${props.group.uuid}`); @@ -114,9 +122,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 f1a53f17..c369d7ae 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 }, @@ -45,7 +48,7 @@ describe("a closed question in the document", () => { }); 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?"); @@ -59,7 +62,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"); @@ -70,13 +73,13 @@ 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); }); it("moves with Alt+Up/Down, closed or open", async () => { diff --git a/resources/js/components/Factory/Document/DocumentQuestion.vue b/resources/js/components/Factory/Document/DocumentQuestion.vue index df54b1a7..f4a994bc 100644 --- a/resources/js/components/Factory/Document/DocumentQuestion.vue +++ b/resources/js/components/Factory/Document/DocumentQuestion.vue @@ -10,13 +10,25 @@ class="flex h-6 items-center text-[13px] tabular-nums text-grey-600" >{{ number }} - + +
+ + +
@@ -73,6 +85,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"; import { useMoveBlock } from "./blockMove"; const props = defineProps<{ diff --git a/resources/js/components/Factory/Document/FormDocument.test.ts b/resources/js/components/Factory/Document/FormDocument.test.ts index 847160e0..42794653 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"; import { moveNote } from "./blockMove"; const blocks = [ @@ -17,6 +19,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", () => { @@ -30,6 +34,8 @@ describe("the form document", () => { scrolled.mockClear(); useForm().$reset(); useWorkbench().$reset(); + addAt.value = null; + cursorFor.value = null; }); afterEach(() => wrapper?.unmount()); @@ -65,6 +71,90 @@ describe("the form document", () => { 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"); + expect(wrapper.text()).not.toContain("0 questions"); + await option("Short text").trigger("click"); + + expect(useForm().createFormBlock).toHaveBeenCalledWith( + null, + "input-short", + null, + ); + }); + + it("adds a question at the end, also with one open, 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"); + expect(wrapper.text()).toContain("Add a question at the end"); + await option("Email").trigger("click"); + await flushPromises(); + + expect(useForm().createFormBlock).toHaveBeenCalledWith( + null, + "input-email", + null, + ); + expect(cursorFor.value).toBe("b3"); + }); + + it("gives the cursor back to the end button after Esc, but not after a click elsewhere", async () => { + useForm().blocks = blocks; + wrapper = mount(FormDocument, { + props: { status: "loaded" }, + global: { stubs }, + attachTo: document.body, + }); + const button = wrapper + .findAll("button") + .find((item) => item.text() === "Add question")!; + const open = async () => { + (button.element as HTMLElement).focus(); + await button.trigger("click"); + }; + + // a click into the open question's text keeps the cursor there + const text = document.createElement("textarea"); + document.body.append(text); + await open(); + text.focus(); + text.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await flushPromises(); + expect(addAt.value).toBeNull(); + expect(document.activeElement).toBe(text); + text.remove(); + + await open(); + await wrapper.find("input").trigger("keydown", { key: "Escape" }); + expect(addAt.value).toBeNull(); + expect(document.activeElement).toBe(button.element); + }); + it("reads out where a moved question went", async () => { useForm().blocks = blocks; wrapper = mount(FormDocument, { diff --git a/resources/js/components/Factory/Document/FormDocument.vue b/resources/js/components/Factory/Document/FormDocument.vue index fe991a6d..a1aa8560 100644 --- a/resources/js/components/Factory/Document/FormDocument.vue +++ b/resources/js/components/Factory/Document/FormDocument.vue @@ -24,11 +24,13 @@ @@ -103,11 +122,17 @@ 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, + closePicker, + openPicker, + useAddQuestion, +} from "./addQuestion"; import { questionNumbers } from "./questionSummary"; import { moveNote } from "./blockMove"; import { @@ -125,6 +150,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/QuestionCard.test.ts b/resources/js/components/Factory/Document/QuestionCard.test.ts index 66914e25..4b1040f5 100644 --- a/resources/js/components/Factory/Document/QuestionCard.test.ts +++ b/resources/js/components/Factory/Document/QuestionCard.test.ts @@ -151,4 +151,56 @@ describe("an open question", () => { ).toBe(true); expect(document.activeElement).toBe(settingsButton().element); }); + + describe("typing / in its text", () => { + const focus = vi.fn(); + 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("gives the cursor back after Esc, but not after a click elsewhere", async () => { + await slash(null); + focus.mockClear(); + + // the click's target takes the focus; tiptap would take it back a frame later + document.body.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await flushPromises(); + expect(wrapper!.find("[role=listbox]").exists()).toBe(false); + expect(focus).not.toHaveBeenCalled(); + + await wrapper! + .find("[contenteditable]") + .trigger("keydown", { key: "/" }); + await wrapper!.find("input").trigger("keydown", { key: "Escape" }); + expect(focus).toHaveBeenCalledOnce(); + }); + + 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 a81c38ef..99c96c78 100644 --- a/resources/js/components/Factory/Document/QuestionCard.vue +++ b/resources/js/components/Factory/Document/QuestionCard.vue @@ -5,7 +5,20 @@ Question {{ number }} text - + + @@ -130,7 +143,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; @@ -172,6 +187,28 @@ 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 = (returnFocus = true) => { + typesOpen.value = false; + + if (returnFocus) { + 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/Document/TypePicker.test.ts b/resources/js/components/Factory/Document/TypePicker.test.ts new file mode 100644 index 00000000..f40d71ab --- /dev/null +++ b/resources/js/components/Factory/Document/TypePicker.test.ts @@ -0,0 +1,110 @@ +import { mount, VueWrapper } from "@vue/test-utils"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import TypePicker from "./TypePicker.vue"; + +let wrapper: VueWrapper | undefined; + +const mountPicker = (large = true) => { + window.matchMedia = vi.fn().mockReturnValue({ + matches: large, + addEventListener: () => {}, + removeEventListener: () => {}, + }); + 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"); + expect(options()[1].classes()).toContain("ring-2"); + + await input.trigger("keydown", { key: "Enter" }); + expect(wrapper!.emitted("pick")).toEqual([["input-long"]]); + }); + + it("leaves the search to a tap on a phone, where the keyboard would cover the list", () => { + mountPicker(false); + + expect(document.activeElement?.textContent?.trim()).toBe( + "Add a question", + ); + expect(options()[0].attributes("aria-selected")).toBe("true"); + expect(options()[0].classes()).not.toContain("ring-2"); + }); + + 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..e733d64a --- /dev/null +++ b/resources/js/components/Factory/Document/TypePicker.vue @@ -0,0 +1,179 @@ + + + diff --git a/resources/js/components/Factory/Document/addQuestion.ts b/resources/js/components/Factory/Document/addQuestion.ts new file mode 100644 index 00000000..d2833bda --- /dev/null +++ b/resources/js/components/Factory/Document/addQuestion.ts @@ -0,0 +1,67 @@ +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(returnFocus = true) { + const spot = addAt.value; + addAt.value = null; + + // a click elsewhere leaves the focus where the click put it + if (!returnFocus) { + return; + } + + 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 }; +} 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: { 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(