diff --git a/.vitepress/config.mts b/.vitepress/config.mts
index d1dee7d2..5c660bed 100644
--- a/.vitepress/config.mts
+++ b/.vitepress/config.mts
@@ -29,8 +29,8 @@ export default defineConfig({
text: "Managing Forms",
link: "/workbench/managing-forms",
},
- { text: "Workbench", link: "/workbench/workbench" },
- { text: "Storyboard", link: "/workbench/storyboard" },
+ { text: "Form Editor", link: "/workbench/workbench" },
+ { text: "Outline", link: "/workbench/storyboard" },
{ text: "Form Blocks", link: "/workbench/form-blocks" },
{
text: "Conditional Logic",
@@ -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 9b3a9421..bfc10c03 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,9 @@ 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 or its Identifier, so links that fill in answers and webhooks can't mix the two up. The copy goes right after the original. (#262)
- The public form API has a preview mode for the form's team: start a session with `preview: true`, also for 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)
+- The form editor shows the end screen as the last block of your form. You can edit its headline, text and button right there, and the End screen tab in Settings shows the same values. (#271)
+- "Preview" in the editor shows your form beside the questions, on tablet and phone as a panel. It follows the question you edit, and the editor follows when you click Next in it. On the other form pages, "Preview" opens your form in a new tab in preview mode. Answers in either preview aren't saved. (#273)
### Changed
@@ -23,6 +26,11 @@ Lines marked **Upgrade step** need you to act after updating.
- The form list loads faster when a team has many forms. It now needs the same few database queries for one form or a hundred. Before, every form added 3 queries. (#256)
- On screens narrower than 1280 px, a form's editor bar now sits in two rows under the app header: the form with Dashboard and Preview, then Editor, Settings, Integrations and Submissions as tabs. Nothing in it scrolls sideways any more, and the header keeps the logo and your team name. On every screen, keyboard focus in the editor bar and the account menu now shows a blue ring. (#257)
- A form's pages have one top bar instead of two: the form's name with its menu, a Draft or Live label, the pages, Preview, Publish and your account. Publish first checks for questions without text, rules that point to a deleted question and a missing privacy link. Once the form is live, Share has its link, Open form and Unpublish. On a phone the pages sit in a tab bar at the bottom. (#265)
+- The form editor shows your whole form as one column. Each question shows its number, answer type, text, and if it is required, has rules or is hidden. The question you work on opens right there, also on a phone, where you couldn't edit questions before. Hiding a question, random order and custom answers are now under the question's Settings. An outline next to the form jumps to any question, and on a phone a question's Settings open from the bottom of the screen. Move a question with Alt+Up/Down, with Move up and Move down in its menu, or by dragging. Duplicate in a question's menu puts a copy right below it. (#268, #272, #275, #277)
+- Each rule now shows as a sentence under its question, and a group says when it's hidden. Edit changes a rule right there, and for choices, ratings and scales you pick the answer instead of typing it. A rule whose question was deleted shows in red and can't be saved until you fix it. (#274)
+- The form editor uses plain words: a question's settings are headed by its answer type, like Choices or Rating, "Identifier" is now "Short name", and every switch shows on or off. Settings name what they do, like "Lowest value", "Shuffle choices" and "None ticked: all types allowed." on file uploads. (#279)
+- The editor's preview shows the end screen while you edit it, with your latest text, and says so when your form sends people to a link instead. Its button opens in a new tab there, so you stay in the editor, and typing in a question you just opened no longer goes into the preview. On a tablet or phone, Esc closes the preview even after you clicked into it. Settings now calls the Completion Page "End screen", like the editor. (#280)
+- In the form editor a thin line marks where each question starts, its number stands out more, and the question you edit gets more room around it. On a phone the editor keeps a wider margin at the sides. (#281)
- A logic rule with a missing field now tells you in plain words what to fill in, like "Pick a question for condition 1." (#278)
- Deleting a question, a group, an answer choice, a consent policy or a logic rule in the form editor now asks first in the app's own dialog. It names the question, how many answers will be deleted with it, and the questions whose rules will stop working. A choice nobody picked, or a policy nobody agreed to, is still deleted at once. (#269)
diff --git a/docs/embedding-forms.md b/docs/embedding-forms.md
index 306a3109..ba20040d 100644
--- a/docs/embedding-forms.md
+++ b/docs/embedding-forms.md
@@ -115,13 +115,13 @@ https://app.getinput.co/form-uuid?iframe=1&hideTitle=1&alignLeft=1
## Prefill Questions
-You can fill in answers with URL parameters. The parameter name is the question's Identifier (open the question, then "More Settings"). For example, this fills the question with the Identifier `email`:
+You can fill in answers with URL parameters. The parameter name is the question's short name (open the question, then **Settings**, then **Short name**). For example, this fills the question with the short name `email`:
```
https://app.getinput.co/form-uuid?email=ada@example.com
```
-- Works for short text, long text, email, number, phone and link questions. Parameters for other question types or unknown Identifiers are ignored.
+- Works for short text, long text, email, number, phone and link questions. Parameters for other question types or unknown short names are ignored.
- The question still shows, so the visitor can check and change the answer. A question skipped by a jump doesn't send its prefilled answer.
- The value shows as plain text. Encode special characters: `%2B` for `+`, `%20` for a space.
- Number questions take digits with a dot as decimal separator, like `price=12.5`, rounded to the question's decimal places. Anything else is ignored.
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/consent.md b/docs/input-types/consent.md
index 00830474..8dbb3012 100644
--- a/docs/input-types/consent.md
+++ b/docs/input-types/consent.md
@@ -7,7 +7,7 @@ The Consent input type is designed specifically for gathering agreement from use
1. Choose **Consent** as the input type
2. Enter a clear message explaining what the user is consenting to
3. Configure the consent option with a concise label
-4. Toggle "Opt-In Required" if the consent is mandatory to proceed
+4. Turn on "Must be ticked" if people must agree to go on
::: tip Best Practice
< Keep consent messages clear and specific. Explain exactly what users are agreeing to, and include links to relevant policies when appropriate.
diff --git a/docs/input-types/date.md b/docs/input-types/date.md
index 328c8701..deb97f7c 100644
--- a/docs/input-types/date.md
+++ b/docs/input-types/date.md
@@ -7,9 +7,9 @@ The Date input type allows users to select dates from a calendar interface, maki
1. Choose **Date** as the input type
2. Enter the question or prompt asking for a date
3. Configure the date restrictions (optional):
- - **Minimum Date**: Earliest date users can select
- - **Maximum Date**: Latest date users can select
- - **No Past Dates**: Toggle to prevent selection of dates before today
+ - **Min date**: Earliest date users can select
+ - **Max date**: Latest date users can select
+ - **No dates in the past**: Turn on to prevent selection of dates before today
::: tip Best Practice
< Always specify what kind of date you're asking for (appointment date, birth date, event date, etc.) and the expected format in your prompt.
diff --git a/docs/input-types/file-upload.md b/docs/input-types/file-upload.md
index 2ed4154f..9656032b 100644
--- a/docs/input-types/file-upload.md
+++ b/docs/input-types/file-upload.md
@@ -1,15 +1,15 @@
-# 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
- - **Maximum File Size**: Limit file size (in MB)
- - **Allowed File Types**: Choose which file formats are accepted
+ - **Most files**: How many files users can upload (1 to 10)
+ - **Largest file (MB)**: Up to 8 MB per file
+ - **File types**: Choose which file formats are accepted. None ticked: all types allowed
::: tip Best Practice
< Be specific about what files you're looking for and any requirements (format, size, resolution, etc.) to avoid confusion and unsuccessful uploads.
@@ -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..7f88b859 100644
--- a/docs/input-types/long-text.md
+++ b/docs/input-types/long-text.md
@@ -1,14 +1,14 @@
-# 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)
- - **Maximum Characters**: Limit on response length (default: 500)
+ - **Height in lines**: How many lines the field shows (1 to 10, default: 5)
+ - **Max characters**: Limit on response length (default: 500)
- **Placeholder**: Hint text shown when the field is empty
4. Toggle the "Required" option if necessary
@@ -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..52615a8d 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
@@ -14,7 +14,7 @@ This input type allows users to select one option from a list of choices, perfec
- **Single Selection**: Users can select exactly one option
- **Visual Clarity**: Clear indication of which option is selected
- **Option Ordering**: Arrange options in a meaningful sequence
-- **Randomization**: Option to randomize choice order to reduce bias (if available)
+- **Shuffle choices**: Show the choices in a random order to reduce bias
## Example Uses:
@@ -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.
:::
-
+
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..85f66709 100644
--- a/docs/input-types/number.md
+++ b/docs/input-types/number.md
@@ -7,8 +7,8 @@ The Number input type is specifically designed for collecting numerical data wit
1. Choose **Number** as the input type
2. Enter the question or prompt
3. Configure the number options:
- - **Decimal Places**: How many decimal digits to allow (0-10)
- - **Symbol**: Optional character(s) to display before the number (e.g., $, β¬, %)
+ - **Decimal places**: How many decimal digits to allow (0-10). Leave empty for whole numbers only
+ - **Symbol**: Shown next to the number, e.g. β¬ or kg
- **Placeholder**: Hint text to show when the field is empty
4. Toggle the "Required" option if necessary
@@ -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/rating.md b/docs/input-types/rating.md
index d9291023..a167d5dc 100644
--- a/docs/input-types/rating.md
+++ b/docs/input-types/rating.md
@@ -7,10 +7,10 @@ The Rating input type allows users to provide feedback on a visual scale using i
1. Choose **Rating** as the input type
2. Enter the prompt for what's being rated
3. Configure the rating options:
- - **Min Rating**: Starting point for the scale (default: 1)
- - **Max Rating**: Maximum value on the scale (default: 5)
- - **Left Label**: Optional text at the start of the scale (e.g., "Poor")
- - **Right Label**: Optional text at the end of the scale (e.g., "Excellent")
+ - **Lowest value**: Starting point for the scale (default: 1)
+ - **Highest value**: Maximum value on the scale (default: 5)
+ - **Label for lowest**: Optional text at the start of the scale (e.g., "Poor")
+ - **Label for highest**: Optional text at the end of the scale (e.g., "Excellent")
- **Icon**: Choose from stars, hearts, trophies, and other symbols
- **Color**: Customize the color of the rating icons
4. Toggle the "Required" option if necessary
diff --git a/docs/input-types/scale.md b/docs/input-types/scale.md
index a2c53c4b..070d6f70 100644
--- a/docs/input-types/scale.md
+++ b/docs/input-types/scale.md
@@ -7,10 +7,10 @@ The Scale input type presents users with a numerical range from which they can s
1. Select **Scale** as the input type
2. Enter the question or prompt
3. Configure the scale options:
- - **Min Rating**: Starting number for the scale (default: 1)
- - **Max Rating**: Ending number for the scale (default: 5)
- - **Left Label**: Text description for the low end of the scale (e.g., "Strongly Disagree")
- - **Right Label**: Text description for the high end of the scale (e.g., "Strongly Agree")
+ - **Lowest value**: Starting number for the scale (default: 1)
+ - **Highest value**: Ending number for the scale (default: 5)
+ - **Label for lowest**: Text description for the low end of the scale (e.g., "Strongly Disagree")
+ - **Label for highest**: Text description for the high end of the scale (e.g., "Strongly Agree")
4. Toggle the "Required" option if necessary
## Features:
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/managing-submissions.md b/docs/managing-submissions.md
index 168d213d..8e15a618 100644
--- a/docs/managing-submissions.md
+++ b/docs/managing-submissions.md
@@ -28,7 +28,7 @@ The Table View displays each individual submission in a tabular format:
The Summary View provides an aggregated analysis of your submissions:
-- For multiple choice questions: shows response distributions with visual graphs
+- For single choice and checkbox questions: shows response distributions with visual graphs
- For text inputs: displays common responses
- For numerical inputs: shows average values and ranges
- For rating scales: presents average ratings with visual indicators
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
-
-_Example: Adding a multiple choice question to your form_
+
+_Example: Adding a single choice question to your form_
## Troubleshooting Common Issues
diff --git a/docs/workbench/conditional-logic.md b/docs/workbench/conditional-logic.md
index 3a938cce..b40ca2a3 100644
--- a/docs/workbench/conditional-logic.md
+++ b/docs/workbench/conditional-logic.md
@@ -14,8 +14,8 @@ Conditional logic lets you control the flow of your form based on how users resp
To add logic to any block in your form:
-1. Select the block you want to apply logic to
-2. Click the **Block Logic** button in the block footer
+1. Open the question you want to apply logic to (for a group: use its header)
+2. Click **Rules** at the bottom of the question
3. In the slide-out panel that appears, click the **Rule** button to add a new rule
4. Give your rule a descriptive name (e.g., "Skip to payment if premium selected")
5. Configure your conditions and actions (detailed below)
@@ -73,9 +73,7 @@ Go to logic is evaluated after a user interacts with the current block. When sel
## Logic Visualization
-Blocks with logic rules applied are indicated by a blue highlight on the **Block Logic** button. If a block has multiple rules, a counter will show the number of applied rules (e.g., "Block Logic (2)").
-
-When viewing the Storyboard with logic visualization enabled, you can see a visual representation of all logic rules, making it easier to understand your form's conditional flow.
+In the editor, the line below each question shows how many rules it has (e.g., "2 rules"), and its **Rules** button shows the same count (e.g., "Rules (2)").
## Best Practices
diff --git a/docs/workbench/form-blocks.md b/docs/workbench/form-blocks.md
index 5422a717..d9603479 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,21 +30,23 @@ 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. Click **Add question** at the end of the form, 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, with the cursor in its text
+
+To change the type of an empty question, type "/" in its text.
## Block Groups
Organize related blocks together:
-1. **Creating a Group**: Select multiple blocks and click "Group"
-2. **Group Settings**: Add a group title and description
-3. **Collapsing/Expanding**: Control group visibility in the editor
+1. **Creating a Group**: Click **Add group** at the end of the form and give it a name
+2. **Filling a Group**: Use **Add question to this group** in the group's menu, or drag questions into it
+3. **Hiding a Group**: **Hide group** in its menu hides all its questions at once, and a rule on the group shows or hides them together
diff --git a/docs/workbench/storyboard.md b/docs/workbench/storyboard.md
index 4dc4472f..3097bc21 100644
--- a/docs/workbench/storyboard.md
+++ b/docs/workbench/storyboard.md
@@ -1,46 +1,16 @@
-# Storyboard: Form Block Overview
+# Outline
-The Storyboard is the sidebar component of the Workbench that provides a scrollable overview of all the blocks in your form. It helps you navigate through your form blocks quickly and efficiently.
+The outline is a short list of your form's questions and groups. Use it to jump to a question in a long form. It replaces the Storyboard of earlier versions.
-## What is the Storyboard?
+## Where to find it
-The Storyboard is essentially a compact representation of your form structure that:
+- **Wide screens (1280 px and up)** - The outline sits on the left of the editor
+- **Tablets and phones** - Click **Outline** above the questions to open it as a panel. Close it with the close button or Escape
-- Shows all blocks in your form in sequential order
-- Allows quick navigation between blocks
-- Provides a way to reorder blocks through drag and drop
-- Displays visual indicators for blocks with conditional logic
+## Using the outline
-## Using the Storyboard
+- Each question shows its number and the start of its text. Groups show their name, with their questions below
+- Click a question to open it in the document, scrolled into view, with the cursor in its text
+- The open question is marked, and the outline keeps it in view
-### Navigating Your Form
-
-The Storyboard makes it easy to move through your form:
-
-1. **Scrolling**: Scroll up and down to see all the blocks in your form
-2. **Block Selection**: Click on any block to select it and display its properties in the main Workbench area
-3. **Visual Overview**: Quickly see your entire form structure without having to navigate through the form itself
-
-### Managing Blocks
-
-The Storyboard provides essential block management capabilities:
-
-1. **Reordering**: Drag and drop blocks to change their order in the form
-2. **Adding Blocks**: Use the "+" button at the bottom of blocks to add new blocks
-3. **Removing Blocks**: Select a block and use the delete option to remove it
-4. **Block Status**: See visual indicators for required fields or blocks with conditional logic
-
-### Logic Indicators
-
-When you've added conditional logic to blocks, the Storyboard helps you keep track:
-
-- Blocks with conditional logic have visual indicators
-- You can quickly identify which blocks have special behavior
-
-### Form Organization
-
-For forms with multiple questions, the Storyboard helps you:
-
-- Keep track of the overall structure
-- Ensure a logical question sequence
-- Easily navigate to specific sections
+To add, move or delete questions, use the document itself. See [The Form Editor](./workbench.md).
diff --git a/docs/workbench/workbench.md b/docs/workbench/workbench.md
index 53cf41eb..8103406b 100644
--- a/docs/workbench/workbench.md
+++ b/docs/workbench/workbench.md
@@ -1,47 +1,51 @@
-# Workbench Overview
+# The Form Editor
-The Workbench is the core interface of Input where you build and design your forms. It provides a powerful, intuitive environment for creating engaging form experiences without requiring any coding knowledge.
+The editor is where you build your form. It shows the whole form as one document: every question in order, with groups as sections. You edit a question right where it sits in the form, on a computer, a tablet or a phone.
-## Workbench Layout
+## Layout
-The Workbench is divided into several key areas:
+1. **Top bar** - The form's name and menu, Draft or Live, the form's pages (Editor, Settings, Integrations, Submissions), the save status, Preview and Publish
+2. **Outline** - A short list of all questions and groups to jump to one. On wide screens it sits on the left; on smaller screens the **Outline** button opens it as a panel. See [Outline](./storyboard.md)
+3. **Document** - Your questions in the order people answer them
-1. **Main Workbench Area** - The central area where you can see and interact with the currently selected form block
-2. **Sidebar** - Contains a list of all form blocks and tools for adding new blocks
-3. **Top Navigation** - Access to form settings, preview, and publishing options
+## Reading the document
-## Working with Blocks
+Each question shows:
-The building blocks of your form are called "blocks." Each block represents a question, content, or interaction in your form:
+- Its number and an icon for its answer type
+- Its text, on up to two lines
+- A line below with the answer type, the number of choices, **Required**, the number of rules, **Hidden**, and its short name if you gave it one
-1. **Adding Blocks** - Click the "+" button at the bottom of any block to add a new block below it
-2. **Selecting Blocks** - Click on any block in the sidebar to select it and edit its properties in the main workbench area
-3. **Rearranging Blocks** - Drag and drop blocks in the sidebar to reorder them in your form
-4. **Deleting Blocks** - Select a block and click the trash icon to remove it
+Groups show their name with their questions inside. An empty group says how to add a question to it.
-## Block Configuration
+## Editing a question
-When you select a block, you can configure its properties in the main workbench area:
+Click a question, or move to it with Tab and press Enter, to open it in place. The open question shows:
-1. **Block Type** - Choose the type of interaction (text, multiple 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
-5. **Advanced Settings** - Access additional options specific to each block type
+1. **Text** - The question with a toolbar for bold, links, images, headings, lists and more
+2. **Choices** - For single choice and checkboxes. Each row has a grip to drag it and a delete button. Alt+Up and Alt+Down move a choice
+3. **Answer type** - Change what kind of answer the question takes
+4. **Required** - Whether people must answer it
+5. **Settings** - The settings of this answer type under its own heading (for example **Choices** or **Rating**), then **Short name** and **Hide this question**. On a phone they open as a sheet from the bottom
+6. **Rules** - Logic rules for this question, see [Conditional Logic](./conditional-logic.md)
+7. **Menu (β¦)** - Duplicate, Add question below, Move up, Move down, Copy question ID and Delete
-## Preview Mode
+Press Escape to close the question. A link with `?block=` opens that question and scrolls it into view.
-To see how your form will appear to users:
+## Adding, moving and deleting
-1. Click the "Preview" button in the top navigation
-2. A new browser tab will open showing your form as users will see it
-3. Navigate through your form as a user would
-4. Test interactions, conditional logic, and overall flow
-5. Close the preview tab to return to the Workbench
+- **Add** - Use **Add question** or **Add group** at the end of the form, or **Add question below** in a question's menu
+- **Duplicate** - The copy goes right below the question and opens
+- **Move** - Drag a question by its grip; the page scrolls when you drag near its edge. With the keyboard, press Alt+Up or Alt+Down. On touch screens, use **Move up** and **Move down** in the menu. A screen reader hears the new position, for example "Moved to position 3 of 34"
+- **Delete** - The editor asks first. It names the question, how many answers will be deleted with it, and the questions whose rules will stop working
-## Auto-Save
+## Preview
-Input automatically saves your work as you make changes, so you never have to worry about losing progress. The saving status is indicated in the top navigation bar.
+Click **Preview** in the top bar to open your form in a new tab, as people will see it. Test the questions, the rules and the flow, then close the tab to return to the editor.
+
+## Saving
+
+Every change saves on its own. The top bar shows "Savingβ¦", then "Saved" for a moment. When a change can't be saved, it says "Not saved" and offers **Retry**. On smaller screens only the icon shows.
## Published vs. Unpublished Forms
@@ -50,6 +54,6 @@ Forms in Input can be either published or unpublished:
- **Published** - The form is visible to the public and can receive submissions
- **Unpublished** - The form is only visible to you and team members with access
-When you make changes to a form in the Workbench, these changes are saved immediately. If the form is published, these changes will be visible to users right away. There is no separate draft state - any changes you make to a published form will be live immediately.
+When you make changes to a form in the editor, these changes are saved immediately. If the form is published, these changes will be visible to users right away. There is no separate draft state - any changes you make to a published form will be live immediately.
-You can toggle a form between published and unpublished states using the publish/unpublish controls in the form settings.
\ No newline at end of file
+Use **Publish** in the top bar to publish a form. Once it is live, **Share** has its link, Open form and Unpublish.
diff --git a/resources/css/_touch-fields.css b/resources/css/_touch-fields.css
new file mode 100644
index 00000000..360e2527
--- /dev/null
+++ b/resources/css/_touch-fields.css
@@ -0,0 +1,33 @@
+/* The form editor's question card below lg: every field 44 px tall with
+ 16 px text (iOS zooms into a focused field under 16 px), and a 44 px tap
+ area on switches and checkboxes. One rule here instead of a class on
+ every D9 field in the Configure* parts. */
+@media (max-width: 1023px) {
+ .touch-fields
+ :is(
+ input:not([type="checkbox"], [type="radio"]),
+ textarea,
+ button[aria-haspopup="listbox"]
+ ) {
+ min-height: 2.75rem;
+ font-size: 1rem;
+ }
+
+ .touch-fields [role="option"] {
+ padding-block: 0.625rem;
+ }
+
+ /* D9Switch is relative and 24 px tall */
+ .touch-fields [role="switch"]::before {
+ content: "";
+ position: absolute;
+ inset: -10px 0;
+ }
+
+ /* D9Checkbox: its label covers the whole 20 px row, stretched to 44 px */
+ .touch-fields input[type="checkbox"] + div > label::before {
+ content: "";
+ position: absolute;
+ inset: -12px 0;
+ }
+}
diff --git a/resources/css/app.css b/resources/css/app.css
index b2e81f72..4cb71eee 100644
--- a/resources/css/app.css
+++ b/resources/css/app.css
@@ -12,6 +12,7 @@
@import "./_tooltip.css";
@import "./_typography.css";
@import "./_tiptap.css";
+@import "./_touch-fields.css";
@import "tailwindcss/utilities";
diff --git a/resources/js/Layouts/AppLayout.test.ts b/resources/js/Layouts/AppLayout.test.ts
index b0d1821d..f6b89efe 100644
--- a/resources/js/Layouts/AppLayout.test.ts
+++ b/resources/js/Layouts/AppLayout.test.ts
@@ -64,7 +64,7 @@ describe("app header", () => {
);
});
- it("links a draft's bar to its form in a new tab, until the live preview fills the slot", async () => {
+ it("links a draft's bar to its form's preview in a new tab, where no live preview fills the slot", async () => {
window.route = ((name: string) => `/${name}`) as typeof window.route;
// the real bar, without its menus
const wrapper = mount(AppLayout, {
@@ -86,7 +86,8 @@ describe("app header", () => {
await wrapper.vm.$nextTick();
const preview = wrapper.get("header a[target='_blank']");
- expect(preview.attributes("href")).toBe("/forms.show");
+ // in preview mode: no answers are stored
+ expect(preview.attributes("href")).toBe("/forms.show?preview=1");
expect(preview.get("span").text()).toBe("Preview");
expect(preview.text()).toContain("(opens in a new tab)");
// on phones it's in the form menu: the name keeps its room
diff --git a/resources/js/Layouts/AppLayout.vue b/resources/js/Layouts/AppLayout.vue
index c8e59193..fe52d00f 100644
--- a/resources/js/Layouts/AppLayout.vue
+++ b/resources/js/Layouts/AppLayout.vue
@@ -4,7 +4,9 @@
-
+
diff --git a/resources/js/Pages/Forms/Edit.test.ts b/resources/js/Pages/Forms/Edit.test.ts
index 698a0c87..00704f77 100644
--- a/resources/js/Pages/Forms/Edit.test.ts
+++ b/resources/js/Pages/Forms/Edit.test.ts
@@ -1,8 +1,27 @@
import { flushPromises, mount, VueWrapper } from "@vue/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { AxiosResponse } from "axios";
+import { callGetForm } from "@/api/forms";
import Edit from "./Edit.vue";
+import FormMenu from "@/components/Factory/TopBar/FormMenu.vue";
+import PreviewFrame from "@/components/Factory/Preview/PreviewFrame.vue";
import { useForm, useWorkbench } from "@/stores";
+vi.mock("@/api/forms", async (importOriginal) => ({
+ ...(await importOriginal()),
+ callGetForm: vi.fn(),
+}));
+
+// the headlessui dialog and menu need it, jsdom has none
+vi.stubGlobal(
+ "ResizeObserver",
+ class {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ },
+);
+
const form = { uuid: "form1", name: "Customer feedback" } as FormModel;
const blocks = [
{ id: 1, uuid: "b1", type: "none" },
@@ -11,20 +30,31 @@ const blocks = [
const stubs = {
AppLayout: { props: ["title"], template: " " },
- BlockContainer: { template: "questions " },
- BlockLogicEditor: true,
- Workbench: true,
+ DocumentList: { template: "questions " },
+ FormPreview: {
+ props: ["modelValue"],
+ template: "",
+ },
};
let wrapper: VueWrapper | undefined;
// the test pinia stubs actions: getBlocks does what each test tells it to
-const mountEditor = async (load: () => Promise) => {
+const mountEditor = async (
+ load: () => Promise,
+ global: { stubs?: object; mocks?: object } = { stubs },
+) => {
vi.mocked(useForm().getBlocks).mockImplementation(load);
- wrapper = mount(Edit, { props: { form }, global: { stubs } });
+ wrapper = mount(Edit, {
+ props: { form },
+ global,
+ attachTo: document.body,
+ });
await flushPromises();
return wrapper;
};
+const focused = () =>
+ document.activeElement?.closest("button")?.textContent?.trim();
const loads = (result: FormBlockModel[]) => async () => {
useForm().blocks = result;
};
@@ -45,8 +75,7 @@ describe("editor loading", () => {
const editor = await mountEditor(() => new Promise(() => {}));
expect(editor.get("[role=status]").text()).toBe("Loading questions");
- expect(editor.text()).not.toContain("Welcome to the Editor");
- expect(editor.text()).not.toContain("No blocks found");
+ expect(editor.text()).not.toContain("Add your first question");
expect(editor.find("[role=alert]").exists()).toBe(false);
});
@@ -56,8 +85,7 @@ describe("editor loading", () => {
const alert = editor.get("[role=alert]");
expect(alert.text()).toContain("Couldn't load the questions.");
- expect(editor.text()).not.toContain("Welcome to the Editor");
- expect(editor.text()).not.toContain("No blocks found");
+ expect(editor.text()).not.toContain("Add your first question");
getBlocks.mockImplementation(loads(blocks));
await alert.get("button").trigger("click");
@@ -73,8 +101,7 @@ describe("editor loading", () => {
it("shows the empty-form text for a loaded form without questions", async () => {
const editor = await mountEditor(loads([]));
- expect(editor.text()).toContain("Welcome to the Editor");
- expect(editor.text()).toContain("No blocks found");
+ expect(editor.text()).toContain("Add your first question");
expect(editor.find("[role=status]").exists()).toBe(false);
});
@@ -83,6 +110,132 @@ describe("editor loading", () => {
const editor = await mountEditor(loads(blocks));
expect(editor.text()).toContain("questions");
- expect(editor.text()).not.toContain("Welcome to the Editor");
+ expect(editor.text()).not.toContain("Add your first question");
+ });
+
+ it("puts focus on Add question after Try again loads, and back on Try again when it fails again", async () => {
+ const getBlocks = vi.mocked(useForm().getBlocks);
+ const editor = await mountEditor(() => Promise.reject(new Error("500")));
+
+ await editor.get("[role=alert] button").trigger("click");
+ await flushPromises();
+ expect(focused()).toBe("Try again");
+
+ // putOnWorkbench is a stub, so no question is open to take the focus
+ getBlocks.mockImplementation(loads(blocks));
+ await editor.get("[role=alert] button").trigger("click");
+ await flushPromises();
+ expect(focused()).toBe("Add question");
+ });
+
+ it("opens no question when the questions arrive after the editor closed", async () => {
+ let arrive!: () => void;
+ const editor = await mountEditor(
+ () => new Promise((resolve) => (arrive = resolve)),
+ );
+
+ editor.unmount();
+ wrapper = undefined;
+ useForm().blocks = blocks;
+ arrive();
+ await flushPromises();
+
+ expect(useWorkbench().putOnWorkbench).not.toHaveBeenCalled();
+ });
+
+ // the top bar has its Preview from lg
+ it("opens the preview from the document on a phone", async () => {
+ const editor = await mountEditor(loads(blocks));
+ expect(editor.find("aside").exists()).toBe(false);
+
+ const preview = editor
+ .findAll("button")
+ .find((button) => button.text() === "Preview");
+ await preview?.trigger("click");
+
+ expect(editor.find("aside").exists()).toBe(true);
+ // a click on the text would land on a span, which the closing dialog
+ // can't focus again: the button's inside lets clicks through to it
+ expect(preview?.classes()).toContain("[&_*]:pointer-events-none");
+ // a 44 px icon below sm, so Outline fits beside it; the label from sm
+ expect(preview?.classes()).toEqual(
+ expect.arrayContaining(["h-11", "w-11", "sm:w-auto"]),
+ );
+ expect(preview?.get("span").classes()).toEqual(["sr-only", "sm:not-sr-only"]);
+ expect(preview?.attributes("aria-haspopup")).toBe("dialog");
+ });
+
+ it("closes the preview on Esc inside it and puts focus back on Preview", async () => {
+ const editor = await mountEditor(loads(blocks), {
+ stubs: { ...stubs, FormPreview: false, PreviewFrame: true },
+ mocks: { route: () => "/" },
+ });
+ const preview = editor
+ .findAll("button")
+ .find((button) => button.text() === "Preview");
+ await preview?.trigger("click");
+ await flushPromises();
+ expect(document.activeElement?.closest("[role=dialog]")).not.toBeNull();
+
+ // after a click into the frame, the form there gets the key and tells the editor
+ editor.findComponent(PreviewFrame).vm.$emit("escape");
+ await flushPromises();
+
+ expect(document.querySelector("[role=dialog]")).toBeNull();
+ expect(document.activeElement).toBe(preview?.element);
+ });
+
+ it("puts focus back on the form menu when a preview opened from it closes", async () => {
+ // the menu item is gone by then, so the dialog can't focus it again
+ const editor = await mountEditor(loads(blocks), {
+ stubs: {
+ ...stubs,
+ AppLayout: {
+ components: { FormMenu },
+ template: " ",
+ },
+ FormPreview: false,
+ PreviewFrame: true,
+ },
+ mocks: { route: () => "/" },
+ });
+ const menu = editor.get('[aria-haspopup="menu"]').element as HTMLElement;
+ // a mouse hits the name inside the button
+ menu.querySelector("span")?.click();
+ await flushPromises();
+ [...document.querySelectorAll('[role="menuitem"]')]
+ .find((item) => item.textContent?.trim() === "Preview")
+ ?.click();
+ await new Promise((resolve) => setTimeout(resolve));
+ await flushPromises();
+ expect(document.activeElement?.closest("[role=dialog]")).not.toBeNull();
+
+ document.activeElement?.dispatchEvent(
+ new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
+ );
+ await flushPromises();
+
+ expect(document.activeElement).toBe(menu);
+ });
+});
+
+describe("editor from history", () => {
+ afterEach(() => {
+ wrapper?.unmount();
+ wrapper = undefined;
+ vi.restoreAllMocks();
+ });
+
+ it("loads the form again when browser Back brings the editor back", async () => {
+ vi.mocked(callGetForm).mockResolvedValue({
+ data: { ...form, eoc_headline: "Thanks!" },
+ } as AxiosResponse);
+ window.dispatchEvent(
+ new PopStateEvent("popstate", { state: { component: "x" } }),
+ );
+
+ await mountEditor(loads(blocks));
+
+ expect(useForm().form?.eoc_headline).toBe("Thanks!");
});
});
diff --git a/resources/js/Pages/Forms/Edit.vue b/resources/js/Pages/Forms/Edit.vue
index e3de9026..2c604120 100644
--- a/resources/js/Pages/Forms/Edit.vue
+++ b/resources/js/Pages/Forms/Edit.vue
@@ -1,38 +1,40 @@
-
+
+ Preview
+
+
+
+
diff --git a/resources/js/components/EmptyState.vue b/resources/js/components/EmptyState.vue
index 99191fb6..cbe2caa8 100644
--- a/resources/js/components/EmptyState.vue
+++ b/resources/js/components/EmptyState.vue
@@ -4,7 +4,7 @@
>
{{ title }}
-
+
{{ description }}
@@ -22,7 +22,7 @@ import { D9Spinner } from "@deck9/ui";
defineProps<{
title: string;
- description: string;
+ description?: string;
isLoading?: boolean;
}>();
diff --git a/resources/js/components/Factory/Document/AddQuestionLine.vue b/resources/js/components/Factory/Document/AddQuestionLine.vue
new file mode 100644
index 00000000..19702b2b
--- /dev/null
+++ b/resources/js/components/Factory/Document/AddQuestionLine.vue
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+ Add question here, after {{ label }}
+
+
+
+
+
diff --git a/resources/js/components/Factory/Document/BlockMenu.test.ts b/resources/js/components/Factory/Document/BlockMenu.test.ts
new file mode 100644
index 00000000..4b70fa55
--- /dev/null
+++ b/resources/js/components/Factory/Document/BlockMenu.test.ts
@@ -0,0 +1,184 @@
+import { flushPromises, mount, VueWrapper } from "@vue/test-utils";
+import { AxiosResponse } from "axios";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { callGetFormBlocks } from "@/api/blocks";
+import { useForm, useWorkbench } from "@/stores";
+import BlockMenu from "./BlockMenu.vue";
+
+vi.mock("@/api/blocks", async (importOriginal) => ({
+ ...(await importOriginal()),
+ callGetFormBlocks: vi.fn(),
+}));
+
+const blocks = [
+ { id: 1, uuid: "q1", type: "radio" },
+ { id: 2, uuid: "q2", type: "radio" },
+] as FormBlockModel[];
+
+const group = { id: 3, uuid: "g1", type: "group" } as FormBlockModel;
+
+let wrapper: VueWrapper | undefined;
+
+const openMenu = async (block: FormBlockModel) => {
+ wrapper = mount(BlockMenu, {
+ props: { block, label: "Options for question 1" },
+ attachTo: document.body,
+ });
+ await wrapper.find("button").trigger("click");
+ await flushPromises();
+};
+
+// the menu opens in a portal
+const item = (label: string) =>
+ [...document.querySelectorAll("[role=menuitem]")].find(
+ (element) => element.textContent?.trim() === label,
+ );
+
+describe("a block's menu", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ useWorkbench().$reset();
+ useForm().$reset();
+ useForm().blocks = blocks;
+ });
+
+ afterEach(() => {
+ wrapper?.unmount();
+ document.body.innerHTML = "";
+ });
+
+ it("moves a question down, for touch and pointer users", async () => {
+ await openMenu(blocks[0]);
+
+ item("Move down")!.click();
+ await flushPromises();
+
+ expect(useForm().changeBlockSequence).toHaveBeenCalledWith(
+ false,
+ 1,
+ blocks[0],
+ );
+ });
+
+ it("can't move the first block up or the last one down", async () => {
+ await openMenu(blocks[0]);
+ expect(item("Move up")?.getAttribute("aria-disabled")).toBe("true");
+ expect(item("Move down")?.getAttribute("aria-disabled")).toBeNull();
+ wrapper!.unmount();
+ document.body.innerHTML = "";
+
+ await openMenu(blocks[1]);
+ expect(item("Move up")?.getAttribute("aria-disabled")).toBeNull();
+ expect(item("Move down")?.getAttribute("aria-disabled")).toBe("true");
+ });
+
+ it("duplicates a question", async () => {
+ await openMenu(blocks[0]);
+
+ item("Duplicate")!.click();
+ await flushPromises();
+
+ expect(useForm().duplicateFormBlock).toHaveBeenCalledWith(blocks[0]);
+ });
+
+ it("has no Duplicate for a group, which the server can't copy", async () => {
+ await openMenu(group);
+
+ expect(item("Duplicate")).toBeUndefined();
+ expect(item("Add question to this group")).toBeDefined();
+ });
+});
+
+describe("deleting from a block's menu", () => {
+ const q1 = { id: 1, uuid: "q1", type: "radio" } as FormBlockModel;
+ const g1 = { id: 2, uuid: "g1", type: "group" } as FormBlockModel;
+ const q3 = { id: 3, uuid: "q3", type: "radio", parent_block: "g1" };
+ const q2 = { id: 4, uuid: "q2", type: "radio" } as FormBlockModel;
+
+ // the rows FormDocument shows, and its Add question button
+ const showRows = (...uuids: string[]) => {
+ document.body.innerHTML = uuids
+ .map(
+ (uuid) =>
+ `${uuid}
`,
+ )
+ .join("");
+ document.body.insertAdjacentHTML(
+ "beforeend",
+ 'Add question
',
+ );
+ };
+
+ const deleteFrom = async (block: FormBlockModel) => {
+ await openMenu(block);
+ item(block.type === "group" ? "Delete groupβ¦" : "Deleteβ¦")!.click();
+ await flushPromises();
+ // the dialog asks first: nothing is deleted yet
+ expect(useForm().deleteFormBlock).not.toHaveBeenCalled();
+
+ document
+ .querySelector("[role=alertdialog] button.bg-red-600")!
+ .click();
+ await flushPromises();
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ useWorkbench().$reset();
+ const form = useForm();
+ form.$reset();
+ form.form = { uuid: "form1" } as FormModel;
+ form.blocks = [q1, g1, q3 as FormBlockModel, q2];
+ vi.mocked(callGetFormBlocks).mockResolvedValue({
+ data: form.blocks,
+ } as AxiosResponse);
+ vi.mocked(form.deleteFormBlock).mockImplementation(async (block) => {
+ form.blocks = form.blocks!.filter(
+ (item) =>
+ item.id !== block.id && item.parent_block !== block.uuid,
+ );
+ });
+ });
+
+ afterEach(() => {
+ wrapper?.unmount();
+ document.body.innerHTML = "";
+ });
+
+ it("moves focus to the next row, past a deleted group's questions", async () => {
+ showRows("q1", "g1", "q3", "q2");
+
+ await deleteFrom(g1);
+
+ expect(useForm().deleteFormBlock).toHaveBeenCalledWith(g1);
+ expect(document.activeElement?.textContent).toBe("q2");
+ });
+
+ it("moves focus to the row above when the last one is deleted", async () => {
+ showRows("q1", "g1", "q3", "q2");
+
+ await deleteFrom(q2);
+
+ expect(document.activeElement?.closest("[id^=row-]")?.id).toBe(
+ "row-q3",
+ );
+ });
+
+ it("moves focus to Add question when no block is left", async () => {
+ useForm().blocks = [q1];
+ showRows("q1");
+
+ await deleteFrom(q1);
+
+ expect(document.activeElement?.textContent).toBe("Add question");
+ });
+
+ it("keeps focus on the row when the delete fails", async () => {
+ vi.mocked(useForm().deleteFormBlock).mockResolvedValue();
+ showRows("q1", "g1", "q3", "q2");
+
+ await deleteFrom(q1);
+
+ expect(document.activeElement?.textContent).toBe("q1");
+ });
+});
diff --git a/resources/js/components/Factory/Document/BlockMenu.vue b/resources/js/components/Factory/Document/BlockMenu.vue
new file mode 100644
index 00000000..078da09e
--- /dev/null
+++ b/resources/js/components/Factory/Document/BlockMenu.vue
@@ -0,0 +1,133 @@
+
+
+
+
+
+ {{ label }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/resources/js/components/Factory/Document/DocumentGroup.vue b/resources/js/components/Factory/Document/DocumentGroup.vue
new file mode 100644
index 00000000..bd8352c5
--- /dev/null
+++ b/resources/js/components/Factory/Document/DocumentGroup.vue
@@ -0,0 +1,139 @@
+
+
+
+
+
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..b54afbc4
--- /dev/null
+++ b/resources/js/components/Factory/Document/DocumentList.test.ts
@@ -0,0 +1,114 @@
+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,
+ );
+ });
+});
+
+describe("lines and air between blocks", () => {
+ const group = { id: 4, uuid: "g1", type: "group" } as FormBlockModel;
+
+ // the box around each block, in document order
+ const items = (list: FormBlockModel[]) => {
+ useForm().blocks = list;
+ wrapper = mount(DocumentList, {
+ props: { numbers: { b1: 1, b2: 2, b3: 3 } },
+ global: { stubs: { QuestionCard: true, DocumentGroup: true } },
+ });
+ return wrapper.findAll(".smooth-dnd-draggable-wrapper > div.relative");
+ };
+
+ beforeEach(() => {
+ useForm().$reset();
+ useWorkbench().$reset();
+ });
+
+ afterEach(() => wrapper?.unmount());
+
+ it("draws a line at the start of each question but the first, not next to a group", () => {
+ const lines = items([blocks[0], group, blocks[1], blocks[2]]).map(
+ (item) => item.classes("border-t"),
+ );
+
+ expect(lines).toEqual([false, false, false, true]);
+ });
+
+ it("gives the open question 24 px above and below, the others 8 px", () => {
+ useWorkbench().block = blocks[1];
+
+ const padding = items(blocks).map((item) =>
+ ["py-2", "py-6"].filter((name) => item.classes(name)),
+ );
+
+ expect(padding).toEqual([["py-2"], ["py-6"], ["py-2"]]);
+ });
+
+ it("keeps an open group at 8 px, so its frame doesn't move", () => {
+ useWorkbench().block = group;
+
+ expect(items([blocks[0], group])[1].classes()).toContain("py-2");
+ });
+});
diff --git a/resources/js/components/Factory/Document/DocumentList.vue b/resources/js/components/Factory/Document/DocumentList.vue
new file mode 100644
index 00000000..7badaf65
--- /dev/null
+++ b/resources/js/components/Factory/Document/DocumentList.vue
@@ -0,0 +1,104 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/resources/js/components/Factory/Document/DocumentOutline.test.ts b/resources/js/components/Factory/Document/DocumentOutline.test.ts
new file mode 100644
index 00000000..98f1b267
--- /dev/null
+++ b/resources/js/components/Factory/Document/DocumentOutline.test.ts
@@ -0,0 +1,69 @@
+import { mount } from "@vue/test-utils";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { useForm, useWorkbench } from "@/stores";
+import DocumentOutline from "./DocumentOutline.vue";
+
+const blocks = [
+ { id: 1, uuid: "q1", type: "none", message: "Welcome
" },
+ { id: 2, uuid: "g1", type: "group", title: "Food" },
+ { id: 3, uuid: "q2", type: "radio", message: "", parent_block: "g1" },
+ { id: 4, uuid: "q3", type: "input-short", message: "Name?
" },
+] as FormBlockModel[];
+
+const numbers = { q1: 1, q2: 2, q3: 3 };
+
+const scrolled = vi.fn();
+
+describe("the outline", () => {
+ beforeEach(() => {
+ // jsdom has no scrollIntoView
+ Element.prototype.scrollIntoView = function (
+ this: Element,
+ options?: boolean | ScrollIntoViewOptions,
+ ) {
+ scrolled(this.textContent, (options as ScrollIntoViewOptions)?.block);
+ };
+ useForm().$reset();
+ useWorkbench().$reset();
+ useForm().blocks = blocks;
+ });
+
+ it("lists questions in document order under their group, never by id", () => {
+ const wrapper = mount(DocumentOutline, { props: { numbers } });
+ const lines = wrapper.findAll("li").map((li) => li.text());
+
+ expect(lines).toEqual([
+ "1Welcome",
+ "Food",
+ "2No question text yet",
+ "3Name?",
+ ]);
+ });
+
+ it("marks the open question and says which one was picked", async () => {
+ useWorkbench().block = blocks[3];
+ const wrapper = mount(DocumentOutline, { props: { numbers } });
+ const current = wrapper.find("[aria-current=true]");
+
+ expect(current.text()).toBe("3Name?");
+
+ await wrapper.findAll("button")[0].trigger("click");
+ expect(wrapper.emitted("pick")?.[0]).toEqual([blocks[0]]);
+ });
+
+ it("keeps the open question in view, also when it opens as a panel", async () => {
+ useWorkbench().block = blocks[3];
+ const wrapper = mount(DocumentOutline, {
+ props: { numbers },
+ attachTo: document.body,
+ });
+ await wrapper.vm.$nextTick();
+ expect(scrolled).toHaveBeenLastCalledWith("3Name?", "nearest");
+
+ useWorkbench().block = blocks[0];
+ await wrapper.vm.$nextTick();
+ expect(scrolled).toHaveBeenLastCalledWith("1Welcome", "nearest");
+
+ wrapper.unmount();
+ });
+});
diff --git a/resources/js/components/Factory/Document/DocumentOutline.vue b/resources/js/components/Factory/Document/DocumentOutline.vue
new file mode 100644
index 00000000..8b871d39
--- /dev/null
+++ b/resources/js/components/Factory/Document/DocumentOutline.vue
@@ -0,0 +1,89 @@
+
+
+
+
+ Outline
+
+
+
+
+
+
+
+
diff --git a/resources/js/components/Factory/Document/DocumentQuestion.test.ts b/resources/js/components/Factory/Document/DocumentQuestion.test.ts
new file mode 100644
index 00000000..d9f52b28
--- /dev/null
+++ b/resources/js/components/Factory/Document/DocumentQuestion.test.ts
@@ -0,0 +1,188 @@
+import { flushPromises, mount } from "@vue/test-utils";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { useForm, useWorkbench } from "@/stores";
+import DocumentQuestion from "./DocumentQuestion.vue";
+
+const question = {
+ id: 7,
+ uuid: "hSV2gq81LmQyTz0a",
+ type: "checkbox",
+ message: "Which days can you help?
",
+ is_required: true,
+ is_disabled: true,
+ title: "days",
+ logics: [{ id: 1 }],
+ interactions: [
+ { type: "button", is_editable: true },
+ { type: "button", is_editable: true },
+ { type: "button", is_editable: false },
+ ],
+} as unknown as FormBlockModel;
+
+// 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 },
+ global: {
+ stubs: {
+ QuestionCard: {
+ template: ``,
+ },
+ },
+ },
+ attachTo: document.body,
+ });
+
+describe("a closed question in the document", () => {
+ beforeEach(() => {
+ // the shared test store keeps its spies' calls
+ vi.clearAllMocks();
+ useWorkbench().$reset();
+ useForm().$reset();
+ });
+
+ afterEach(() => {
+ document.body.innerHTML = "";
+ });
+
+ it("shows its number, text and a meta line, never its random id", () => {
+ const text = mountQuestion().find(row).text();
+
+ expect(text).toContain("Question 3:");
+ expect(text).toContain("Which days can you help?");
+ expect(text).toContain("Checkboxes Β· 2 choices");
+ expect(text).toContain("Required");
+ // its rules show as sentences under it
+ expect(text).not.toContain("rule");
+ expect(text).toContain("Always hidden");
+ expect(text).toContain("days");
+ expect(text).not.toContain(question.uuid);
+ });
+
+ it("says when a question has no text yet instead of naming it by id", () => {
+ const text = mountQuestion({ ...question, message: "" })
+ .find(row)
+ .text();
+
+ expect(text).toContain("No question text yet");
+ expect(text).not.toContain(question.uuid);
+ });
+
+ it("marks its number while the open question jumps here", async () => {
+ const workbench = useWorkbench();
+ const wrapper = mountQuestion();
+ const number = () => wrapper.find("span[aria-hidden] > span");
+
+ expect(number().text()).toBe("3");
+ expect(number().classes()).not.toContain("bg-blue-50");
+
+ workbench.block = {
+ id: 1,
+ uuid: "q-1",
+ logics: [{ action: "goto", action_payload: question.uuid }],
+ } as unknown as FormBlockModel;
+ await wrapper.vm.$nextTick();
+
+ expect(number().classes()).toContain("bg-blue-50");
+ });
+
+ it("opens as a card when clicked", async () => {
+ const workbench = useWorkbench();
+ const wrapper = mountQuestion();
+
+ 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(row).exists()).toBe(false);
+ });
+
+ it("moves with Alt+Up/Down, closed or open", async () => {
+ const store = useForm();
+ const workbench = useWorkbench();
+ const next = { id: 8, uuid: "next", type: "radio" } as FormBlockModel;
+ store.blocks = [question, next];
+ const wrapper = mountQuestion();
+
+ await wrapper.find("button").trigger("keydown", {
+ key: "ArrowDown",
+ altKey: true,
+ });
+ expect(store.changeBlockSequence).toHaveBeenCalledWith(
+ false,
+ 1,
+ question,
+ );
+
+ workbench.block = question;
+ await wrapper.vm.$nextTick();
+ await wrapper.find(".card").trigger("keydown", {
+ key: "ArrowUp",
+ altKey: true,
+ });
+ // already first: nothing moves
+ expect(store.changeBlockSequence).toHaveBeenCalledTimes(1);
+
+ // a plain arrow or one with Shift is the text field's
+ await wrapper.find(".card").trigger("keydown", { key: "ArrowDown" });
+ await wrapper.find(".card").trigger("keydown", {
+ key: "ArrowDown",
+ altKey: true,
+ shiftKey: true,
+ });
+ expect(store.changeBlockSequence).toHaveBeenCalledTimes(1);
+ });
+
+ it("closes with Escape and gives focus back to its row", async () => {
+ const workbench = useWorkbench();
+ vi.mocked(workbench.clearWorkbench).mockImplementation(() => {
+ workbench.block = null;
+ });
+ workbench.block = question;
+ const wrapper = mountQuestion();
+
+ await wrapper.find(".card").trigger("keydown", { key: "Escape" });
+ await flushPromises();
+
+ expect(workbench.clearWorkbench).toHaveBeenCalled();
+ expect(document.activeElement?.tagName).toBe("BUTTON");
+ expect(document.activeElement?.textContent).toContain(
+ "Which days can you help?",
+ );
+ });
+
+ it("closes with Escape from the question text, which cancels every Escape", async () => {
+ const workbench = useWorkbench();
+ workbench.block = question;
+ const wrapper = mountQuestion();
+ const text = wrapper.find(".text");
+ text.element.addEventListener("keydown", (event) =>
+ event.preventDefault(),
+ );
+
+ await text.trigger("keydown", { key: "Escape" });
+ await flushPromises();
+
+ expect(workbench.clearWorkbench).toHaveBeenCalled();
+ });
+
+ it("leaves Escape to a select or menu inside the card", async () => {
+ const workbench = useWorkbench();
+ workbench.block = question;
+ const wrapper = mountQuestion();
+ const card = wrapper.find(".card");
+ // like a listbox that closes on Escape
+ card.element.addEventListener("keydown", (event) =>
+ event.preventDefault(),
+ );
+
+ await card.trigger("keydown", { key: "Escape" });
+ await flushPromises();
+
+ expect(workbench.clearWorkbench).not.toHaveBeenCalled();
+ });
+});
diff --git a/resources/js/components/Factory/Document/DocumentQuestion.vue b/resources/js/components/Factory/Document/DocumentQuestion.vue
new file mode 100644
index 00000000..6e3eba73
--- /dev/null
+++ b/resources/js/components/Factory/Document/DocumentQuestion.vue
@@ -0,0 +1,149 @@
+
+
+
+
+
diff --git a/resources/js/components/Factory/Document/EndScreenBlock.test.ts b/resources/js/components/Factory/Document/EndScreenBlock.test.ts
new file mode 100644
index 00000000..37fa5bf7
--- /dev/null
+++ b/resources/js/components/Factory/Document/EndScreenBlock.test.ts
@@ -0,0 +1,502 @@
+import { flushPromises, mount, VueWrapper } from "@vue/test-utils";
+import { AxiosError, AxiosResponse } from "axios";
+import { createPinia, setActivePinia } from "pinia";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { callGetForm, callUpdateForm } from "@/api/forms";
+import { useForm, useWorkbench } from "@/stores";
+import { useNotice } from "@/stores/notice";
+import { useFreshForm } from "@/utils/useFreshForm";
+import CompletionPage from "@/components/Factory/Settings/CompletionPage.vue";
+import EndScreenBlock from "./EndScreenBlock.vue";
+import { endScreenOpen } from "./blockFocus";
+
+vi.mock("@/api/forms", async (importOriginal) => ({
+ ...(await importOriginal()),
+ callGetForm: vi.fn(),
+ callUpdateForm: vi.fn(),
+}));
+
+const form = {
+ uuid: "form1",
+ eoc_headline: "See you on Thursday!",
+ eoc_text: "We sent your ticket to your inbox.
",
+ show_cta_link: true,
+ cta_label: "Add to calendar",
+ cta_link: "https://example.com/cal",
+ use_cta_redirect: false,
+} as FormModel;
+
+let pinia: ReturnType;
+let wrapper: VueWrapper | undefined;
+
+// real actions, not the stubs of the global testing pinia
+const mountBlock = () => {
+ // one at a time, as in the document: they share the open state
+ wrapper?.unmount();
+ wrapper = mount(EndScreenBlock, {
+ attachTo: document.body,
+ global: { plugins: [pinia], directives: { tooltip: {} } },
+ });
+ return wrapper;
+};
+
+const open = async () => {
+ const block = mountBlock();
+ await block.find("button").trigger("click");
+ await vi.waitFor(() =>
+ expect(block.find("[role=textbox]").exists()).toBe(true),
+ );
+ return block;
+};
+
+describe("the end screen in the document", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ window.route = (() => "/settings") as unknown as typeof window.route;
+ pinia = createPinia();
+ setActivePinia(pinia);
+ useForm().form = { ...form };
+ // the server answers with the form as it saved it
+ vi.mocked(callUpdateForm).mockImplementation(
+ async (_, values) =>
+ ({
+ data: { ...useForm().form, ...values },
+ }) as AxiosResponse,
+ );
+ });
+
+ afterEach(() => {
+ wrapper?.unmount();
+ wrapper = undefined;
+ });
+
+ it("shows its headline, text and button when closed", () => {
+ const text = mountBlock().find("button").text();
+
+ expect(text).toContain("See you on Thursday!");
+ expect(text).toContain("End screen");
+ expect(text).toContain("We sent your ticket to your inbox.");
+ expect(text).toContain("Button: Add to calendar");
+ });
+
+ it("keeps its hidden label inside the document's scroll box", () => {
+ // else the absolute sr-only label stretches the whole page
+ expect(mountBlock().get("#row-end-screen").classes()).toContain(
+ "relative",
+ );
+ });
+
+ it("shows the default headline people see when it has none", async () => {
+ useForm().form = { ...form, eoc_headline: null, show_cta_link: false };
+ const text = mountBlock().find("button").text();
+
+ expect(text).toContain("Form Submitted Β· default");
+ expect(text).not.toContain("Button:");
+
+ const block = await open();
+ expect(
+ block.find("#end-screen-headline").attributes("placeholder"),
+ ).toBe("Form Submitted");
+ });
+
+ it("opens in place, closes the open question, and closes when a question opens", async () => {
+ const workbench = useWorkbench();
+ workbench.block = { id: 4, uuid: "q4" } as FormBlockModel;
+
+ const block = await open();
+
+ expect(workbench.block).toBeNull();
+ const headline = block.find("#end-screen-headline");
+ expect(headline.element.value).toBe("See you on Thursday!");
+ expect(document.activeElement).toBe(headline.element);
+ expect(block.find("label[for=end-screen-headline]").text()).toBe(
+ "End screen headline",
+ );
+ expect(block.find("#end-screen-text-label").text()).toBe(
+ "End screen text",
+ );
+
+ workbench.block = { id: 5, uuid: "q5" } as FormBlockModel;
+ await flushPromises();
+
+ expect(block.find("#end-screen-headline").exists()).toBe(false);
+ });
+
+ it("tells the preview while it's open, and starts closed on the next page", async () => {
+ const block = await open();
+ expect(endScreenOpen.value).toBe(true);
+
+ block.unmount();
+ wrapper = undefined;
+ expect(endScreenOpen.value).toBe(false);
+ });
+
+ it("gets 24 px above and below when open, like an open question", async () => {
+ expect(mountBlock().get("#row-end-screen").classes()).toContain("pt-2");
+
+ const row = (await open()).get("#row-end-screen");
+ expect(row.classes()).toContain("py-6");
+ expect(row.classes()).not.toContain("pt-2");
+ });
+
+ it("has the question card's phone sizes, number column and focus bar", async () => {
+ const block = await open();
+ const [number, card] = block.findAll("#row-end-screen > div");
+
+ expect(number.classes()).toEqual(
+ expect.arrayContaining(["w-8", "sm:w-12"]),
+ );
+ expect(card.classes()).toContain("touch-fields");
+ expect(block.get("a").classes()).toContain("min-h-11");
+ // the editor draws its own bar
+ expect(block.find(".focus-within\\:border-blue-500").exists()).toBe(
+ false,
+ );
+ });
+
+ it("saves only what changed, right away when you leave the block", async () => {
+ const block = await open();
+
+ await block.find("#end-screen-headline").setValue("Thanks!");
+ await block.find("#end-screen-headline").trigger("focusout", {
+ relatedTarget: block.find("#end-screen-button").element,
+ });
+ await flushPromises();
+ expect(callUpdateForm).not.toHaveBeenCalled();
+
+ await block.find("#end-screen-headline").trigger("focusout");
+ await flushPromises();
+
+ expect(callUpdateForm).toHaveBeenCalledTimes(1);
+ expect(callUpdateForm).toHaveBeenCalledWith(
+ expect.objectContaining({ uuid: "form1" }),
+ { eoc_headline: "Thanks!" },
+ undefined,
+ );
+ expect(useForm().form?.eoc_headline).toBe("Thanks!");
+ });
+
+ it("saves right away when the focus moves to the Settings link in the block", async () => {
+ const block = await open();
+
+ await block.find("#end-screen-headline").setValue("Thanks!");
+ await block.find("#end-screen-headline").trigger("focusout", {
+ relatedTarget: block.find("a").element,
+ });
+ await flushPromises();
+
+ expect(callUpdateForm).toHaveBeenCalledWith(
+ expect.anything(),
+ { eoc_headline: "Thanks!" },
+ undefined,
+ );
+ });
+
+ it("saves right away on browser Back", async () => {
+ const block = await open();
+
+ await block.find("#end-screen-headline").setValue("Thanks!");
+ window.dispatchEvent(new PopStateEvent("popstate"));
+ await flushPromises();
+
+ expect(callUpdateForm).toHaveBeenCalledWith(
+ expect.anything(),
+ { eoc_headline: "Thanks!" },
+ undefined,
+ );
+ });
+
+ it("lets the next page load the form again only after the save made on Back", async () => {
+ vi.mocked(callGetForm).mockImplementation(
+ async () =>
+ ({
+ data: useForm().form,
+ }) as AxiosResponse,
+ );
+ let answer = () => {};
+ vi.mocked(callUpdateForm).mockImplementation(
+ (_, values) =>
+ new Promise((resolve) => {
+ answer = () =>
+ resolve({
+ data: { ...useForm().form, ...values },
+ } as AxiosResponse);
+ }),
+ );
+ const block = await open();
+
+ await block.find("#end-screen-headline").setValue("Thanks!");
+ window.dispatchEvent(
+ new PopStateEvent("popstate", { state: { component: "x" } }),
+ );
+ // the page Back goes to
+ useFreshForm();
+ await flushPromises();
+ expect(callGetForm).not.toHaveBeenCalled();
+
+ answer();
+ await flushPromises();
+ expect(callGetForm).toHaveBeenCalledTimes(1);
+ });
+
+ it("lets a page opened by a link while the save is on its way load the form again after it", async () => {
+ vi.mocked(callGetForm).mockImplementation(
+ async () =>
+ ({
+ data: useForm().form,
+ }) as AxiosResponse,
+ );
+ let answer = () => {};
+ vi.mocked(callUpdateForm).mockImplementation(
+ (_, values) =>
+ new Promise((resolve) => {
+ answer = () =>
+ resolve({
+ data: { ...useForm().form, ...values },
+ } as AxiosResponse);
+ }),
+ );
+ const block = await open();
+
+ await block.find("#end-screen-headline").setValue("Thanks!");
+ await block.find("#end-screen-headline").trigger("focusout");
+ // the link's visit starts, and its page loads
+ document.dispatchEvent(
+ new CustomEvent("inertia:before", { detail: { visit: {} } }),
+ );
+ useFreshForm();
+ await flushPromises();
+ expect(callGetForm).not.toHaveBeenCalled();
+
+ answer();
+ await flushPromises();
+ expect(callGetForm).toHaveBeenCalledTimes(1);
+ });
+
+ it("opens with the form loaded again after Back, not the older one the page came with", async () => {
+ let answer = () => {};
+ vi.mocked(callGetForm).mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ answer = () =>
+ resolve({
+ data: { ...form, eoc_headline: "Thanks!" },
+ } as AxiosResponse);
+ }),
+ );
+ window.dispatchEvent(
+ new PopStateEvent("popstate", { state: { component: "x" } }),
+ );
+ // the page Back goes to
+ useFreshForm();
+ const block = mountBlock();
+ await flushPromises();
+
+ await block.find("button").trigger("click");
+ answer();
+ await vi.waitFor(() =>
+ expect(block.find("#end-screen-headline").exists()).toBe(true),
+ );
+
+ expect(
+ block.get("#end-screen-headline").element.value,
+ ).toBe("Thanks!");
+ });
+
+ it("saves on its own after a short pause while you type", async () => {
+ vi.useFakeTimers();
+ try {
+ const block = await open();
+
+ await block.find("#end-screen-headline").setValue("Thanks!");
+ vi.advanceTimersByTime(1000);
+ await flushPromises();
+
+ expect(callUpdateForm).toHaveBeenCalledWith(
+ expect.anything(),
+ { eoc_headline: "Thanks!" },
+ undefined,
+ );
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("edits the button: off hides its text and link", async () => {
+ const block = await open();
+
+ expect(block.find("#end-screen-button-label-label").text()).toBe(
+ "Button text",
+ );
+
+ expect(
+ block.find("#end-screen-button-label").element
+ .value,
+ ).toBe("Add to calendar");
+ expect(
+ block.find("#end-screen-button-link").element
+ .value,
+ ).toBe("https://example.com/cal");
+
+ await block.find("#end-screen-button-link").setValue("mailto:a@b.co");
+ await block.find("#end-screen-button").trigger("click");
+ await block.find("#end-screen-button").trigger("focusout");
+ await flushPromises();
+
+ expect(callUpdateForm).toHaveBeenCalledWith(
+ expect.anything(),
+ { cta_link: "mailto:a@b.co", show_cta_link: false },
+ undefined,
+ );
+ expect(block.find("#end-screen-button-label").exists()).toBe(false);
+ });
+
+ it("shows the server's reason when a save is refused", async () => {
+ const refused = new AxiosError(
+ "Unprocessable",
+ undefined,
+ undefined,
+ undefined,
+ {
+ status: 422,
+ data: { message: "The cta link field format is invalid." },
+ } as AxiosResponse,
+ );
+ vi.mocked(callUpdateForm).mockRejectedValue(refused);
+ const block = await open();
+
+ await block.find("#end-screen-button-link").setValue("example.com");
+ await block.find("#end-screen-button-link").trigger("focusout");
+ await flushPromises();
+
+ expect(useNotice().message).toBe(
+ "Your change to the end screen wasn't saved: The cta link field format is invalid.",
+ );
+ expect(useForm().form?.cta_link).toBe("https://example.com/cal");
+ });
+
+ it("shows Saving in the save status from the first key until the save lands", async () => {
+ let answer = () => {};
+ vi.mocked(callUpdateForm).mockImplementation(
+ (_, values) =>
+ new Promise((resolve) => {
+ answer = () =>
+ resolve({
+ data: { ...useForm().form, ...values },
+ } as AxiosResponse);
+ }),
+ );
+ const block = await open();
+
+ await block.find("#end-screen-headline").setValue("Thanks!");
+ expect(useNotice().saveStatus).toBe("saving");
+
+ await block.find("#end-screen-headline").trigger("focusout");
+ expect(useNotice().saveStatus).toBe("saving");
+
+ answer();
+ await flushPromises();
+ expect(useNotice().saveStatus).toBe("idle");
+ });
+
+ it("sends a refused change again on Retry", async () => {
+ vi.mocked(callUpdateForm).mockRejectedValueOnce(new AxiosError());
+ const block = await open();
+
+ await block.find("#end-screen-headline").setValue("Thanks!");
+ await block.find("#end-screen-headline").trigger("focusout");
+ await flushPromises();
+ expect(useNotice().saveStatus).toBe("failed");
+
+ useNotice().retrySaves();
+ await flushPromises();
+
+ expect(callUpdateForm).toHaveBeenCalledTimes(2);
+ expect(useForm().form?.eoc_headline).toBe("Thanks!");
+ expect(useNotice().saveStatus).toBe("idle");
+ });
+
+ it("sends a Retry only to the form it was made in", async () => {
+ vi.mocked(callUpdateForm).mockRejectedValueOnce(new AxiosError());
+ const block = await open();
+
+ await block.find("#end-screen-headline").setValue("Thanks!");
+ await block.find("#end-screen-headline").trigger("focusout");
+ await flushPromises();
+ // you open another form, then click Retry
+ block.unmount();
+ wrapper = undefined;
+ useForm().form = { ...form, uuid: "form2" };
+ useNotice().retrySaves();
+ await flushPromises();
+
+ expect(callUpdateForm).toHaveBeenCalledTimes(1);
+ });
+
+ it("stops showing Saving when it closes with a change still in its pause", async () => {
+ const block = await open();
+
+ await block.find("#end-screen-headline").setValue("Thanks!");
+ block.unmount();
+ wrapper = undefined;
+
+ expect(useNotice().saveStatus).toBe("idle");
+ });
+
+ it("sends a change still in its pause when the page closes, so it outlives the page", async () => {
+ const block = await open();
+
+ await block.find("#end-screen-headline").setValue("Thanks!");
+ window.dispatchEvent(new Event("pagehide"));
+
+ expect(callUpdateForm).toHaveBeenCalledWith(
+ expect.anything(),
+ { eoc_headline: "Thanks!" },
+ { adapter: "fetch", fetchOptions: { keepalive: true } },
+ );
+ });
+
+ it("only links to Settings while the form sends people to a link instead", () => {
+ useForm().form = { ...form, use_cta_redirect: true };
+ const block = mountBlock();
+
+ expect(block.find("button").exists()).toBe(false);
+ expect(block.text()).toContain("https://example.com/cal");
+ expect(block.find("a").attributes("href")).toBe(
+ "/settings#completion-page",
+ );
+ });
+
+ // the public form only goes to a web link, else it shows the end screen
+ it("shows the end screen when the link isn't a web link", () => {
+ useForm().form = { ...form, use_cta_redirect: true, cta_link: "mailto:hi@example.com" };
+ const block = mountBlock();
+
+ expect(block.text()).not.toContain("No end screen");
+ expect(block.find("button").text()).toContain("See you on Thursday!");
+ });
+
+ it("stays in step with the Settings page through the form store", async () => {
+ const block = await open();
+ await block.find("#end-screen-headline").setValue("Thanks!");
+ await block.find("#end-screen-headline").trigger("focusout");
+ await flushPromises();
+
+ const settings = mount(CompletionPage, {
+ global: { plugins: [pinia], directives: { tooltip: {} } },
+ });
+ expect(
+ settings.find("#eoc-headline").element.value,
+ ).toBe("Thanks!");
+
+ await settings.find("#eoc-headline").setValue("Bye!");
+ await settings.find("form").trigger("submit");
+ await flushPromises();
+ settings.unmount();
+
+ // closed, the block shows the saved headline without a reload
+ useWorkbench().block = { id: 5, uuid: "q5" } as FormBlockModel;
+ await flushPromises();
+ expect(block.find("button").text()).toContain("Bye!");
+ });
+});
diff --git a/resources/js/components/Factory/Document/EndScreenBlock.vue b/resources/js/components/Factory/Document/EndScreenBlock.vue
new file mode 100644
index 00000000..499e712c
--- /dev/null
+++ b/resources/js/components/Factory/Document/EndScreenBlock.vue
@@ -0,0 +1,281 @@
+
+
+
+
+
+
+
+
+
+
+
+ No end screen:
+ people go to {{ store.form?.cta_link }}
+
+
Change in Settings
+
+
+
+
+ End screen headline
+
+ End screen
+
+
+
+ End screen text
+
+
+
+
+
+
+ Button
+
+
+ Also in Settings
+
+
+
+
+
+
+
+ End screen: {{ store.form?.eoc_headline || "Form Submitted Β· default" }}
+ End screen
+
+ {{ text }}
+ Button: {{ store.form?.cta_label || "Close" }}
+
+
+
+
+
diff --git a/resources/js/components/Factory/Document/FormDocument.test.ts b/resources/js/components/Factory/Document/FormDocument.test.ts
new file mode 100644
index 00000000..27250350
--- /dev/null
+++ b/resources/js/components/Factory/Document/FormDocument.test.ts
@@ -0,0 +1,247 @@
+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 = [
+ { id: 1, uuid: "b1", type: "none", message: "Welcome
" },
+ { id: 2, uuid: "b2", type: "none", message: "Second
" },
+] as FormBlockModel[];
+
+const stubs = {
+ DocumentList: {
+ template: ``,
+ },
+ RuleGutter: true,
+ EndScreenBlock: { template: "[end]
" },
+};
+
+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", () => {
+ beforeEach(() => {
+ Element.prototype.scrollIntoView = function (
+ this: Element,
+ options?: boolean | ScrollIntoViewOptions,
+ ) {
+ scrolled(this.id, (options as ScrollIntoViewOptions)?.block);
+ };
+ scrolled.mockClear();
+ useForm().$reset();
+ useWorkbench().$reset();
+ addAt.value = null;
+ cursorFor.value = null;
+ });
+
+ afterEach(() => wrapper?.unmount());
+
+ it("scrolls the question opened by link into view once it loads", async () => {
+ wrapper = mount(FormDocument, {
+ props: { status: "loading" },
+ global: { stubs },
+ attachTo: document.body,
+ });
+
+ useForm().blocks = blocks;
+ useWorkbench().block = blocks[1];
+ await wrapper.setProps({ status: "loaded" });
+ await wrapper.vm.$nextTick();
+
+ // the smallest scroll, so a question already in view stays put
+ expect(scrolled).toHaveBeenCalledWith("row-b2", "nearest");
+ });
+
+ it("opens and scrolls to a question picked in the outline", async () => {
+ useForm().blocks = blocks;
+ wrapper = mount(FormDocument, {
+ props: { status: "loaded" },
+ global: { stubs },
+ attachTo: document.body,
+ });
+
+ await wrapper.findAll("nav li button")[1].trigger("click");
+ await flushPromises();
+
+ 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");
+ 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("puts Outline and the page's actions side by side, as 44 px icons below sm", () => {
+ useForm().blocks = blocks;
+ wrapper = mount(FormDocument, {
+ props: { status: "loaded" },
+ slots: { actions: "Preview " },
+ global: { stubs },
+ });
+
+ const outline = wrapper
+ .findAll("button")
+ .find((button) => button.text() === "Outline");
+ expect(outline?.classes()).toEqual(
+ expect.arrayContaining(["h-11", "w-11", "sm:w-auto"]),
+ );
+ expect(outline?.get("span").classes()).toEqual(["sr-only", "sm:not-sr-only"]);
+ expect(outline?.element.nextElementSibling?.textContent).toBe("Preview");
+ });
+
+ it("keeps the page's actions on an empty form, without Outline and the count", () => {
+ useForm().blocks = [];
+ wrapper = mount(FormDocument, {
+ props: { status: "loaded" },
+ slots: { actions: "Preview " },
+ global: { stubs },
+ });
+
+ const labels = wrapper.findAll("button").map((button) => button.text());
+ expect(labels).toContain("Preview");
+ expect(labels).not.toContain("Outline");
+ expect(wrapper.text()).not.toContain("0 questions");
+ });
+
+ it("reads out where a moved question went", async () => {
+ useForm().blocks = blocks;
+ wrapper = mount(FormDocument, {
+ props: { status: "loaded" },
+ global: { stubs },
+ attachTo: document.body,
+ });
+
+ moveNote.value = "Moved to position 2 of 2";
+ await wrapper.vm.$nextTick();
+
+ expect(
+ document.querySelector("[aria-live=polite]")?.textContent,
+ ).toBe("Moved to position 2 of 2");
+ });
+
+ it.each([
+ ["the questions", "[list]", blocks],
+ ["no questions", "Add your first question", []],
+ ])("shows the end screen after %s", (_, first, list) => {
+ useForm().blocks = list as FormBlockModel[];
+ wrapper = mount(FormDocument, {
+ props: { status: "loaded" },
+ global: { stubs },
+ });
+ const text = wrapper.text();
+
+ expect(text.indexOf(first)).toBeGreaterThan(-1);
+ expect(text.indexOf("[end]")).toBeGreaterThan(text.indexOf(first));
+ });
+
+ it("starts the end screen with a line, but not against a group's frame", () => {
+ const group = { id: 3, uuid: "g1", type: "group" } as FormBlockModel;
+ const inGroup = {
+ id: 4,
+ uuid: "b4",
+ type: "none",
+ parent_block: "g1",
+ } as FormBlockModel;
+ const line = (list: FormBlockModel[]) => {
+ wrapper?.unmount();
+ useForm().blocks = list;
+ wrapper = mount(FormDocument, {
+ props: { status: "loaded" },
+ global: { stubs },
+ });
+ return wrapper
+ .findAll("div")
+ .find((item) => item.text() === "[end]")!
+ .classes("border-t");
+ };
+
+ expect(line(blocks)).toBe(true);
+ expect(line([...blocks, group, inGroup])).toBe(false);
+ expect(line([])).toBe(false);
+ });
+});
diff --git a/resources/js/components/Factory/Document/FormDocument.vue b/resources/js/components/Factory/Document/FormDocument.vue
new file mode 100644
index 00000000..023babd8
--- /dev/null
+++ b/resources/js/components/Factory/Document/FormDocument.vue
@@ -0,0 +1,243 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ moveNote }}
+
+
+
diff --git a/resources/js/components/Factory/Document/QuestionCard.test.ts b/resources/js/components/Factory/Document/QuestionCard.test.ts
new file mode 100644
index 00000000..6747eabe
--- /dev/null
+++ b/resources/js/components/Factory/Document/QuestionCard.test.ts
@@ -0,0 +1,224 @@
+import { flushPromises, mount, VueWrapper } from "@vue/test-utils";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { useForm, useWorkbench } from "@/stores";
+import QuestionCard from "./QuestionCard.vue";
+import { cursorFor } from "./blockFocus";
+
+const block = {
+ id: 4,
+ uuid: "q4",
+ type: "input-short",
+ message: "Your name?
",
+ interactions: [],
+} as unknown as FormBlockModel;
+
+const stubs = {
+ Editor: true,
+ BlockMenu: true,
+ QuestionSettings: {
+ props: ["id"],
+ template: `settings
`,
+ },
+};
+
+let wrapper: VueWrapper | undefined;
+
+const mountCard = (phone: boolean, withStubs: object = stubs) => {
+ window.matchMedia = vi.fn().mockReturnValue({
+ matches: phone,
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ });
+ wrapper = mount(QuestionCard, {
+ props: { block: useWorkbench().block ?? block, number: 4 },
+ global: { stubs: withStubs },
+ attachTo: document.body,
+ });
+ return wrapper;
+};
+const settingsButton = () =>
+ wrapper!.findAll("button").find((b) => b.text() === "Settings")!;
+
+describe("an open question", () => {
+ beforeEach(() => {
+ globalThis.ResizeObserver ??= class {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ } as unknown as typeof ResizeObserver;
+ useWorkbench().$reset();
+ useForm().$reset();
+ });
+
+ afterEach(() => wrapper?.unmount());
+
+ it.each([
+ ["a wider screen", false],
+ ["a phone", true],
+ ])(
+ "makes its answer field without opening Settings, on %s",
+ async (_, phone) => {
+ const workbench = useWorkbench();
+ useForm().mapping = { "input-short": "input" } as Record<
+ FormBlockType,
+ FormBlockInteractionType
+ >;
+ workbench.block = { ...block, interactions: [] };
+ vi.mocked(workbench.createInteraction).mockResolvedValue({
+ id: 9,
+ type: "input",
+ label: "",
+ } as unknown as FormBlockInteractionModel);
+
+ mountCard(phone, { Editor: true, BlockMenu: true });
+ await flushPromises();
+
+ expect(workbench.createInteraction).toHaveBeenCalledWith("input");
+ },
+ );
+
+ it("shows Required only when the question takes an answer", () => {
+ const stubs = { Editor: true, BlockMenu: true, QuestionSettings: true };
+ wrapper = mount(QuestionCard, {
+ props: { block: { ...block, type: "none" }, number: 1 },
+ global: { stubs },
+ });
+ expect(wrapper.text()).not.toContain("Required");
+
+ wrapper.unmount();
+ wrapper = mount(QuestionCard, {
+ props: { block, number: 4 },
+ global: { stubs },
+ });
+ expect(wrapper.text()).toContain("Required");
+ });
+
+ it("points the Settings button at its panel", () => {
+ mountCard(false, { Editor: true, BlockMenu: true });
+
+ const panel = wrapper!.find(
+ `#${settingsButton().attributes("aria-controls")}`,
+ );
+ expect(panel.exists()).toBe(true);
+ expect(panel.attributes("role")).toBe("group");
+ });
+
+ it("opens its settings inside the card on a wider screen", async () => {
+ mountCard(false);
+ await settingsButton().trigger("click");
+
+ expect(wrapper!.find("#question-q4-settings").isVisible()).toBe(true);
+ expect(document.querySelector("[role=dialog]")).toBeNull();
+ });
+
+ it("opens its settings as a bottom sheet on a phone, and Done closes it", async () => {
+ mountCard(true);
+ await settingsButton().trigger("click");
+ await flushPromises();
+
+ const sheet = document.querySelector("[role=dialog]");
+ expect(sheet?.textContent).toContain("Question 4 settings");
+ expect(sheet?.textContent).toContain("settings");
+
+ const done = [...sheet!.querySelectorAll("button")].find(
+ (b) => b.textContent?.trim() === "Done",
+ );
+ done!.click();
+ await flushPromises();
+
+ // closed but kept, hidden: its answer field settings stay mounted
+ expect(
+ document.querySelector("[role=dialog]")?.hidden,
+ ).toBe(true);
+ expect(document.getElementById("question-q4-settings")).not.toBeNull();
+ // the kept sheet doesn't give focus back by itself
+ expect(document.activeElement).toBe(settingsButton().element);
+ });
+
+ it("gives focus back to Settings when Esc closes the sheet", async () => {
+ mountCard(true);
+ await settingsButton().trigger("click");
+ await flushPromises();
+
+ document
+ .querySelector("[role=dialog]")!
+ .dispatchEvent(
+ new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
+ );
+ await flushPromises();
+
+ expect(
+ document.querySelector("[role=dialog]")?.hidden,
+ ).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);
+ });
+ });
+
+ it("puts the cursor at the end of its text, e.g. in a copy", async () => {
+ // jsdom has no layout, and the editor scrolls to the cursor
+ Range.prototype.getClientRects = () => [] as unknown as DOMRectList;
+ Range.prototype.getBoundingClientRect = () => new DOMRect();
+ cursorFor.value = "q4";
+ mountCard(false, { ...stubs, Editor: false });
+
+ await vi.waitFor(() =>
+ expect(document.activeElement?.getAttribute("role")).toBe(
+ "textbox",
+ ),
+ );
+ const selection = document.getSelection()!;
+ expect(selection.anchorNode?.textContent).toBe("Your name?");
+ expect(selection.anchorOffset).toBe("Your name?".length);
+ });
+});
diff --git a/resources/js/components/Factory/Document/QuestionCard.vue b/resources/js/components/Factory/Document/QuestionCard.vue
new file mode 100644
index 00000000..c3ba4aa0
--- /dev/null
+++ b/resources/js/components/Factory/Document/QuestionCard.vue
@@ -0,0 +1,218 @@
+
+
+
Question {{ number }} text
+
+
+
+
+
+
+
+
+ Answer type
+
+
+
+
+
+ Required
+
+
+
+
+
+
+ Settings
+
+
+
+ Add rule
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Question {{ number }} settings
+
+
+ Done
+
+
+
+
+
+
+
+
+
+
+
diff --git a/resources/js/components/Factory/Document/QuestionSettings.test.ts b/resources/js/components/Factory/Document/QuestionSettings.test.ts
new file mode 100644
index 00000000..912e2696
--- /dev/null
+++ b/resources/js/components/Factory/Document/QuestionSettings.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it } from "vitest";
+import { shallowMount } from "@vue/test-utils";
+import QuestionSettings from "./QuestionSettings.vue";
+
+const heading = (type: string, interactionType?: FormBlockInteractionType) =>
+ shallowMount(QuestionSettings, {
+ props: {
+ id: "settings",
+ block: {
+ uuid: "q1",
+ type,
+ interactions: [],
+ } as unknown as FormBlockModel,
+ interactionType,
+ },
+ }).find("h3");
+
+describe("the settings heading", () => {
+ it.each([
+ ["radio", "button", "Choices"],
+ ["input-short", "input", "Answer field"],
+ ["input-long", "textarea", "Answer field"],
+ ["rating", "range", "Rating"],
+ ["scale", "range", "Scale"],
+ ["date", "date", "Date"],
+ ["input-file", "file", "File upload"],
+ ] as const)(
+ "names a %s question's settings %s β %s",
+ (type, interaction, text) => {
+ expect(heading(type, interaction).text()).toBe(text);
+ expect(heading(type, interaction).attributes("hidden")).toBe(
+ undefined,
+ );
+ },
+ );
+
+ // their panel holds only Short name and Hide this question
+ it.each([
+ ["consent", "consent"],
+ ["none", undefined],
+ ] as const)("shows no heading for a %s question", (type, interaction) => {
+ expect(heading(type, interaction).text()).toBe("Settings");
+ expect(heading(type, interaction).attributes("hidden")).toBe("");
+ });
+});
diff --git a/resources/js/components/Factory/Document/QuestionSettings.vue b/resources/js/components/Factory/Document/QuestionSettings.vue
new file mode 100644
index 00000000..081dc956
--- /dev/null
+++ b/resources/js/components/Factory/Document/QuestionSettings.vue
@@ -0,0 +1,114 @@
+
+
+
+ {{ heading || "Settings" }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Short name
+
+ Used in prefill links, webhooks and the submissions table.
+
+
+
+
+
+
+
+ Hide this question
+
+
+ Hidden questions don't show in the form.
+
+
+
+
+
+
+
+
diff --git a/resources/js/components/Factory/Document/Rules/BlockRules.test.ts b/resources/js/components/Factory/Document/Rules/BlockRules.test.ts
new file mode 100644
index 00000000..9feb216b
--- /dev/null
+++ b/resources/js/components/Factory/Document/Rules/BlockRules.test.ts
@@ -0,0 +1,391 @@
+import { mount, VueWrapper } from "@vue/test-utils";
+import { D9Select } from "@deck9/ui";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { useForm, useLogic } from "@/stores";
+import { questionNumbers } from "../questionSummary";
+import BlockRules from "./BlockRules.vue";
+import { addRule, editing } from "./ruleEditing";
+
+const choices = (...labels: string[]) =>
+ labels.map((label, sequence) => ({
+ type: "button",
+ is_editable: true,
+ label,
+ sequence,
+ }));
+
+const question = (
+ uuid: string,
+ type: string,
+ text: string,
+ extra: Partial = {},
+) =>
+ ({
+ id: uuid.length,
+ uuid,
+ type,
+ message: `${text}
`,
+ parent_block: null,
+ interactions: [],
+ logics: [],
+ ...extra,
+ }) as unknown as FormBlockModel;
+
+const rule = (
+ action: FormBlockLogic["action"],
+ conditions: Partial[],
+ target: string | null = null,
+): FormBlockLogic => ({
+ id: 1,
+ uuid: "rule-1",
+ form_block_id: 1,
+ name: "Rule #1",
+ action,
+ action_payload: target,
+ evaluate: action === "goto" ? "after" : "before",
+ conditions: conditions.map((condition) => ({
+ operator: "equals",
+ value: "",
+ chainOperator: "and",
+ ...condition,
+ })) as FormBlockLogicCondition[],
+});
+
+let blocks: FormBlockModel[];
+const find = (uuid: string) =>
+ useForm().blocks!.find((block) => block.uuid === uuid)!;
+
+beforeEach(() => {
+ blocks = [
+ question("q-come", "radio", "Will you come?", {
+ interactions: choices("Yes", "No, not this time") as never,
+ }),
+ question("q-days", "checkbox", "Which days?", {
+ interactions: choices("Mon", "Tue") as never,
+ }),
+ question("q-rate", "rating", "How was it?"),
+ question("q-date", "date", "When?"),
+ question("q-num", "input-number", "How many?"),
+ question("q-name", "input-short", "Your name?"),
+ question("g-1", "group", "", { title: "Feedback" }),
+ question("q-diff", "input-long", "What should we do differently?", {
+ parent_block: "g-1",
+ }),
+ ];
+ useForm().blocks = blocks;
+ useLogic().$reset();
+ editing.value = null;
+});
+
+const mountRules = (uuid: string) =>
+ mount(BlockRules, {
+ props: { block: find(uuid), numbers: questionNumbers(blocks) },
+ global: { stubs: { teleport: true } },
+ attachTo: document.body,
+ });
+
+const text = (wrapper: VueWrapper) => wrapper.text().replace(/\s+/g, " ");
+
+const select = (wrapper: VueWrapper, name: string) =>
+ wrapper
+ .findAllComponents(D9Select)
+ .find((field) => String(field.props("id")).endsWith(name))!;
+
+const optionLabels = (wrapper: VueWrapper, name: string) =>
+ (select(wrapper, name).props("options") as { label: string }[]).map(
+ (option) => option.label,
+ );
+
+const pick = async (wrapper: VueWrapper, name: string, label: string) => {
+ const field = select(wrapper, name);
+ const option = (field.props("options") as { label: string }[]).find(
+ (item) => item.label === label,
+ );
+ field.vm.$emit("update:modelValue", option);
+ await wrapper.vm.$nextTick();
+};
+
+const button = (wrapper: VueWrapper, label: string) =>
+ wrapper.findAll("button").find((item) => item.text() === label)!;
+
+describe("a rule under its question", () => {
+ it("reads a jump on the question's own answer as a short sentence", () => {
+ find("q-come").logics = [
+ rule(
+ "goto",
+ [{ source: "q-come", value: "No, not this time" }],
+ "q-diff",
+ ),
+ ];
+
+ expect(text(mountRules("q-come"))).toContain(
+ "If βNo, not this timeβ, go to 7 Β· What should we do differently?",
+ );
+ });
+
+ it("names another question by its number, never its id", () => {
+ find("q-name").logics = [
+ rule(
+ "goto",
+ [
+ { source: "q-come", operator: "equalsNot", value: "Yes" },
+ { source: "q-days", operator: "contains", value: "Mon" },
+ ],
+ "q-diff",
+ ),
+ ];
+
+ const sentence = text(mountRules("q-name"));
+
+ expect(sentence).toContain(
+ "If question 1 is not βYesβ and question 2 includes βMonβ, go to 7 Β· What should",
+ );
+ expect(sentence).not.toContain("q-come");
+ });
+
+ it("says when a group is hidden", () => {
+ find("g-1").logics = [
+ rule("hide", [{ source: "q-come", value: "No, not this time" }]),
+ ];
+
+ expect(text(mountRules("g-1"))).toContain(
+ "Hidden if question 1 is βNo, not this timeβ",
+ );
+ });
+
+ it("ignores an old target on a hide rule", () => {
+ find("g-1").logics = [
+ rule("show", [{ source: "q-come", value: "Yes" }], "q-name"),
+ ];
+
+ const sentence = text(mountRules("g-1"));
+
+ expect(sentence).toContain("Shown only if question 1 is βYesβ");
+ expect(sentence).not.toContain("go to");
+ });
+
+ it("shows a rule whose question was deleted in red", () => {
+ find("q-name").logics = [
+ rule("goto", [{ source: "gone", value: "Yes" }], "gone-too"),
+ ];
+
+ const wrapper = mountRules("q-name");
+ const broken = wrapper.findAll(".text-red-700");
+
+ expect(broken).toHaveLength(2);
+ expect(broken[0].text()).toContain("a deleted question");
+ });
+});
+
+describe("editing a rule in place", () => {
+ beforeEach(() => {
+ find("q-name").logics = [
+ rule("goto", [{ source: "q-come", value: "Yes" }], "q-diff"),
+ ];
+ });
+
+ const edit = async () => {
+ const wrapper = mountRules("q-name");
+ await button(wrapper, "Edit").trigger("click");
+ return wrapper;
+ };
+
+ it("turns the sentence into pickers", async () => {
+ const wrapper = await edit();
+
+ expect(text(wrapper)).not.toContain("If question 1 is βYesβ");
+ expect(optionLabels(wrapper, "-0-source")).toEqual([
+ "1 Β· Will you come?",
+ "2 Β· Which days?",
+ "3 Β· How was it?",
+ "4 Β· When?",
+ "5 Β· How many?",
+ "6 Β· Your name?",
+ ]);
+ expect(optionLabels(wrapper, "-target")).toEqual([
+ "7 Β· What should we do differently?",
+ ]);
+ });
+
+ it("offers the choices of a single choice question", async () => {
+ const wrapper = await edit();
+
+ expect(optionLabels(wrapper, "-0-operator")).toEqual(["is", "is not"]);
+ expect(optionLabels(wrapper, "-0-answer")).toEqual([
+ "Yes",
+ "No, not this time",
+ ]);
+ });
+
+ it("offers the range of a rating", async () => {
+ const wrapper = await edit();
+ await pick(wrapper, "-0-source", "3 Β· How was it?");
+
+ expect(optionLabels(wrapper, "-0-operator")).toContain("is more than");
+ expect(optionLabels(wrapper, "-0-answer")).toEqual([
+ "1",
+ "2",
+ "3",
+ "4",
+ "5",
+ ]);
+ });
+
+ it.each([
+ ["4 Β· When?", "date"],
+ ["5 Β· How many?", "number"],
+ ])("gives %s a %s field", async (source, type) => {
+ const wrapper = await edit();
+ await pick(wrapper, "-0-source", source);
+
+ expect(wrapper.find(`input[type=${type}]`).exists()).toBe(true);
+ });
+
+ it("keeps a text field for text, with a case hint", async () => {
+ const wrapper = await edit();
+ await pick(wrapper, "-0-source", "6 Β· Your name?");
+
+ expect(optionLabels(wrapper, "-0-operator")).toHaveLength(6);
+ expect(wrapper.find("input[type=text]").exists()).toBe(true);
+ expect(text(wrapper)).toContain("Upper and lower case must match.");
+ });
+
+ it("can't save a rule whose question was deleted until it's fixed", async () => {
+ find("q-name").logics = [
+ rule("goto", [{ source: "gone", value: "Yes" }], "q-diff"),
+ ];
+ const wrapper = await edit();
+ const save = () => button(wrapper, "Save rule");
+
+ expect(text(wrapper)).toContain("Deleted question");
+ expect(text(wrapper)).toContain(
+ "The question this rule used was deleted. Pick another one.",
+ );
+ expect(save().attributes("disabled")).toBeDefined();
+
+ await save().trigger("click");
+ expect(useLogic().saveSingleRule).not.toHaveBeenCalled();
+
+ await pick(wrapper, "-0-source", "1 Β· Will you come?");
+ expect(save().attributes("disabled")).toBeUndefined();
+ });
+
+ it("saves the picked answer and shows the sentence again", async () => {
+ const logic = useLogic();
+ // an update answers only true
+ vi.mocked(logic.saveSingleRule).mockResolvedValue(true as never);
+ const wrapper = await edit();
+
+ await pick(wrapper, "-0-answer", "No, not this time");
+ await button(wrapper, "Save rule").trigger("click");
+ await vi.waitFor(() => expect(editing.value).toBeNull());
+
+ expect(logic.saveSingleRule).toHaveBeenCalledWith(
+ expect.objectContaining({
+ action: "goto",
+ action_payload: "q-diff",
+ evaluate: "after",
+ conditions: [
+ expect.objectContaining({
+ source: "q-come",
+ operator: "equals",
+ value: "No, not this time",
+ }),
+ ],
+ }),
+ );
+ expect(text(wrapper)).toContain("If question 1 is βNo, not this timeβ");
+ });
+
+ it("closes on Escape, but not when an open list takes it", async () => {
+ const wrapper = await edit();
+ const editor = wrapper.find("[role=group]");
+ const listEscape = new KeyboardEvent("keydown", {
+ key: "Escape",
+ bubbles: true,
+ cancelable: true,
+ });
+ listEscape.preventDefault();
+
+ editor.element.dispatchEvent(listEscape);
+ expect(editing.value).not.toBeNull();
+
+ await editor.trigger("keydown", { key: "Escape" });
+ expect(editing.value).toBeNull();
+ });
+
+ it("shows why the server refused a rule", async () => {
+ vi.mocked(useLogic().saveSingleRule).mockRejectedValue({
+ response: {
+ status: 422,
+ data: { errors: { action_payload: ["Pick a question."] } },
+ },
+ });
+ const wrapper = await edit();
+
+ await button(wrapper, "Save rule").trigger("click");
+
+ await vi.waitFor(() =>
+ expect(wrapper.find("[role=alert]").text()).toContain(
+ "Pick a question.",
+ ),
+ );
+ expect(editing.value).not.toBeNull();
+ });
+
+ it("asks in the app's dialog before it removes a rule", async () => {
+ const logic = useLogic();
+ const wrapper = await edit();
+
+ await button(wrapper, "Remove rule").trigger("click");
+
+ expect(text(wrapper)).toContain("Remove this rule?");
+ expect(logic.removeRule).not.toHaveBeenCalled();
+
+ const confirm = wrapper
+ .findAll("button")
+ .filter((item) => item.text() === "Remove rule")
+ .at(-1)!;
+ await confirm.trigger("click");
+
+ expect(logic.removeRule).toHaveBeenCalledWith(0);
+ });
+});
+
+describe("adding a rule", () => {
+ it("starts a jump on the question's own answer, and Cancel drops it", async () => {
+ const owner = find("q-come");
+ addRule(owner);
+ const wrapper = mountRules("q-come");
+ await wrapper.vm.$nextTick();
+
+ expect(optionLabels(wrapper, "-0-source")).toEqual([
+ "1 Β· Will you come?",
+ ]);
+ expect(select(wrapper, "-0-source").props("modelValue")).toMatchObject({
+ id: "q-come",
+ });
+
+ await button(wrapper, "Cancel").trigger("click");
+ expect(owner.logics).toHaveLength(0);
+ });
+
+ it("ends when the editor is left, and an unsaved rule goes with it", async () => {
+ const owner = find("q-come");
+ addRule(owner);
+ const wrapper = mountRules("q-come");
+ await wrapper.vm.$nextTick();
+
+ wrapper.unmount();
+
+ expect(editing.value).toBeNull();
+ expect(owner.logics).toHaveLength(0);
+ });
+
+ it("hides a group by default", () => {
+ const group = find("g-1");
+ addRule(group);
+
+ expect(group.logics?.[0].action).toBe("hide");
+ });
+});
diff --git a/resources/js/components/Factory/Document/Rules/BlockRules.vue b/resources/js/components/Factory/Document/Rules/BlockRules.vue
new file mode 100644
index 00000000..9e9dc503
--- /dev/null
+++ b/resources/js/components/Factory/Document/Rules/BlockRules.vue
@@ -0,0 +1,54 @@
+
+
+
+
+
diff --git a/resources/js/components/Factory/Document/Rules/DeletedQuestion.vue b/resources/js/components/Factory/Document/Rules/DeletedQuestion.vue
new file mode 100644
index 00000000..167681a4
--- /dev/null
+++ b/resources/js/components/Factory/Document/Rules/DeletedQuestion.vue
@@ -0,0 +1,11 @@
+
+
+ a deleted question
+
+
+
+
diff --git a/resources/js/components/Factory/Document/Rules/RuleEditor.vue b/resources/js/components/Factory/Document/Rules/RuleEditor.vue
new file mode 100644
index 00000000..d7340144
--- /dev/null
+++ b/resources/js/components/Factory/Document/Rules/RuleEditor.vue
@@ -0,0 +1,418 @@
+
+
+
+
+
If
+
+ And or or, condition {{ i + 1 }}
+ (condition.chainOperator = option.id as 'and' | 'or')
+ "
+ />
+
+
+
+ Question for condition {{ i + 1 }}
+ setSource(condition, option.id as string)
+ "
+ />
+
+
+
+ Comparison for condition {{ i + 1 }}
+ (condition.operator = option.id as Operator)
+ "
+ />
+
+
+
+
+ Answer for condition {{ i + 1 }}
+ (condition.value = String(option.id))
+ "
+ />
+
+
+ Answer for condition {{ i + 1 }}
+ (condition.value = String(value ?? ''))
+ "
+ />
+
+
+
+
+
+
+
+
+
+ The question this rule used was deleted. Pick another one.
+
+
+ Upper and lower case must match.
+
+
+
+
+ Add condition
+
+
+
+
then
+
+ Action
+ (draft.action = option.id as FormBlockLogic['action'])
+ "
+ />
+
+
+ Go to question
+ (draft.action_payload = option.id as string)
+ "
+ />
+
+
+
+ The question this rule went to was deleted. Pick another one.
+
+
+
+
+
+
+ Remove rule
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/resources/js/components/Factory/Document/Rules/RuleGutter.test.ts b/resources/js/components/Factory/Document/Rules/RuleGutter.test.ts
new file mode 100644
index 00000000..4aa8b31d
--- /dev/null
+++ b/resources/js/components/Factory/Document/Rules/RuleGutter.test.ts
@@ -0,0 +1,58 @@
+import { mount } from "@vue/test-utils";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { useForm, useWorkbench } from "@/stores";
+import RuleGutter from "./RuleGutter.vue";
+
+const block = (uuid: string, logics: Partial[] = []) =>
+ ({ uuid, type: "radio", logics }) as unknown as FormBlockModel;
+
+const row = (uuid: string, top: number) => {
+ const element = document.createElement("div");
+ element.id = `row-${uuid}`;
+ element.getBoundingClientRect = () => ({ top }) as DOMRect;
+ document.body.append(element);
+};
+
+const lines = () => document.querySelectorAll("[data-rule-line]");
+
+describe("the gutter line of the open question", () => {
+ beforeEach(() => {
+ row("q-1", 100);
+ row("q-2", 200);
+ row("q-3", 300);
+ });
+
+ afterEach(() => {
+ document.body.innerHTML = "";
+ });
+
+ const open = (source: FormBlockModel) => {
+ useForm().blocks = [source, block("q-2"), block("q-3")];
+ useWorkbench().block = source;
+ return mount(RuleGutter, { attachTo: document.body });
+ };
+
+ it("runs from the question down to where its rule jumps", async () => {
+ open(block("q-1", [{ action: "goto", action_payload: "q-3" }]));
+
+ await vi.waitFor(() => expect(lines()).toHaveLength(1));
+ const line = lines()[0] as HTMLElement;
+
+ expect(line.style.top).toBe("124px");
+ expect(line.style.height).toBe("200px");
+ });
+
+ it("draws nothing for hide rules, deleted targets or jumps back up", async () => {
+ const wrapper = open(
+ block("q-2", [
+ { action: "hide", action_payload: "q-3" },
+ { action: "goto", action_payload: "gone" },
+ { action: "goto", action_payload: "q-1" },
+ ]),
+ );
+ await wrapper.vm.$nextTick();
+ await wrapper.vm.$nextTick();
+
+ expect(lines()).toHaveLength(0);
+ });
+});
diff --git a/resources/js/components/Factory/Document/Rules/RuleGutter.vue b/resources/js/components/Factory/Document/Rules/RuleGutter.vue
new file mode 100644
index 00000000..d62df7f9
--- /dev/null
+++ b/resources/js/components/Factory/Document/Rules/RuleGutter.vue
@@ -0,0 +1,81 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/resources/js/components/Factory/Document/Rules/RuleLine.vue b/resources/js/components/Factory/Document/Rules/RuleLine.vue
new file mode 100644
index 00000000..c574f04b
--- /dev/null
+++ b/resources/js/components/Factory/Document/Rules/RuleLine.vue
@@ -0,0 +1,138 @@
+
+
+
+
+
+ {{ "question " }} {{ part.number }}
+ β{{ part.value }}β
+ {{ part.text }}
+
+
+ {{ targetLabel }}
+
+
+
+ Edit
+
+
+
+
+
diff --git a/resources/js/components/Factory/Document/Rules/ruleEditing.ts b/resources/js/components/Factory/Document/Rules/ruleEditing.ts
new file mode 100644
index 00000000..7c0d49dd
--- /dev/null
+++ b/resources/js/components/Factory/Document/Rules/ruleEditing.ts
@@ -0,0 +1,88 @@
+import { ref } from "vue";
+import { useForm, useWorkbench } from "@/stores";
+import { answerKind, operatorsFor } from "./ruleText";
+
+// the one rule that shows as pickers: its block and its place in the block's rules
+export const editing = ref<{ block: string; index: number } | null>(null);
+
+// where the cursor goes back to when a new rule is dropped
+let opener: HTMLElement | null = null;
+
+// ends editing; a new rule that was never saved goes with it
+export function stopEditing() {
+ const current = editing.value;
+ editing.value = null;
+
+ const block = useForm().blocks?.find(
+ (item) => item.uuid === current?.block,
+ );
+ const rule = current && block?.logics?.[current.index];
+
+ if (rule && rule.id === undefined) {
+ block.logics?.splice(current.index, 1);
+ }
+}
+
+function start(block: FormBlockModel, index: number) {
+ const workbench = useWorkbench();
+
+ opener = document.activeElement as HTMLElement | null;
+ editing.value = { block: block.uuid, index };
+
+ if (workbench.block?.id !== block.id) {
+ workbench.putOnWorkbench(block);
+ }
+}
+
+export function editRule(block: FormBlockModel, index: number) {
+ stopEditing();
+ start(block, index);
+}
+
+// a question jumps on its own answer; a group hides
+export function addRule(block: FormBlockModel) {
+ stopEditing();
+
+ const isGroup = block.type === "group";
+ const source = !isGroup && answerKind(block) ? block : null;
+ const logics = block.logics ?? [];
+
+ block.logics = [
+ ...logics,
+ {
+ form_block_id: block.id,
+ name: `Rule ${logics.length + 1}`,
+ action: isGroup ? "hide" : "goto",
+ action_payload: null,
+ evaluate: isGroup ? "before" : "after",
+ conditions: [
+ {
+ source: source?.uuid,
+ operator: operatorsFor(source)[0].id,
+ value: "",
+ chainOperator: "and",
+ },
+ ],
+ },
+ ];
+
+ start(block, logics.length);
+}
+
+// the saved rule takes the place of the one being edited
+export function keepSaved(rule: FormBlockLogic) {
+ const current = editing.value;
+ const block = useForm().blocks?.find(
+ (item) => item.uuid === current?.block,
+ );
+
+ if (current && block?.logics) {
+ block.logics[current.index] = rule;
+ }
+
+ editing.value = null;
+}
+
+export function returnFocus(fallback: HTMLElement | null) {
+ (fallback ?? (opener?.isConnected ? opener : null))?.focus();
+}
diff --git a/resources/js/components/Factory/Document/Rules/ruleText.test.ts b/resources/js/components/Factory/Document/Rules/ruleText.test.ts
new file mode 100644
index 00000000..23bcbc5b
--- /dev/null
+++ b/resources/js/components/Factory/Document/Rules/ruleText.test.ts
@@ -0,0 +1,79 @@
+import { describe, expect, it } from "vitest";
+import { answerOptions, operatorLabel, operatorsFor } from "./ruleText";
+
+const block = (type: string, interactions: unknown[] = []) =>
+ ({ uuid: type, type, interactions }) as unknown as FormBlockModel;
+
+const labels = (type: string) => operatorsFor(block(type)).map((o) => o.label);
+
+describe("rule operators", () => {
+ it("offer is and is not for a single choice", () => {
+ expect(labels("radio")).toEqual(["is", "is not"]);
+ });
+
+ it("offer includes and doesn't include for checkboxes", () => {
+ expect(labels("checkbox")).toEqual(["includes", "doesn't include"]);
+ });
+
+ it.each(["input-number", "rating", "scale", "date"])(
+ "offer comparisons for %s",
+ (type) => {
+ expect(labels(type)).toEqual([
+ "is",
+ "is not",
+ "is less than",
+ "is more than",
+ ]);
+ },
+ );
+
+ it("offer all six for text", () => {
+ expect(labels("input-short")).toEqual([
+ "is",
+ "is not",
+ "contains",
+ "doesn't contain",
+ "is less than",
+ "is more than",
+ ]);
+ });
+
+ it("name a stored operator in words", () => {
+ expect(operatorLabel("equals", block("radio"))).toBe("is");
+ expect(operatorLabel("contains", block("checkbox"))).toBe("includes");
+ expect(operatorLabel("isGreaterThan", undefined)).toBe("is more than");
+ });
+});
+
+describe("rule answers", () => {
+ it("are the choices of a choice question, in order", () => {
+ const question = block("radio", [
+ { type: "button", is_editable: true, label: "No", sequence: 2 },
+ { type: "button", is_editable: true, label: "Yes", sequence: 1 },
+ { type: "button", is_editable: false, label: "Other", sequence: 3 },
+ { type: "button", is_editable: true, label: "", sequence: 4 },
+ ]);
+
+ expect(answerOptions(question)).toEqual(["Yes", "No"]);
+ });
+
+ it("are the range of a rating or scale", () => {
+ const scale = block("scale", [
+ { type: "range", options: { start: 0, end: 3 } },
+ ]);
+
+ expect(answerOptions(scale)).toEqual(["0", "1", "2", "3"]);
+ expect(answerOptions(block("rating"))).toEqual([
+ "1",
+ "2",
+ "3",
+ "4",
+ "5",
+ ]);
+ });
+
+ it("are free for text, number and date", () => {
+ expect(answerOptions(block("input-short"))).toEqual([]);
+ expect(answerOptions(block("date"))).toEqual([]);
+ });
+});
diff --git a/resources/js/components/Factory/Document/Rules/ruleText.ts b/resources/js/components/Factory/Document/Rules/ruleText.ts
new file mode 100644
index 00000000..95bc4065
--- /dev/null
+++ b/resources/js/components/Factory/Document/Rules/ruleText.ts
@@ -0,0 +1,126 @@
+import sortBy from "lodash/sortBy";
+
+// what a rule can compare a question's answer with
+export type AnswerKind =
+ | "choice"
+ | "multi"
+ | "range"
+ | "number"
+ | "date"
+ | "text";
+
+const kinds: Partial> = {
+ radio: "choice",
+ checkbox: "multi",
+ rating: "range",
+ scale: "range",
+ "input-number": "number",
+ date: "date",
+ "input-short": "text",
+ "input-long": "text",
+ "input-email": "text",
+ "input-link": "text",
+ "input-phone": "text",
+ "input-secret": "text",
+};
+
+const comparisons: Operator[] = [
+ "equals",
+ "equalsNot",
+ "isLowerThan",
+ "isGreaterThan",
+];
+
+const operatorsByKind: Record = {
+ choice: ["equals", "equalsNot"],
+ // checkbox answers are joined into one text, so "includes" is a contains
+ multi: ["contains", "containsNot"],
+ range: comparisons,
+ number: comparisons,
+ date: comparisons,
+ text: [
+ "equals",
+ "equalsNot",
+ "contains",
+ "containsNot",
+ "isLowerThan",
+ "isGreaterThan",
+ ],
+};
+
+const words: Record = {
+ equals: "is",
+ equalsNot: "is not",
+ contains: "contains",
+ containsNot: "doesn't contain",
+ isLowerThan: "is less than",
+ isGreaterThan: "is more than",
+};
+
+const checkboxWords: Partial> = {
+ contains: "includes",
+ containsNot: "doesn't include",
+};
+
+export function answerKind(block?: FormBlockModel | null): AnswerKind | null {
+ return (block && kinds[block.type]) ?? null;
+}
+
+export function operatorLabel(
+ operator: Operator | undefined,
+ block?: FormBlockModel | null,
+): string {
+ if (!operator) {
+ return "is";
+ }
+
+ return (
+ (answerKind(block) === "multi" && checkboxWords[operator]) ||
+ words[operator]
+ );
+}
+
+export function operatorsFor(block?: FormBlockModel | null) {
+ const kind = answerKind(block);
+
+ return (kind ? operatorsByKind[kind] : operatorsByKind.text).map((id) => ({
+ id,
+ label: operatorLabel(id, block),
+ }));
+}
+
+// the answers a choice, rating or scale question can give; free fields give none
+export function answerOptions(block?: FormBlockModel | null): string[] {
+ const interactions = block?.interactions ?? [];
+ const kind = answerKind(block);
+
+ if (kind === "choice" || kind === "multi") {
+ const labels = sortBy(interactions, "sequence")
+ .filter(
+ (item) =>
+ item.type === "button" && item.is_editable && item.label,
+ )
+ .map((item) => item.label as string);
+
+ return [...new Set(labels)];
+ }
+
+ if (kind === "range") {
+ const range = interactions.find((item) => item.type === "range");
+ const start = Number(range?.options?.start ?? 1);
+ const end = Number(range?.options?.end ?? 5);
+
+ return Array.from({ length: Math.max(end - start + 1, 0) }, (_, i) =>
+ String(start + i),
+ );
+ }
+
+ return [];
+}
+
+// the questions a block's rules jump to
+export function jumpTargets(block?: FormBlockModel | null): string[] {
+ return (block?.logics ?? [])
+ .filter((rule) => rule.action === "goto" && rule.action_payload)
+ .map((rule) => rule.action_payload as string);
+}
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/Document/blockFocus.test.ts b/resources/js/components/Factory/Document/blockFocus.test.ts
new file mode 100644
index 00000000..6b243339
--- /dev/null
+++ b/resources/js/components/Factory/Document/blockFocus.test.ts
@@ -0,0 +1,35 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { useForm, useWorkbench } from "@/stores";
+import { cursorFor, useAddBlock } from "./blockFocus";
+
+const open = { id: 3, uuid: "q3", type: "radio" } as FormBlockModel;
+const added = { id: 4, uuid: "q4", type: "radio" } as FormBlockModel;
+
+describe("adding a question", () => {
+ beforeEach(() => {
+ useWorkbench().$reset();
+ cursorFor.value = null;
+ });
+
+ it("puts the cursor in the new question", async () => {
+ const workbench = useWorkbench();
+ workbench.block = open;
+ vi.mocked(useForm().createFormBlock).mockImplementation(async () => {
+ workbench.block = added;
+ });
+
+ await useAddBlock().addBlock(open);
+
+ expect(cursorFor.value).toBe("q4");
+ });
+
+ it("leaves the cursor alone when the question can't be made", async () => {
+ const workbench = useWorkbench();
+ workbench.block = open;
+ vi.mocked(useForm().createFormBlock).mockResolvedValue(undefined);
+
+ await useAddBlock().addBlock(open);
+
+ expect(cursorFor.value).toBeNull();
+ });
+});
diff --git a/resources/js/components/Factory/Document/blockFocus.ts b/resources/js/components/Factory/Document/blockFocus.ts
new file mode 100644
index 00000000..24e3458e
--- /dev/null
+++ b/resources/js/components/Factory/Document/blockFocus.ts
@@ -0,0 +1,87 @@
+import { nextTick, ref } from "vue";
+import { useForm, useWorkbench } from "@/stores";
+
+// the question whose card takes the cursor once it shows: its text editor isn't there at once
+export const cursorFor = ref(null);
+
+// the end screen's card is open: it's no block, so the workbench doesn't hold it
+export const endScreenOpen = ref(false);
+
+// puts the cursor in a question's text, or in a group's name
+export async function focusBlock(block: FormBlockModel | null): Promise {
+ if (!block) {
+ return false;
+ }
+
+ if (block.type !== "group") {
+ cursorFor.value = block.uuid;
+ return true;
+ }
+
+ await nextTick();
+ const name = document.getElementById(`group-${block.uuid}-name`);
+ name?.focus();
+
+ return name !== null;
+}
+
+// brings a question or group into view, by default to the top of the document
+export async function scrollToBlock(
+ uuid: string,
+ block: ScrollLogicalPosition = "start",
+) {
+ await nextTick();
+ document.getElementById(`row-${uuid}`)?.scrollIntoView({ block });
+}
+
+// a new block or a copy opens with the cursor in it
+export function useAddBlock() {
+ const store = useForm();
+ const workbench = useWorkbench();
+
+ const opens = async (create: () => Promise) => {
+ const before = workbench.block;
+ await create();
+
+ // a failed create keeps the open block: leave the cursor where it is
+ if (workbench.block !== before) {
+ await focusBlock(workbench.block);
+ }
+ };
+
+ const addBlock = (...args: Parameters) =>
+ opens(() => store.createFormBlock(...args));
+
+ const duplicateBlock = (block: FormBlockModel) =>
+ opens(() => store.duplicateFormBlock(block));
+
+ return { addBlock, duplicateBlock };
+}
+
+// the blocks around one as the document shows them, nearest first: those
+// after it, then those before it. A group's own questions go with it.
+export function blocksAround(block: FormBlockModel): FormBlockModel[] {
+ const shown = (nodes: TreeNode[]): FormBlockModel[] =>
+ nodes.flatMap((node) => [node.block, ...shown(node.children)]);
+ const order = shown(useForm().blocksTree).filter(
+ (item) => item.parent_block !== block.uuid,
+ );
+ const at = order.findIndex((item) => item.id === block.id);
+
+ return [...order.slice(at + 1), ...order.slice(0, at).reverse()];
+}
+
+// focuses a block's row: a closed question's button, an open one's text, a group's name
+export async function focusRow(block: FormBlockModel): Promise {
+ if (block.type === "group" || useWorkbench().block?.id === block.id) {
+ return focusBlock(block);
+ }
+
+ await nextTick();
+ const button = document.querySelector(
+ `#row-${block.uuid} > button`,
+ );
+ button?.focus();
+
+ return button !== null;
+}
diff --git a/resources/js/components/Factory/Document/blockMove.test.ts b/resources/js/components/Factory/Document/blockMove.test.ts
new file mode 100644
index 00000000..f6dfe996
--- /dev/null
+++ b/resources/js/components/Factory/Document/blockMove.test.ts
@@ -0,0 +1,106 @@
+import { createPinia, setActivePinia } from "pinia";
+import { nextTick } from "vue";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { useForm } from "@/stores";
+import { moveNote, useMoveBlock } from "./blockMove";
+
+vi.mock("@/api/blocks", async (original) => ({
+ ...(await original()),
+ callUpdateBlockSequence: vi.fn().mockResolvedValue({ status: 204 }),
+}));
+
+const make = () =>
+ [
+ { id: 1, uuid: "q1", type: "radio" },
+ { id: 2, uuid: "q2", type: "radio" },
+ { id: 3, uuid: "g1", type: "group", title: "Food" },
+ { id: 4, uuid: "c1", type: "radio", parent_block: "g1" },
+ { id: 5, uuid: "c2", type: "radio", parent_block: "g1" },
+ { id: 6, uuid: "q3", type: "radio" },
+ ] as FormBlockModel[];
+
+// the document's order: top-level blocks, a group's questions after it
+const order = () => {
+ const blocks = useForm().blocks ?? [];
+
+ return blocks
+ .filter((block) => !block.parent_block)
+ .map((block) =>
+ block.type === "group"
+ ? `${block.uuid}[${blocks
+ .filter((child) => child.parent_block === block.uuid)
+ .map((child) => child.uuid)
+ .join(" ")}]`
+ : block.uuid,
+ )
+ .join(" ");
+};
+
+const move = async (uuid: string, step: -1 | 1) => {
+ const block = useForm().blocks!.find((item) => item.uuid === uuid)!;
+ const moved = await useMoveBlock().moveBlock(block, step);
+ await nextTick();
+
+ return moved;
+};
+
+describe("moving a block one step", () => {
+ beforeEach(() => {
+ setActivePinia(createPinia());
+ useForm().form = { uuid: "f1" } as FormModel;
+ useForm().blocks = make();
+ });
+
+ it("swaps a question with its neighbour and says where it went", async () => {
+ expect(await move("q1", 1)).toBe(true);
+
+ expect(order()).toBe("q2 q1 g1[c1 c2] q3");
+ expect(moveNote.value).toBe("Moved to position 2 of 5");
+ });
+
+ it("steps a question into a group next to it", async () => {
+ await move("q2", 1);
+ expect(order()).toBe("q1 g1[q2 c1 c2] q3");
+ expect(moveNote.value).toBe("Moved into group Food, to position 2 of 5");
+
+ await move("q3", -1);
+ expect(order()).toBe("q1 g1[q2 c1 c2 q3]");
+ expect(moveNote.value).toBe("Moved into group Food, to position 5 of 5");
+ });
+
+ it("steps a question out at the ends of its group", async () => {
+ await move("c1", -1);
+ expect(order()).toBe("q1 q2 c1 g1[c2] q3");
+ expect(moveNote.value).toBe(
+ "Moved out of group Food, to position 3 of 5",
+ );
+
+ await move("c2", 1);
+ expect(order()).toBe("q1 q2 c1 g1[] c2 q3");
+ expect(moveNote.value).toBe(
+ "Moved out of group Food, to position 4 of 5",
+ );
+ });
+
+ it("moves a group over a whole block, with its questions", async () => {
+ await move("g1", -1);
+
+ expect(order()).toBe("q1 g1[c1 c2] q2 q3");
+ expect(moveNote.value).toBe("Moved group Food to position 2 of 4");
+ });
+
+ it("stays put at the top and the bottom, and says so", async () => {
+ const { canMove } = useMoveBlock();
+ const [first, , , , , last] = useForm().blocks!;
+
+ expect(canMove(first, -1)).toBe(false);
+ expect(canMove(last, 1)).toBe(false);
+ expect(canMove(first, 1)).toBe(true);
+
+ expect(await move("q1", -1)).toBe(false);
+ expect(moveNote.value).toBe("Already at the top");
+ expect(await move("q3", 1)).toBe(false);
+ expect(moveNote.value).toBe("Already at the bottom");
+ expect(order()).toBe("q1 q2 g1[c1 c2] q3");
+ });
+});
diff --git a/resources/js/components/Factory/Document/blockMove.ts b/resources/js/components/Factory/Document/blockMove.ts
new file mode 100644
index 00000000..807ea456
--- /dev/null
+++ b/resources/js/components/Factory/Document/blockMove.ts
@@ -0,0 +1,131 @@
+import { nextTick, ref } from "vue";
+import { useForm, useWorkbench } from "@/stores";
+import { questionNumbers } from "./questionSummary";
+import { cursorFor } from "./blockFocus";
+
+// what the document's live region says after a move
+export const moveNote = ref("");
+
+export type Step = -1 | 1;
+
+// reads a short text out in the document's live region
+export async function announce(text: string) {
+ // a repeated text is only read out again after a change
+ moveNote.value = "";
+ await nextTick();
+ moveNote.value = text;
+}
+
+// where one step takes a block: a question steps into a group next to it and
+// out at its ends, a group steps over a whole block
+function stepTarget(
+ blocks: FormBlockModel[],
+ block: FormBlockModel,
+ step: Step,
+): { scope: string | null; index: number } | null {
+ const inScope = (parent: string | null) =>
+ blocks.filter((item) => (item.parent_block ?? null) === parent);
+ const parent = block.parent_block ?? null;
+ const siblings = inScope(parent);
+ const index = siblings.findIndex((item) => item.id === block.id) + step;
+ const next = siblings[index];
+
+ if (block.type !== "group") {
+ if (parent && !next) {
+ const group = inScope(null).findIndex(
+ (item) => item.uuid === parent,
+ );
+
+ return { scope: null, index: group + (step > 0 ? 1 : 0) };
+ }
+
+ if (!parent && next?.type === "group") {
+ return {
+ scope: next.uuid,
+ index: step > 0 ? 0 : inScope(next.uuid).length,
+ };
+ }
+ }
+
+ return next ? { scope: parent, index } : null;
+}
+
+export function useMoveBlock() {
+ const store = useForm();
+ const workbench = useWorkbench();
+
+ const canMove = (block: FormBlockModel, step: Step) =>
+ stepTarget(store.blocks ?? [], block, step) !== null;
+
+ const moveBlock = async (block: FormBlockModel, step: Step) => {
+ const target = stepTarget(store.blocks ?? [], block, step);
+
+ if (!target) {
+ await announce(step < 0 ? "Already at the top" : "Already at the bottom");
+ return false;
+ }
+
+ const from = block.parent_block ?? null;
+ const focused = document.activeElement as HTMLElement | null;
+
+ store.changeBlockSequence(target.scope ?? false, target.index, block);
+ await nextTick();
+
+ // a moved element loses focus; one in another list is a new element
+ if (focused?.isConnected) {
+ focused.focus();
+ } else if (workbench.block?.id === block.id) {
+ cursorFor.value = block.uuid;
+ } else {
+ document
+ .getElementById(`row-${block.uuid}`)
+ ?.querySelector(":scope > button")
+ ?.focus();
+ }
+
+ const blocks = store.blocks ?? [];
+ const numbers = questionNumbers(blocks);
+ const top = blocks.filter((item) => !item.parent_block);
+ const groupName = (uuid: string | null) =>
+ blocks.find((item) => item.uuid === uuid)?.title || "Group";
+
+ if (block.type === "group") {
+ await announce(
+ `Moved group ${groupName(block.uuid)} to position ${top.findIndex((item) => item.id === block.id) + 1} of ${top.length}`,
+ );
+ return true;
+ }
+
+ const to = `to position ${numbers[block.uuid]} of ${Object.keys(numbers).length}`;
+
+ if (target.scope === from) {
+ await announce(`Moved ${to}`);
+ } else if (target.scope) {
+ await announce(`Moved into group ${groupName(target.scope)}, ${to}`);
+ } else {
+ await announce(`Moved out of group ${groupName(from)}, ${to}`);
+ }
+
+ return true;
+ };
+
+ // Alt+Up/Down moves a block
+ const onMoveKey = (event: KeyboardEvent, block: FormBlockModel) => {
+ const step = { ArrowUp: -1, ArrowDown: 1 }[event.key] as Step;
+
+ if (
+ !step ||
+ !event.altKey ||
+ event.shiftKey ||
+ event.ctrlKey ||
+ event.metaKey
+ ) {
+ return;
+ }
+
+ event.preventDefault();
+ moveBlock(block, step);
+ };
+
+ return { canMove, moveBlock, onMoveKey };
+}
diff --git a/resources/js/components/Factory/Document/questionSummary.test.ts b/resources/js/components/Factory/Document/questionSummary.test.ts
new file mode 100644
index 00000000..309eccd6
--- /dev/null
+++ b/resources/js/components/Factory/Document/questionSummary.test.ts
@@ -0,0 +1,38 @@
+import { describe, expect, it } from "vitest";
+import { plainText, questionNumbers } from "./questionSummary";
+
+const block = (uuid: string, type = "radio", parent_block: string | null = null) =>
+ ({ uuid, type, parent_block }) as FormBlockModel;
+
+describe("plain question text", () => {
+ it("drops tags and keeps a space between paragraphs", () => {
+ expect(
+ plainText("Hi & welcome How was it ?
"),
+ ).toBe("Hi & welcome How was it?");
+ });
+
+ it("is empty for no text or only an image", () => {
+ expect(plainText(null)).toBe("");
+ expect(plainText(' ')).toBe("");
+ });
+});
+
+describe("question numbers", () => {
+ it("counts on through groups and skips the group itself", () => {
+ const numbers = questionNumbers([
+ block("a"),
+ block("g", "group"),
+ block("c"),
+ block("b", "radio", "g"),
+ block("b2", "date", "g"),
+ ]);
+
+ expect(numbers).toEqual({ a: 1, b: 2, b2: 3, c: 4 });
+ });
+
+ it("gives an empty group nothing", () => {
+ expect(questionNumbers([block("g", "group"), block("a")])).toEqual({
+ a: 1,
+ });
+ });
+});
diff --git a/resources/js/components/Factory/Document/questionSummary.ts b/resources/js/components/Factory/Document/questionSummary.ts
new file mode 100644
index 00000000..a34648a0
--- /dev/null
+++ b/resources/js/components/Factory/Document/questionSummary.ts
@@ -0,0 +1,32 @@
+// the text a closed question shows: no HTML, one space where a paragraph ended
+export function plainText(html: string | null): string {
+ if (!html) {
+ return "";
+ }
+
+ const spaced = html.replace(/<\/(p|h[1-6]|li|blockquote)>| /gi, "$& ");
+ const doc = new DOMParser().parseFromString(spaced, "text/html");
+
+ return (doc.body.textContent ?? "").replace(/\s+/g, " ").trim();
+}
+
+// questions count on through groups; a group itself gets no number
+export function questionNumbers(
+ blocks: FormBlockModel[],
+): Record {
+ const numbers: Record = {};
+ const inScope = (parent: string | null) =>
+ blocks.filter((block) => (block.parent_block ?? null) === parent);
+ let next = 1;
+
+ for (const block of inScope(null)) {
+ const questions =
+ block.type === "group" ? inScope(block.uuid) : [block];
+
+ for (const question of questions) {
+ numbers[question.uuid] = next++;
+ }
+ }
+
+ return numbers;
+}
diff --git a/resources/js/components/Factory/Main/BlockInteractions.vue b/resources/js/components/Factory/Main/BlockInteractions.vue
deleted file mode 100644
index 36828819..00000000
--- a/resources/js/components/Factory/Main/BlockInteractions.vue
+++ /dev/null
@@ -1,26 +0,0 @@
-
-
-
Input Settings
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/resources/js/components/Factory/Main/BlockMessage.vue b/resources/js/components/Factory/Main/BlockMessage.vue
deleted file mode 100644
index 71061471..00000000
--- a/resources/js/components/Factory/Main/BlockMessage.vue
+++ /dev/null
@@ -1,22 +0,0 @@
-
-
-
Message
-
-
-
-
-
diff --git a/resources/js/components/Factory/Main/BlockMetaHeader.vue b/resources/js/components/Factory/Main/BlockMetaHeader.vue
deleted file mode 100644
index 597ba426..00000000
--- a/resources/js/components/Factory/Main/BlockMetaHeader.vue
+++ /dev/null
@@ -1,34 +0,0 @@
-
-
-
- ID
-
- {{ block.uuid }}
-
-
-
- Last Change
- {{ formattedUpdatedAt }}
-
-
-
-
-
diff --git a/resources/js/components/Factory/Main/BlockType.vue b/resources/js/components/Factory/Main/BlockType.vue
deleted file mode 100644
index 2de35c6b..00000000
--- a/resources/js/components/Factory/Main/BlockType.vue
+++ /dev/null
@@ -1,65 +0,0 @@
-
-
-
- Type
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/resources/js/components/Factory/Main/ChoiceRowActions.vue b/resources/js/components/Factory/Main/ChoiceRowActions.vue
new file mode 100644
index 00000000..fa0d0587
--- /dev/null
+++ b/resources/js/components/Factory/Main/ChoiceRowActions.vue
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/resources/js/components/Factory/Main/ConfigureClicks.test.ts b/resources/js/components/Factory/Main/ConfigureClicks.test.ts
new file mode 100644
index 00000000..12ea86c4
--- /dev/null
+++ b/resources/js/components/Factory/Main/ConfigureClicks.test.ts
@@ -0,0 +1,199 @@
+import { flushPromises, mount, VueWrapper } from "@vue/test-utils";
+import { AxiosResponse } from "axios";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { callGetFormBlocks } from "@/api/blocks";
+import { useForm, useWorkbench } from "@/stores";
+import ConfigureClicks from "./ConfigureClicks.vue";
+import { moveNote } from "@/components/Factory/Document/blockMove";
+
+vi.mock("@/api/blocks", async (importOriginal) => ({
+ ...(await importOriginal()),
+ callGetFormBlocks: vi.fn(),
+}));
+
+const choice = (id: number, label: string) =>
+ ({
+ id,
+ type: "button",
+ label,
+ sequence: id,
+ is_editable: true,
+ }) as FormBlockInteractionModel;
+
+let wrapper: VueWrapper | undefined;
+
+const mountChoices = (...labels: string[]) => {
+ return mountList(labels.map((label, i) => choice(i + 1, label)));
+};
+
+// a custom answer ("Other") is a hidden row in the same list
+const custom = (sequence: number) =>
+ ({
+ id: 99,
+ type: "button",
+ label: "Other",
+ sequence,
+ is_editable: false,
+ }) as FormBlockInteractionModel;
+
+const mountList = (interactions: FormBlockInteractionModel[]) => {
+ const workbench = useWorkbench();
+ useForm().mapping = { radio: "button" } as Record<
+ FormBlockType,
+ FormBlockInteractionType
+ >;
+ workbench.block = {
+ id: 1,
+ uuid: "q1",
+ type: "radio",
+ interactions,
+ } as unknown as FormBlockModel;
+ vi.mocked(workbench.deleteInteraction).mockImplementation(
+ async ({ id }) => {
+ const list = workbench.block!.interactions!;
+ list.splice(
+ list.findIndex((item) => item.id === id),
+ 1,
+ );
+ },
+ );
+
+ // nobody picked these choices yet, so a delete doesn't ask first
+ useForm().form = { uuid: "form1" } as FormModel;
+ vi.mocked(callGetFormBlocks).mockResolvedValue({
+ data: [
+ {
+ ...workbench.block,
+ interactions: interactions.map((item) => ({
+ ...item,
+ responses_count: 0,
+ })),
+ },
+ ],
+ } as unknown as AxiosResponse);
+
+ wrapper = mount(ConfigureClicks, { attachTo: document.body });
+ return wrapper;
+};
+
+const deleteButton = (label: string) =>
+ wrapper!.find(`button[aria-label='Delete choice β${label}β']`);
+
+describe("deleting a choice in its row", () => {
+ beforeEach(() => {
+ useWorkbench().$reset();
+ useForm().$reset();
+ });
+
+ afterEach(() => wrapper?.unmount());
+
+ it("moves focus to the choice above", async () => {
+ mountChoices("Yes", "No", "Maybe");
+
+ await deleteButton("No").trigger("click");
+ await flushPromises();
+
+ expect(document.activeElement?.id).toBe("1_label");
+ });
+
+ it("moves focus to the new first choice when the first is gone", async () => {
+ mountChoices("Yes", "No");
+
+ await deleteButton("Yes").trigger("click");
+ await flushPromises();
+
+ expect(document.activeElement?.id).toBe("2_label");
+ });
+
+ it("skips a custom answer row when it moves focus", async () => {
+ mountList([custom(0), choice(1, "Yes"), choice(2, "No")]);
+
+ await deleteButton("No").trigger("click");
+ await flushPromises();
+
+ expect(document.activeElement?.id).toBe("1_label");
+ });
+
+ it("moves focus to Add choice when no choice is left", async () => {
+ mountChoices("Yes");
+
+ await deleteButton("Yes").trigger("click");
+ await flushPromises();
+
+ const focused = document.activeElement;
+ expect(focused?.tagName).toBe("BUTTON");
+ expect(focused?.textContent).toContain("Add choice");
+ });
+});
+
+describe("Enter on the last choice", () => {
+ beforeEach(() => {
+ useWorkbench().$reset();
+ useForm().$reset();
+ });
+
+ afterEach(() => wrapper?.unmount());
+
+ it("adds a choice, also with a custom answer last", async () => {
+ mountList([choice(1, "Yes"), choice(2, "No"), custom(3)]);
+ vi.mocked(useWorkbench().createInteraction).mockResolvedValue(
+ undefined,
+ );
+
+ await wrapper!.find("[id='2_label']").trigger("keyup", { key: "Enter" });
+ await flushPromises();
+
+ expect(useWorkbench().createInteraction).toHaveBeenCalledWith("button");
+ });
+});
+
+describe("moving a choice with Alt+Up/Down", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ useWorkbench().$reset();
+ useForm().$reset();
+ });
+
+ afterEach(() => wrapper?.unmount());
+
+ const label = (id: number) =>
+ wrapper!.find(`[id='${id}_label']`);
+
+ it("moves the choice, keeps focus on it and says where it went", async () => {
+ const workbench = useWorkbench();
+ mountChoices("Yes", "No", "Maybe");
+ vi.mocked(workbench.changeInteractionSequence).mockImplementation(
+ async (from, to) => {
+ const list = [...workbench.block!.interactions!].sort(
+ (a, b) => a.sequence - b.sequence,
+ );
+ list.splice(to, 0, list.splice(from, 1)[0]);
+ list.forEach((item, index) => (item.sequence = index));
+ },
+ );
+ // the question around it must not move as well
+ const outside = vi.fn();
+ document.body.addEventListener("keydown", outside);
+
+ label(2).element.focus();
+ await label(2).trigger("keydown", { key: "ArrowUp", altKey: true });
+ await label(2).trigger("keyup", { key: "ArrowUp", altKey: true });
+ await flushPromises();
+
+ expect(workbench.changeInteractionSequence).toHaveBeenCalledWith(1, 0);
+ expect((document.activeElement as HTMLInputElement).value).toBe("No");
+ expect(moveNote.value).toBe("Moved choice to position 1 of 3");
+ expect(outside).not.toHaveBeenCalled();
+ document.body.removeEventListener("keydown", outside);
+ });
+
+ it("stays put at the top and says so", async () => {
+ mountChoices("Yes", "No");
+
+ await label(1).trigger("keydown", { key: "ArrowUp", altKey: true });
+ await flushPromises();
+
+ expect(useWorkbench().changeInteractionSequence).not.toHaveBeenCalled();
+ expect(moveNote.value).toBe("Already at the top");
+ });
+});
diff --git a/resources/js/components/Factory/Main/ConfigureClicks.vue b/resources/js/components/Factory/Main/ConfigureClicks.vue
index 140dbd03..16fa6408 100644
--- a/resources/js/components/Factory/Main/ConfigureClicks.vue
+++ b/resources/js/components/Factory/Main/ConfigureClicks.vue
@@ -2,14 +2,13 @@
@@ -21,54 +20,51 @@
@next="focusNextItem"
@nextSoft="focusNextItemSoft"
@previous="focusPreviousItem"
- @onDelete="focusNeighborItem"
+ @onDelete="deleteItem"
+ @move="moveItem"
/>
-
-
-
-
-
-
-
+
+
+ Add choice
+ Enter adds a choice.
+
diff --git a/resources/js/components/Factory/Main/ConfigureConsents.vue b/resources/js/components/Factory/Main/ConfigureConsents.vue
index 34b4916e..35f9a6ac 100644
--- a/resources/js/components/Factory/Main/ConfigureConsents.vue
+++ b/resources/js/components/Factory/Main/ConfigureConsents.vue
@@ -2,14 +2,13 @@
@@ -21,26 +20,28 @@
v-bind="{ item, index }"
:key="`${item.id}-${index}`"
:ref="bindTemplateRefsForTraversables.bind(index)"
+ @onDelete="deleteItem"
/>
-
-
+
+ >
+
+ Add consent
+
diff --git a/resources/js/components/Factory/Main/InteractionHoverActions.vue b/resources/js/components/Factory/Main/InteractionHoverActions.vue
deleted file mode 100644
index cde6c160..00000000
--- a/resources/js/components/Factory/Main/InteractionHoverActions.vue
+++ /dev/null
@@ -1,24 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/resources/js/components/Factory/Main/Interactions/ClickInteraction.vue b/resources/js/components/Factory/Main/Interactions/ClickInteraction.vue
index 13bc9d20..03d63e85 100644
--- a/resources/js/components/Factory/Main/Interactions/ClickInteraction.vue
+++ b/resources/js/components/Factory/Main/Interactions/ClickInteraction.vue
@@ -1,43 +1,45 @@
-
-
-
-
-
-
-
+
+
+
+
+
-
-
import { D9Input } from "@deck9/ui";
-import InteractionHoverActions from "@/components/Factory/Main/InteractionHoverActions.vue";
+import ChoiceRowActions from "@/components/Factory/Main/ChoiceRowActions.vue";
import IndexItem from "@/components/Factory/Shared/IndexItem.vue";
import DeleteChoiceDialog from "@/components/Factory/Shared/DeleteChoiceDialog.vue";
import { useDeleteChoice } from "@/components/Factory/Shared/useDeleteBlock";
import { useWorkbench } from "@/stores";
import { Ref, ref, watch } from "vue";
+import type { Step } from "@/components/Factory/Document/blockMove";
const workbench = useWorkbench();
@@ -74,8 +77,12 @@ const emit = defineEmits<{
(e: "nextSoft", index: number): void;
(e: "previous", index: number): void;
(e: "onDelete", index: number): void;
+ (e: "move", index: number, step: Step): void;
}>();
+// the row draws the border, so its fields are bare
+const field = "!border-0 !bg-transparent !px-1 !py-2 focus:!ring-0";
+
const labelElement = ref | null>(null);
const label: Ref = ref(props.item.label);
diff --git a/resources/js/components/Factory/Main/Interactions/ConsentInteraction.vue b/resources/js/components/Factory/Main/Interactions/ConsentInteraction.vue
index 961503ab..0be0b728 100644
--- a/resources/js/components/Factory/Main/Interactions/ConsentInteraction.vue
+++ b/resources/js/components/Factory/Main/Interactions/ConsentInteraction.vue
@@ -4,8 +4,13 @@
:class="[index === 0 ? 'mt-4' : 'mt-8']"
>
-
Consent Policy
+ Consent
+
Consent required Must be ticked
-
import { D9Input, D9Textarea, D9Switch } from "@deck9/ui";
-import InteractionHoverActions from "@/components/Factory/Main/InteractionHoverActions.vue";
+import ChoiceRowActions from "@/components/Factory/Main/ChoiceRowActions.vue";
import IndexItem from "@/components/Factory/Shared/IndexItem.vue";
import DeleteChoiceDialog from "@/components/Factory/Shared/DeleteChoiceDialog.vue";
import { useDeleteChoice } from "@/components/Factory/Shared/useDeleteBlock";
@@ -72,6 +74,10 @@ const props = defineProps<{
item: FormBlockInteractionModel;
}>();
+const emit = defineEmits<{
+ (e: "onDelete", index: number): void;
+}>();
+
const labelElement = ref(null) as unknown as Ref;
const label: Ref = ref(props.item.label);
@@ -102,7 +108,7 @@ const {
answers,
ask: askDelete,
confirm: deleteNow,
-} = useDeleteChoice(props.item);
+} = useDeleteChoice(props.item, () => emit("onDelete", props.index));
const focus = () => {
labelElement.value.focus();
diff --git a/resources/js/components/Factory/Main/Interactions/CustomResponseSettings.vue b/resources/js/components/Factory/Main/Interactions/CustomResponseSettings.vue
index ca2471e4..b404b226 100644
--- a/resources/js/components/Factory/Main/Interactions/CustomResponseSettings.vue
+++ b/resources/js/components/Factory/Main/Interactions/CustomResponseSettings.vue
@@ -1,13 +1,11 @@
diff --git a/resources/js/components/Factory/Main/Interactions/RandomizeResponsesSetting.vue b/resources/js/components/Factory/Main/Interactions/RandomizeResponsesSetting.vue
index 9c2dc1bc..8ca17efe 100644
--- a/resources/js/components/Factory/Main/Interactions/RandomizeResponsesSetting.vue
+++ b/resources/js/components/Factory/Main/Interactions/RandomizeResponsesSetting.vue
@@ -1,13 +1,11 @@
-
+
diff --git a/resources/js/components/Factory/Main/NumberSettings.test.ts b/resources/js/components/Factory/Main/NumberSettings.test.ts
index ce48eccf..73893677 100644
--- a/resources/js/components/Factory/Main/NumberSettings.test.ts
+++ b/resources/js/components/Factory/Main/NumberSettings.test.ts
@@ -37,7 +37,7 @@ vi.mock("../utils/useInteractionsUtils", () => ({
}),
}));
-// types into one field and returns the options the settings would save
+// types into one field, found by id, and returns the options the settings would save
const savedOptions = async (
component: Component,
field: string,
@@ -58,19 +58,19 @@ const savedOptions = async (
},
});
await flushPromises();
- await wrapper.find(`input[placeholder^="${field}"]`).setValue(typed);
+ await wrapper.find(`input#${field}`).setValue(typed);
await flushPromises();
return save.mock.lastCall?.[0].options;
};
const savedDecimalPlaces = async (typed: string) =>
- (await savedOptions(ConfigureInput, "Number of decimal places", typed))
+ (await savedOptions(ConfigureInput, "input-decimal-places", typed))
?.decimalPlaces;
describe("file limits in the file block settings", () => {
it("clamps max files to 1 to 10 before saving", async () => {
- const files = "The maximum number of allowed files";
+ const files = "file-max-files";
expect(
(await savedOptions(ConfigureFile, files, "50"))?.allowedFiles,
).toBe(10);
@@ -80,7 +80,7 @@ describe("file limits in the file block settings", () => {
});
it("clamps max size to 1 to 8 MB before saving", async () => {
- const size = "The maximum file size";
+ const size = "file-max-size";
expect(
(await savedOptions(ConfigureFile, size, "99"))?.allowedFileSize,
).toBe(8);
diff --git a/resources/js/components/Factory/Main/Workbench.vue b/resources/js/components/Factory/Main/Workbench.vue
deleted file mode 100644
index 94b7e11d..00000000
--- a/resources/js/components/Factory/Main/Workbench.vue
+++ /dev/null
@@ -1,35 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/resources/js/components/Factory/Main/WorkbenchEmpty.vue b/resources/js/components/Factory/Main/WorkbenchEmpty.vue
deleted file mode 100644
index fffedd28..00000000
--- a/resources/js/components/Factory/Main/WorkbenchEmpty.vue
+++ /dev/null
@@ -1,34 +0,0 @@
-
-
-
-
-
diff --git a/resources/js/components/Factory/Preview/FormPreview.test.ts b/resources/js/components/Factory/Preview/FormPreview.test.ts
new file mode 100644
index 00000000..42ccf7e4
--- /dev/null
+++ b/resources/js/components/Factory/Preview/FormPreview.test.ts
@@ -0,0 +1,230 @@
+import { enableAutoUnmount, flushPromises, mount } from "@vue/test-utils";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { useForm, useWorkbench } from "@/stores";
+import { END_SCREEN } from "@/forms/classic/previewTarget";
+import FormPreview from "./FormPreview.vue";
+import PreviewToggle from "./PreviewToggle.vue";
+
+// the headlessui dialog needs it, jsdom has none
+vi.stubGlobal(
+ "ResizeObserver",
+ class {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ },
+);
+
+const screen = (wide: boolean) =>
+ vi.stubGlobal("matchMedia", (query: string) => ({
+ matches: wide && query === "(min-width: 1280px)",
+ addEventListener() {},
+ removeEventListener() {},
+ }));
+
+const PreviewFrame = { props: ["shown", "missed"], template: "
" };
+
+const mountPreview = (open = true) =>
+ mount(FormPreview, {
+ props: { modelValue: open },
+ attachTo: document.body,
+ global: { stubs: { PreviewFrame } },
+ });
+
+enableAutoUnmount(afterEach);
+
+describe("the preview panel", () => {
+ afterEach(() => {
+ useForm().$reset();
+ useWorkbench().$reset();
+ });
+
+ it("sits beside the document on a desktop", () => {
+ screen(true);
+ const wrapper = mountPreview();
+
+ const aside = wrapper.get("aside");
+ expect(aside.attributes("aria-labelledby")).toBe("preview-heading");
+ expect(aside.get("#preview-heading").text()).toBe("Preview");
+ expect(aside.find("iframe").exists()).toBe(true);
+ expect(document.querySelector("[role=dialog]")).toBeNull();
+ });
+
+ it("opens over the document on a tablet or phone", async () => {
+ screen(false);
+ const wrapper = mountPreview();
+ await wrapper.vm.$nextTick();
+
+ const dialog = document.querySelector("[role=dialog]");
+ expect(dialog?.textContent).toContain("Preview");
+ expect(dialog?.querySelector("iframe")).not.toBeNull();
+
+ const close = [...document.querySelectorAll("button")].find(
+ (button) => button.textContent?.trim() === "Close preview",
+ );
+ close?.click();
+ expect(wrapper.emitted("update:modelValue")).toEqual([[false]]);
+ });
+
+ it("closes when the window gets narrower than a desktop, instead of popping over the document", async () => {
+ let change: (event: { matches: boolean }) => void = () => {};
+ vi.stubGlobal("matchMedia", () => ({
+ matches: true,
+ addEventListener: (_: string, listener: typeof change) => (change = listener),
+ removeEventListener() {},
+ }));
+ const wrapper = mountPreview();
+ expect(wrapper.find("aside").exists()).toBe(true);
+
+ change({ matches: false });
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.emitted("update:modelValue")).toEqual([[false]]);
+ });
+
+ it.each([true, false])("says what to do for a form without questions, not a blank frame (desktop: %s)", async (wide) => {
+ screen(wide);
+ useForm().blocks = [];
+ mountPreview();
+ await flushPromises();
+
+ expect(document.body.textContent).toContain("Add a question to see it here.");
+ expect(document.querySelector("iframe")).toBeNull();
+
+ // while the questions load, the frame
+ useForm().blocks = null;
+ await flushPromises();
+ expect(document.body.textContent).not.toContain("Add a question");
+ expect(document.querySelector("iframe")).not.toBeNull();
+ });
+
+ it("says which question it shows, and when a rule hides the open one", async () => {
+ screen(true);
+ const blocks = [
+ { uuid: "q1", type: "input-short" },
+ { uuid: "q2", type: "input-short" },
+ ] as FormBlockModel[];
+ useForm().blocks = blocks;
+ useWorkbench().block = blocks[1];
+ const wrapper = mountPreview();
+ const frame = wrapper.findComponent(PreviewFrame);
+
+ frame.vm.$emit("update:shown", "q1");
+ await flushPromises();
+ expect(wrapper.get("#preview-heading").text()).toBe("Preview Β· question 1");
+ expect(wrapper.text()).not.toContain("hidden by a rule");
+
+ frame.vm.$emit("update:missed", "q2");
+ await flushPromises();
+ expect(wrapper.get("aside").text()).toContain(
+ "Question 2 is hidden by a rule, so the preview can't jump to it.",
+ );
+
+ // once another question is open, the line goes
+ useWorkbench().block = blocks[0];
+ await flushPromises();
+ expect(wrapper.text()).not.toContain("hidden by a rule");
+ });
+
+ // the public form leaves them out, so no rule is to blame
+ it.each([
+ ["a hidden question", { is_disabled: true }, "is hidden"],
+ ["a question in a hidden group", { parent_block: "g1" }, "is in a hidden group"],
+ ])("says when the open one is %s", async (_, question, reason) => {
+ screen(false);
+ const blocks = [
+ { uuid: "q1", type: "input-short", parent_block: null },
+ { uuid: "g1", type: "group", parent_block: null, is_disabled: true },
+ { uuid: "q2", type: "input-short", parent_block: null, ...question },
+ ] as FormBlockModel[];
+ useForm().blocks = blocks;
+ useWorkbench().block = blocks[2];
+ const wrapper = mountPreview();
+ await flushPromises();
+
+ wrapper.findComponent(PreviewFrame).vm.$emit("update:missed", "q2");
+ await flushPromises();
+
+ expect(document.querySelector("[role=dialog]")?.textContent).toContain(
+ `Question 2 ${reason}, so the preview can't jump to it.`,
+ );
+ });
+
+ it("says when it shows the end screen", async () => {
+ screen(true);
+ useForm().form = { use_cta_redirect: false } as FormModel;
+ const wrapper = mountPreview();
+
+ wrapper.findComponent(PreviewFrame).vm.$emit("update:shown", END_SCREEN);
+ await flushPromises();
+
+ expect(wrapper.get("#preview-heading").text()).toBe("Preview Β· end screen");
+ expect(wrapper.find("iframe").isVisible()).toBe(true);
+ });
+
+ // the preview doesn't follow the link, so its end screen is one nobody sees
+ it.each([true, false])("says where people go instead of the end screen when the form sends them to a link (desktop: %s)", async (wide) => {
+ screen(wide);
+ useForm().blocks = [{ uuid: "q1", type: "input-short" }] as FormBlockModel[];
+ useForm().form = { use_cta_redirect: true, cta_link: "https://example.com/thanks" } as FormModel;
+ const wrapper = mountPreview();
+ await flushPromises();
+
+ const frame = wrapper.findComponent(PreviewFrame);
+ frame.vm.$emit("update:shown", END_SCREEN);
+ await flushPromises();
+
+ const panel = document.querySelector(wide ? "aside" : "[role=dialog]");
+ expect(panel?.textContent).toContain("No end screen: people go to https://example.com/thanks.");
+ expect(frame.isVisible()).toBe(false);
+
+ // back at a question, the frame shows again
+ frame.vm.$emit("update:shown", "q1");
+ await flushPromises();
+ expect(document.body.textContent).not.toContain("No end screen");
+ expect(frame.isVisible()).toBe(true);
+ });
+
+ it("shows the end screen when the link isn't a web link, as the public form does", async () => {
+ screen(true);
+ useForm().form = { use_cta_redirect: true, cta_link: "mailto:hi@example.com" } as FormModel;
+ const wrapper = mountPreview();
+
+ const frame = wrapper.findComponent(PreviewFrame);
+ frame.vm.$emit("update:shown", END_SCREEN);
+ await flushPromises();
+
+ expect(wrapper.text()).not.toContain("No end screen");
+ expect(frame.isVisible()).toBe(true);
+ });
+
+ it.each([true, false])("shows nothing when closed (desktop: %s)", (wide) => {
+ screen(wide);
+ const wrapper = mountPreview(false);
+
+ expect(wrapper.find("aside").exists()).toBe(false);
+ expect(document.querySelector("[role=dialog]")).toBeNull();
+ });
+});
+
+describe("the Preview button in the top bar", () => {
+ it("shows and hides the preview", async () => {
+ const wrapper = mount(PreviewToggle, {
+ props: {
+ modelValue: false,
+ "onUpdate:modelValue": (open: boolean) =>
+ wrapper.setProps({ modelValue: open }),
+ },
+ });
+ const button = wrapper.get("button");
+
+ expect(button.text()).toBe("Preview");
+ expect(button.attributes("aria-pressed")).toBe("false");
+
+ await button.trigger("click");
+ expect(button.attributes("aria-pressed")).toBe("true");
+
+ await button.trigger("click");
+ expect(button.attributes("aria-pressed")).toBe("false");
+ });
+});
diff --git a/resources/js/components/Factory/Preview/FormPreview.vue b/resources/js/components/Factory/Preview/FormPreview.vue
new file mode 100644
index 00000000..75cd09af
--- /dev/null
+++ b/resources/js/components/Factory/Preview/FormPreview.vue
@@ -0,0 +1,134 @@
+
+
+
+
+ {{ heading }}
+
+
+ {{ hidden }}, so the preview can't jump to it.
+
+
+ No end screen: people go to {{ redirect }}.
+
+
+ Add a question to see it here.
+
+
+
+
+
+
+
+
+
+ {{
+ heading
+ }}
+
+ Close preview
+
+
+
+
+ {{ hidden }}, so the preview can't jump to it.
+
+
+ No end screen: people go to {{ redirect }}.
+
+
+ Add a question to see it here.
+
+
+
+
+
+
+
+
diff --git a/resources/js/components/Factory/Preview/PreviewFrame.test.ts b/resources/js/components/Factory/Preview/PreviewFrame.test.ts
new file mode 100644
index 00000000..d643947a
--- /dev/null
+++ b/resources/js/components/Factory/Preview/PreviewFrame.test.ts
@@ -0,0 +1,368 @@
+import {
+ enableAutoUnmount,
+ flushPromises,
+ mount,
+ type VueWrapper,
+} from "@vue/test-utils";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { createPinia, getActivePinia, setActivePinia, type Pinia } from "pinia";
+import { PiniaDebounce } from "@pinia/plugin-debounce";
+import debounce from "lodash/debounce";
+import type { AxiosResponse } from "axios";
+import { createApp, nextTick } from "vue";
+import { useForm, useLogic, useWorkbench } from "@/stores";
+import { useNotice } from "@/stores/notice";
+import { callUpdateFormBlock } from "@/api/blocks";
+import { callUpdateFormBlockInteraction } from "@/api/interactions";
+import { endScreenOpen } from "@/components/Factory/Document/blockFocus";
+import { END_SCREEN } from "@/forms/classic/previewTarget";
+import PreviewFrame from "./PreviewFrame.vue";
+
+vi.mock("@/api/blocks", () => ({ callUpdateFormBlock: vi.fn() }));
+vi.mock("@/api/interactions", () => ({
+ callUpdateFormBlockInteraction: vi.fn(),
+}));
+vi.mock("@/api/logics", () => ({
+ callUpdateFormBlockLogic: vi.fn(async () => ({ status: 200, data: true })),
+}));
+
+// the form page is /
+window.route = ((name: string, params?: { uuid?: string }) =>
+ `/${params?.uuid ?? name}`) as typeof window.route;
+
+const block = (id: number, uuid: string, type = "input-short") =>
+ ({
+ id,
+ uuid,
+ type,
+ title: "",
+ interactions: [{ id: id * 10, label: "Red", options: [] }],
+ }) as unknown as FormBlockModel;
+const first = block(1, "q1uuid");
+const second = block(2, "q2uuid");
+const group = block(3, "g3uuid", "group");
+
+// the testing pinia from the setup file: a test with a real one sets it back
+const testing = getActivePinia() as Pinia;
+
+// a real one, whose typing saves wait a second as in the app
+const realPinia = () => {
+ const pinia = createPinia();
+ createApp({}).use(pinia);
+ pinia.use(PiniaDebounce(debounce));
+ setActivePinia(pinia);
+ return pinia;
+};
+
+// the server answers each save when the test says so
+const answers: Array<() => void> = [];
+const answerLater = () =>
+ new Promise((resolve) =>
+ answers.push(() => resolve({ status: 200 } as AxiosResponse)),
+ );
+
+enableAutoUnmount(afterEach);
+
+const mountFrame = (open: FormBlockModel | null = first, pinia?: Pinia) => {
+ useForm().form = { uuid: "form1" } as FormModel;
+ // copies, which the editor changes
+ useForm().blocks = structuredClone([first, second, group]);
+ useWorkbench().block =
+ useForm().blocks!.find((item) => item.uuid === open?.uuid) ?? null;
+
+ return mount(PreviewFrame, {
+ attachTo: document.body,
+ global: pinia ? { plugins: [pinia] } : {},
+ });
+};
+
+const frameWindow = (wrapper: VueWrapper) =>
+ (wrapper.get("iframe").element as HTMLIFrameElement)
+ .contentWindow as Window;
+
+const fromFrame = (
+ wrapper: VueWrapper,
+ data: unknown,
+ init: MessageEventInit = {},
+) =>
+ window.dispatchEvent(
+ new MessageEvent("message", {
+ data,
+ origin: window.location.origin,
+ source: frameWindow(wrapper),
+ ...init,
+ }),
+ );
+
+describe("the preview frame", () => {
+ beforeEach(() => {
+ setActivePinia(testing);
+ vi.clearAllMocks();
+ answers.length = 0;
+ endScreenOpen.value = false;
+ });
+
+ it("runs the public form in preview mode at the open question", () => {
+ const iframe = mountFrame().get("iframe");
+
+ expect(iframe.attributes("src")).toBe("/form1?preview=1&block=q1uuid");
+ expect(iframe.attributes("title")).toBe("Preview of your form");
+ });
+
+ it("waits for the questions to load, so it starts at the open one instead of jumping there", async () => {
+ useForm().form = { uuid: "form1" } as FormModel;
+ useForm().blocks = null;
+ useWorkbench().block = null;
+ const wrapper = mount(PreviewFrame, { attachTo: document.body });
+ expect(wrapper.find("iframe").exists()).toBe(false);
+
+ useForm().blocks = structuredClone([first, second, group]);
+
+ useWorkbench().block = second;
+ await nextTick();
+
+ expect(wrapper.get("iframe").attributes("src")).toBe(
+ "/form1?preview=1&block=q2uuid",
+ );
+ });
+
+ it("starts at once when it opens with no question open", () => {
+ const iframe = mountFrame(null).get("iframe");
+
+ expect(iframe.attributes("src")).toBe("/form1?preview=1");
+ });
+
+ it("goes to the question opened in the document", async () => {
+ const wrapper = mountFrame();
+ const post = vi.spyOn(frameWindow(wrapper), "postMessage");
+
+ useWorkbench().block = second;
+ await nextTick();
+
+ expect(post).toHaveBeenCalledWith(
+ { type: "input:preview:goto", block: "q2uuid" },
+ window.location.origin,
+ );
+ });
+
+ it("starts at the end screen while its card is open", () => {
+ endScreenOpen.value = true;
+ const iframe = mountFrame(null).get("iframe");
+
+ expect(iframe.attributes("src")).toBe(`/form1?preview=1&block=${END_SCREEN}`);
+ });
+
+ it("goes to the end screen when its card opens, and back to a question opened after", async () => {
+ const wrapper = mountFrame();
+ const post = vi.spyOn(frameWindow(wrapper), "postMessage");
+
+ // opening the card closes the open question first
+ useWorkbench().block = null;
+ endScreenOpen.value = true;
+ await nextTick();
+ expect(post).toHaveBeenLastCalledWith(
+ { type: "input:preview:goto", block: END_SCREEN },
+ window.location.origin,
+ );
+
+ useWorkbench().block = second;
+ await nextTick();
+ expect(post).toHaveBeenLastCalledWith(
+ { type: "input:preview:goto", block: "q2uuid" },
+ window.location.origin,
+ );
+ });
+
+ it("loads the form again at the end screen after its save", async () => {
+ const wrapper = mountFrame(null, realPinia());
+ endScreenOpen.value = true;
+ await nextTick();
+ const before = wrapper.get("iframe").element;
+
+ useNotice().clearSaveError("saveEndScreen");
+ await nextTick();
+ await nextTick();
+
+ const iframe = wrapper.get("iframe");
+ expect(iframe.element).not.toBe(before);
+ expect(iframe.attributes("src")).toBe(`/form1?preview=1&block=${END_SCREEN}`);
+ });
+
+ it("opens the question the preview moves on to", () => {
+ const wrapper = mountFrame();
+
+ fromFrame(wrapper, { type: "input:preview:block", block: "q1uuid" });
+ fromFrame(wrapper, { type: "input:preview:block", block: "q2uuid" });
+
+ expect(useWorkbench().putOnWorkbench).toHaveBeenCalledWith(second);
+ });
+
+ it("doesn't send that question back to the preview", async () => {
+ const wrapper = mountFrame();
+ const post = vi.spyOn(frameWindow(wrapper), "postMessage");
+
+ fromFrame(wrapper, { type: "input:preview:block", block: "q1uuid" });
+ fromFrame(wrapper, { type: "input:preview:block", block: "q2uuid" });
+ // the stubbed action doesn't open it
+ useWorkbench().block = second;
+ await nextTick();
+
+ expect(post).not.toHaveBeenCalled();
+ });
+
+ it("keeps the open group where the preview starts", () => {
+ const wrapper = mountFrame(group);
+ const post = vi.spyOn(frameWindow(wrapper), "postMessage");
+
+ // a group is no page of the form: the preview starts at the first question
+ fromFrame(wrapper, { type: "input:preview:block", block: "q1uuid" });
+
+ expect(useWorkbench().putOnWorkbench).not.toHaveBeenCalled();
+ // and asks for the open block again, it may have missed it while loading
+ expect(post).toHaveBeenCalledWith(
+ { type: "input:preview:goto", block: "g3uuid" },
+ window.location.origin,
+ );
+ });
+
+ it("tells which question the preview shows", () => {
+ const wrapper = mountFrame();
+
+ fromFrame(wrapper, { type: "input:preview:block", block: "q1uuid" });
+ fromFrame(wrapper, { type: "input:preview:block", block: "q2uuid" });
+
+ expect(wrapper.emitted("update:shown")).toEqual([["q1uuid"], ["q2uuid"]]);
+ });
+
+ // after a click into the frame, the form there gets the key
+ it("passes on Esc from the form in the frame, from no other origin or window", () => {
+ const wrapper = mountFrame();
+ const escape = { type: "input:preview:escape" };
+
+ fromFrame(wrapper, escape, { origin: "https://evil.example" });
+ fromFrame(wrapper, escape, { source: window });
+ expect(wrapper.emitted("escape")).toBeUndefined();
+
+ fromFrame(wrapper, escape);
+ expect(wrapper.emitted("escape")).toEqual([[]]);
+ });
+
+ it("tells when the preview doesn't go to the open question, as when a rule hides it", async () => {
+ vi.useFakeTimers();
+ const wrapper = mountFrame();
+ fromFrame(wrapper, { type: "input:preview:block", block: "q1uuid" });
+ const missed = () => wrapper.emitted("update:missed")?.at(-1)?.[0] ?? null;
+
+ // the preview moves: nothing missed
+ useWorkbench().block = second;
+ await nextTick();
+ fromFrame(wrapper, { type: "input:preview:block", block: "q2uuid" });
+ vi.advanceTimersByTime(500);
+ expect(missed()).toBeNull();
+
+ // it stays
+ useWorkbench().block = first;
+ await nextTick();
+ vi.advanceTimersByTime(499);
+ expect(missed()).toBeNull();
+ vi.advanceTimersByTime(1);
+ expect(missed()).toBe("q1uuid");
+ vi.useRealTimers();
+ });
+
+ it.each([
+ ["another origin", { origin: "https://evil.example" }, {}],
+ ["another window", { source: window }, {}],
+ ["another type", {}, { type: "input:preview:goto" }],
+ ["no block id", {}, { block: 2 }],
+ ])("ignores a message from %s", (_, init, change) => {
+ const wrapper = mountFrame();
+ const data = { type: "input:preview:block", block: "q1uuid" };
+
+ fromFrame(wrapper, data);
+ fromFrame(wrapper, { ...data, block: "q2uuid", ...change }, init);
+
+ expect(useWorkbench().putOnWorkbench).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ ["a new choice", () => useNotice().clearSaveError("createInteraction:2")],
+ [
+ "a saved rule",
+ () => {
+ useLogic().block = second;
+ return useLogic().saveSingleRule({ uuid: "r1" } as FormBlockLogic);
+ },
+ ],
+ ])("loads the form again at the open question after %s", async (_, save) => {
+ const wrapper = mountFrame(first, realPinia());
+ const before = wrapper.get("iframe").element;
+
+ useWorkbench().block = second;
+ await save();
+ await nextTick();
+ await nextTick();
+
+ const iframe = wrapper.get("iframe");
+ expect(iframe.element).not.toBe(before);
+ expect(iframe.attributes("src")).toBe("/form1?preview=1&block=q2uuid");
+ });
+
+ it("loads the form again after a delete, which takes the question out after its save", async () => {
+ const wrapper = mountFrame(first, realPinia());
+ const before = wrapper.get("iframe").element;
+
+ useNotice().clearSaveError("deleteBlock:2");
+ useForm().blocks!.splice(1, 1);
+ await nextTick();
+ await nextTick();
+
+ expect(wrapper.get("iframe").element).not.toBe(before);
+ });
+
+ describe("while typing, which saves a second later", () => {
+ beforeEach(() => {
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] });
+ vi.mocked(callUpdateFormBlock).mockImplementation(answerLater);
+ vi.mocked(callUpdateFormBlockInteraction).mockImplementation(
+ answerLater,
+ );
+ });
+ afterEach(() => vi.useRealTimers());
+
+ it("shows two saves that overlap: the first comes back before the second is sent", async () => {
+ const wrapper = mountFrame(first, realPinia());
+ const frame = () => wrapper.get("iframe").element;
+ const start = frame();
+
+ useWorkbench().updateBlock({ title: "Your colour?" });
+ await vi.advanceTimersByTimeAsync(500);
+ useWorkbench().updateInteraction({ id: 10, label: "Green" });
+ await vi.advanceTimersByTimeAsync(500);
+ answers[0]();
+ await flushPromises();
+ const shown = frame();
+ expect(shown).not.toBe(start);
+
+ // the choice reached the server only now
+ await vi.advanceTimersByTimeAsync(500);
+ answers[1]();
+ await flushPromises();
+ expect(frame()).not.toBe(shown);
+ });
+
+ it("keeps running after a save that changed nothing", async () => {
+ const wrapper = mountFrame(first, realPinia());
+ const before = wrapper.get("iframe").element;
+
+ // opening a card saves its answer field: empty settings come as [], go back as {}
+ useWorkbench().updateInteraction({ id: 10, options: {} });
+ await vi.advanceTimersByTimeAsync(1000);
+ answers[0]();
+ await flushPromises();
+
+ expect(callUpdateFormBlockInteraction).toHaveBeenCalledOnce();
+ expect(wrapper.get("iframe").element).toBe(before);
+ });
+ });
+});
diff --git a/resources/js/components/Factory/Preview/PreviewFrame.vue b/resources/js/components/Factory/Preview/PreviewFrame.vue
new file mode 100644
index 00000000..c0406cc2
--- /dev/null
+++ b/resources/js/components/Factory/Preview/PreviewFrame.vue
@@ -0,0 +1,180 @@
+
+
+
+
+
+
diff --git a/resources/js/components/Factory/Preview/PreviewToggle.vue b/resources/js/components/Factory/Preview/PreviewToggle.vue
new file mode 100644
index 00000000..d71073c6
--- /dev/null
+++ b/resources/js/components/Factory/Preview/PreviewToggle.vue
@@ -0,0 +1,18 @@
+
+
+
+ Preview
+
+
+
+
diff --git a/resources/js/components/Factory/Settings/CompletionPage.test.ts b/resources/js/components/Factory/Settings/CompletionPage.test.ts
index b8763d72..cfb59c29 100644
--- a/resources/js/components/Factory/Settings/CompletionPage.test.ts
+++ b/resources/js/components/Factory/Settings/CompletionPage.test.ts
@@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest";
import CompletionPage from "./CompletionPage.vue";
import { useForm } from "@/stores";
-describe("completion page", () => {
+describe("end screen settings", () => {
it("ties the Message label to its editor", async () => {
useForm().form = { uuid: "form1" } as FormModel;
const wrapper = mount(CompletionPage, {
diff --git a/resources/js/components/Factory/Settings/CompletionPage.vue b/resources/js/components/Factory/Settings/CompletionPage.vue
index d88191ee..5a04589b 100644
--- a/resources/js/components/Factory/Settings/CompletionPage.vue
+++ b/resources/js/components/Factory/Settings/CompletionPage.vue
@@ -178,7 +178,7 @@
diff --git a/resources/js/components/Factory/Settings/partials/CompletionPageType.vue b/resources/js/components/Factory/Settings/partials/CompletionPageType.vue
index 249a563f..0ac0c93a 100644
--- a/resources/js/components/Factory/Settings/partials/CompletionPageType.vue
+++ b/resources/js/components/Factory/Settings/partials/CompletionPageType.vue
@@ -61,8 +61,8 @@ import { D9Icon } from "@deck9/ui";
const mailingLists = [
{
id: 1,
- title: "Show completion page",
- description: "Configure a custom completion page",
+ title: "Show end screen",
+ description: "Configure a custom end screen",
value: false,
},
{
diff --git a/resources/js/components/Factory/Shared/DeleteBlockDialog.vue b/resources/js/components/Factory/Shared/DeleteBlockDialog.vue
index 309a671c..f27ebd42 100644
--- a/resources/js/components/Factory/Shared/DeleteBlockDialog.vue
+++ b/resources/js/components/Factory/Shared/DeleteBlockDialog.vue
@@ -72,9 +72,12 @@ const text = computed(() => {
const plain = getTextFromHtml(props.block.message ?? "").trim();
const short = plain.length > 80 ? `${plain.slice(0, 78).trim()}β¦` : plain;
- const name = short
- ? `β${short}β`
- : `Question ${numberOf(props.block.uuid) || ""}`.trim();
+ const quoted = short ? `β${short}β` : "";
+ const number = numberOf(props.block.uuid);
+ // the number first: a copy from Duplicate has the same text
+ const name = number
+ ? `Question ${number}${quoted ? ` (${quoted})` : ""}`
+ : quoted || "The question";
const answers = props.check?.answers;
// unknown when the counts couldn't load: there may still be answers
const withAnswers =
diff --git a/resources/js/components/Factory/Shared/DeleteChoiceDialog.vue b/resources/js/components/Factory/Shared/DeleteChoiceDialog.vue
index 16b197db..152c8802 100644
--- a/resources/js/components/Factory/Shared/DeleteChoiceDialog.vue
+++ b/resources/js/components/Factory/Shared/DeleteChoiceDialog.vue
@@ -3,7 +3,7 @@
:show="choice !== null"
:title="title"
:text="text"
- :confirm-label="policy ? 'Delete policy' : 'Delete choice'"
+ :confirm-label="policy ? 'Delete consent' : 'Delete choice'"
@close="emit('close')"
@confirm="emit('confirm')"
/>
@@ -31,7 +31,7 @@ const title = computed(() => {
const label = props.choice?.label;
if (props.policy) {
- return label ? `Delete policy β${label}β?` : "Delete this policy?";
+ return label ? `Delete consent β${label}β?` : "Delete this consent?";
}
return label ? `Delete β${label}β?` : "Delete this choice?";
diff --git a/resources/js/components/Factory/Shared/DeleteDialogs.test.ts b/resources/js/components/Factory/Shared/DeleteDialogs.test.ts
index 561360c5..4d7ba481 100644
--- a/resources/js/components/Factory/Shared/DeleteDialogs.test.ts
+++ b/resources/js/components/Factory/Shared/DeleteDialogs.test.ts
@@ -9,10 +9,8 @@ import DeleteChoiceDialog from "./DeleteChoiceDialog.vue";
import { checkDelete, useDeleteBlock } from "./useDeleteBlock";
import ClickInteraction from "@/components/Factory/Main/Interactions/ClickInteraction.vue";
import ConsentInteraction from "@/components/Factory/Main/Interactions/ConsentInteraction.vue";
-import InteractionHoverActions from "@/components/Factory/Main/InteractionHoverActions.vue";
-import BlockLogicRule from "@/components/Factory/Sidebar/BlockLogicRule.vue";
import { callGetFormBlocks } from "@/api/blocks";
-import { useForm, useLogic, useWorkbench } from "@/stores";
+import { useForm, useWorkbench } from "@/stores";
vi.mock("@/api/blocks", async (importOriginal) => ({
...(await importOriginal()),
@@ -145,14 +143,14 @@ describe("asking before a delete", () => {
global: { plugins: [pinia], stubs },
});
- it("names the question, its answers and the questions whose rules stop working", async () => {
+ it("names the question by number and text, its answers and the questions whose rules stop working", async () => {
const wrapper = await open("q1");
expect(wrapper.find("[role=alertdialog]").text()).toContain(
"Delete question?",
);
expect(wrapper.text()).toContain(
- "βWill you come to the meetup?β and its 40 answers will be deleted. This can't be undone.",
+ "Question 1 (βWill you come to the meetup?β) and its 40 answers will be deleted. This can't be undone.",
);
expect(wrapper.text()).toContain(
"2 rules use this question and will stop working: question 3, group βFood and drinksβ.",
@@ -166,7 +164,7 @@ describe("asking before a delete", () => {
const wrapper = await open("q2");
expect(wrapper.text()).toContain(
- "βAny allergies?β will be deleted. This can't be undone.",
+ "Question 2 (βAny allergies?β) will be deleted. This can't be undone.",
);
expect(wrapper.text()).toContain(
"1 rule uses this question and will stop working: question 3.",
@@ -188,7 +186,7 @@ describe("asking before a delete", () => {
});
expect(wrapper.text()).toContain(
- "βWill you come to the meetup?β and its 1 answer will be deleted.",
+ "Question 1 (βWill you come to the meetup?β) and its 1 answer will be deleted.",
);
expect(wrapper.text()).not.toContain("rule");
});
@@ -203,7 +201,7 @@ describe("asking before a delete", () => {
});
expect(wrapper.text()).toContain(
- "βWill you come to the meetup?β and its answers will be deleted. This can't be undone.",
+ "Question 1 (βWill you come to the meetup?β) and its answers will be deleted. This can't be undone.",
);
});
@@ -266,9 +264,6 @@ describe("asking before a delete", () => {
const choice = (id: number, index: number) => {
const workbench = useWorkbench();
workbench.block = block("q3");
- const remove = vi
- .spyOn(workbench, "deleteInteraction")
- .mockResolvedValue();
const wrapper = mount(ClickInteraction, {
props: {
index,
@@ -279,7 +274,7 @@ describe("asking before a delete", () => {
global: { plugins: [pinia], stubs },
});
- return { wrapper, remove };
+ return { wrapper };
};
const pressCmdBackspace = (wrapper: ReturnType) =>
@@ -288,24 +283,22 @@ describe("asking before a delete", () => {
.trigger("keydown", { key: "Backspace", metaKey: true });
it("deletes a choice nobody picked at once", async () => {
- const { wrapper, remove } = choice(11, 1);
+ const { wrapper } = choice(11, 1);
await pressCmdBackspace(wrapper);
await flushPromises();
- expect(remove).toHaveBeenCalledWith(
- expect.objectContaining({ id: 11 }),
- );
+ expect(wrapper.emitted("onDelete")).toHaveLength(1);
expect(wrapper.find("[role=alertdialog]").exists()).toBe(false);
});
it("asks first when people picked the choice", async () => {
- const { wrapper, remove } = choice(10, 0);
+ const { wrapper } = choice(10, 0);
await pressCmdBackspace(wrapper);
await flushPromises();
- expect(remove).not.toHaveBeenCalled();
+ expect(wrapper.emitted("onDelete")).toBeUndefined();
expect(wrapper.text()).toContain("Delete βNoβ?");
expect(wrapper.text()).toContain(
"12 people picked it. Their answers to it will be deleted.",
@@ -314,9 +307,7 @@ describe("asking before a delete", () => {
await wrapper.find("button.bg-red-600").trigger("click");
await flushPromises();
- expect(remove).toHaveBeenCalledWith(
- expect.objectContaining({ id: 10 }),
- );
+ expect(wrapper.emitted("onDelete")).toHaveLength(1);
});
it("says 1 person for a single answer", () => {
@@ -336,9 +327,6 @@ describe("asking before a delete", () => {
const policy = (id: number) => {
const workbench = useWorkbench();
workbench.block = block("c1");
- const remove = vi
- .spyOn(workbench, "deleteInteraction")
- .mockResolvedValue();
const wrapper = mount(ConsentInteraction, {
props: {
index: 0,
@@ -350,46 +338,41 @@ describe("asking before a delete", () => {
});
const pressTrash = async () => {
await wrapper
- .findComponent(InteractionHoverActions)
- .find("button")
+ .find("button[aria-label^=Delete]")
.trigger("click");
await flushPromises();
};
- return { wrapper, remove, pressTrash };
+ return { wrapper, pressTrash };
};
it("deletes a policy nobody agreed to at once", async () => {
- const { wrapper, remove, pressTrash } = policy(21);
+ const { wrapper, pressTrash } = policy(21);
await pressTrash();
- expect(remove).toHaveBeenCalledWith(
- expect.objectContaining({ id: 21 }),
- );
+ expect(wrapper.emitted("onDelete")).toHaveLength(1);
expect(wrapper.find("[role=alertdialog]").exists()).toBe(false);
});
it("asks first when people agreed to the policy", async () => {
- const { wrapper, remove, pressTrash } = policy(20);
+ const { wrapper, pressTrash } = policy(20);
await pressTrash();
- expect(remove).not.toHaveBeenCalled();
- expect(wrapper.text()).toContain("Delete policy βPrivacy policyβ?");
+ expect(wrapper.emitted("onDelete")).toBeUndefined();
+ expect(wrapper.text()).toContain("Delete consent βPrivacy policyβ?");
expect(wrapper.text()).toContain(
"3 people agreed to it. Their answers to it will be deleted.",
);
expect(wrapper.find("button.bg-red-600").text()).toBe(
- "Delete policy",
+ "Delete consent",
);
await wrapper.find("button.bg-red-600").trigger("click");
await flushPromises();
- expect(remove).toHaveBeenCalledWith(
- expect.objectContaining({ id: 20 }),
- );
+ expect(wrapper.emitted("onDelete")).toHaveLength(1);
});
});
@@ -444,38 +427,4 @@ describe("asking before a delete", () => {
wrapper.unmount();
});
});
-
- describe("deleting a rule", () => {
- it("asks in the app's dialog, not the browser's", async () => {
- const confirm = vi.spyOn(window, "confirm");
- const remove = vi
- .spyOn(useLogic(), "removeRule")
- .mockResolvedValue();
- const wrapper = mount(BlockLogicRule, {
- props: {
- index: 2,
- rule: {
- name: "Skip",
- evaluate: "after",
- action: "goto",
- action_payload: "q1",
- conditions: [],
- } as unknown as FormBlockLogic,
- },
- global: { plugins: [pinia], stubs },
- });
-
- await wrapper
- .find("button[aria-label='Delete rule']")
- .trigger("click");
-
- expect(confirm).not.toHaveBeenCalled();
- expect(wrapper.text()).toContain("Delete rule βSkipβ?");
- expect(remove).not.toHaveBeenCalled();
-
- await wrapper.find("button.bg-red-600").trigger("click");
-
- expect(remove).toHaveBeenCalledWith(2);
- });
- });
});
diff --git a/resources/js/components/Factory/Shared/Editor.test.ts b/resources/js/components/Factory/Shared/Editor.test.ts
index 0cf34603..6caa4c57 100644
--- a/resources/js/components/Factory/Shared/Editor.test.ts
+++ b/resources/js/components/Factory/Shared/Editor.test.ts
@@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest";
import { nextTick } from "vue";
import Editor from "./Editor.vue";
-// HTML as the tiptap 2 editor saved it for block texts and completion pages
+// HTML as the tiptap 2 editor saved it for block texts and end screens
const stored =
'Welcome Before you start Some bold , italic and underlined text with inline code and a link .
Line one Line two
Above
Below
One
Two
A quote
Pasted font
';
@@ -72,23 +72,15 @@ describe("block text counter", () => {
editor.commands.undo();
await nextTick();
- expect(opened).toMatch(/^\d+ chars, \d+ words$/);
+ expect(opened).toMatch(/^\d+ characters$/);
expect(opened).toBe(counter(wrapper));
});
- it("counts an empty text as 0 chars, 0 words", async () => {
- expect(counter(await open())).toBe("0 chars, 0 words");
+ it("counts an empty text as 0 characters", async () => {
+ expect(counter(await open())).toBe("0 characters");
});
- it.each([
- ["Hi there
", 2],
- ["", 2],
- ["
", 0],
- ])("counts %s as %i words", async (html, words) => {
- expect(counter(await open(html))).toMatch(`, ${words} words`);
- });
-
- it("counts a text of only spaces as 0 words", async () => {
+ it("counts spaces as characters", async () => {
const wrapper = await open();
const editor = wrapper
.findComponent(EditorContent)
@@ -97,6 +89,23 @@ describe("block text counter", () => {
editor.view.dispatch(editor.state.tr.insertText(" "));
await nextTick();
- expect(counter(wrapper)).toBe("3 chars, 0 words");
+ expect(counter(wrapper)).toBe("3 characters");
+ });
+});
+
+describe("a bare editor", () => {
+ it("draws no box around its toolbar and text", async () => {
+ const wrapper = mount(Editor, {
+ props: { modelValue: "Hi
", bare: true },
+ global: { directives: { tooltip: {} } },
+ });
+ await nextTick();
+
+ expect(wrapper.find("[role=group]").classes()).not.toContain("border");
+ const editor = wrapper
+ .findComponent(EditorContent)
+ .props("editor") as TiptapEditor;
+ expect(editor.view.dom.classList).not.toContain("border");
+ expect(wrapper.text()).not.toContain("characters");
});
});
diff --git a/resources/js/components/Factory/Shared/Editor.vue b/resources/js/components/Factory/Shared/Editor.vue
index 8264e918..f7f4349a 100644
--- a/resources/js/components/Factory/Shared/Editor.vue
+++ b/resources/js/components/Factory/Shared/Editor.vue
@@ -1,7 +1,10 @@
@@ -78,7 +81,7 @@
v-bind="{
isActive: editor?.isActive('orderedList') ?? false,
icon: 'list-ol',
- label: 'Ordered List',
+ label: 'Numbered list',
}"
@click="editor?.chain().focus().toggleOrderedList().run()"
/>
@@ -106,7 +109,7 @@
v-bind="{
isActive: false,
icon: 'remove-format',
- label: 'Remove Formatting',
+ label: 'Clear formatting',
}"
@click="editor?.chain().focus().clearNodes().unsetAllMarks().run()"
/>
@@ -114,13 +117,13 @@
- {{ chars }} chars, {{ words }} words
+ {{ chars }} characters
diff --git a/resources/js/components/Factory/Shared/useDeleteBlock.ts b/resources/js/components/Factory/Shared/useDeleteBlock.ts
index 6ecc276a..18819377 100644
--- a/resources/js/components/Factory/Shared/useDeleteBlock.ts
+++ b/resources/js/components/Factory/Shared/useDeleteBlock.ts
@@ -57,23 +57,27 @@ export function useDeleteBlock(block: FormBlockModel) {
check.value = null;
};
- const confirm = () => {
+ // resolves once the server answered, so focus can move after it
+ const confirm = async () => {
const workbench = useWorkbench();
check.value = null;
- useForm().deleteFormBlock(block);
+ const deleting = useForm().deleteFormBlock(block);
if (workbench.block?.id === block.id) {
workbench.clearWorkbench();
}
+
+ await deleting;
};
return { check, ask, cancel, confirm };
}
-// asks before a choice is deleted; one nobody picked goes at once
+// asks before a choice is deleted; one nobody picked goes at once.
+// `remove` does the delete, so a list can move focus once the row is gone.
export function useDeleteChoice(
item: FormBlockInteractionModel,
- onDelete?: () => void,
+ remove: () => unknown = () => useWorkbench().deleteInteraction(item),
) {
const workbench = useWorkbench();
// open while the owner decides
@@ -83,8 +87,7 @@ export function useDeleteChoice(
const confirm = async () => {
asking.value = null;
- onDelete?.();
- await workbench.deleteInteraction(item);
+ await remove();
};
const ask = async () => {
diff --git a/resources/js/components/Factory/Sidebar/Block.vue b/resources/js/components/Factory/Sidebar/Block.vue
deleted file mode 100644
index b5f97014..00000000
--- a/resources/js/components/Factory/Sidebar/Block.vue
+++ /dev/null
@@ -1,117 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ block.title && block.title.length ? block.title : block.uuid }}
-
-
-
-
-
-
--empty--
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/resources/js/components/Factory/Sidebar/BlockContainer.vue b/resources/js/components/Factory/Sidebar/BlockContainer.vue
deleted file mode 100644
index 7c417030..00000000
--- a/resources/js/components/Factory/Sidebar/BlockContainer.vue
+++ /dev/null
@@ -1,104 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
diff --git a/resources/js/components/Factory/Sidebar/BlockFooter.vue b/resources/js/components/Factory/Sidebar/BlockFooter.vue
deleted file mode 100644
index 6b3e8f24..00000000
--- a/resources/js/components/Factory/Sidebar/BlockFooter.vue
+++ /dev/null
@@ -1,89 +0,0 @@
-
-
-
-
- Block Logic
- ({{ block.logics?.length }} )
-
-
-
-
- Required
-
-
-
- Optional
-
-
-
-
-
- Disabled
-
-
-
- Enabled
-
-
-
-
-
-
diff --git a/resources/js/components/Factory/Sidebar/BlockInteraction.test.ts b/resources/js/components/Factory/Sidebar/BlockInteraction.test.ts
deleted file mode 100644
index 3f6a1229..00000000
--- a/resources/js/components/Factory/Sidebar/BlockInteraction.test.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import { mount } from "@vue/test-utils";
-import { describe, expect, it } from "vitest";
-import BlockInteraction from "./BlockInteraction.vue";
-
-describe("BlockInteraction", () => {
- it.each(["range", "file"])(
- "mounts a new %s interaction whose options are still null",
- (type) => {
- const interaction = {
- type,
- label: null,
- options: null,
- } as unknown as FormBlockInteractionModel;
-
- expect(() =>
- mount(BlockInteraction, { props: { interaction } }),
- ).not.toThrow();
- },
- );
-});
diff --git a/resources/js/components/Factory/Sidebar/BlockInteraction.vue b/resources/js/components/Factory/Sidebar/BlockInteraction.vue
deleted file mode 100644
index cc2f3242..00000000
--- a/resources/js/components/Factory/Sidebar/BlockInteraction.vue
+++ /dev/null
@@ -1,33 +0,0 @@
-
-
-
-
- up to {{ interaction.options.allowedFiles }} files ,
- max {{ interaction.options.allowedFileSize }} MB
-
-
- rate from {{ interaction.options.start }} to
- {{ interaction.options.end }}
-
-
{{ interaction.label ?? "-" }}
-
-
-
-
diff --git a/resources/js/components/Factory/Sidebar/BlockLogicEditor.vue b/resources/js/components/Factory/Sidebar/BlockLogicEditor.vue
deleted file mode 100644
index 39ba8949..00000000
--- a/resources/js/components/Factory/Sidebar/BlockLogicEditor.vue
+++ /dev/null
@@ -1,140 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
diff --git a/resources/js/components/Factory/Sidebar/BlockLogicRule.test.ts b/resources/js/components/Factory/Sidebar/BlockLogicRule.test.ts
deleted file mode 100644
index b52636e8..00000000
--- a/resources/js/components/Factory/Sidebar/BlockLogicRule.test.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import { mount } from "@vue/test-utils";
-import { createPinia, setActivePinia } from "pinia";
-import { describe, expect, it } from "vitest";
-import BlockLogicRule from "./BlockLogicRule.vue";
-import { useForm } from "@/stores";
-
-describe("BlockLogicRule", () => {
- it("shows the Identifier and question text as plain text", () => {
- const pinia = createPinia();
- setActivePinia(pinia);
-
- useForm().blocks = [
- {
- uuid: "block1",
- type: "input-short",
- title: "
age ",
- message: "
<i>How old are you?</i>
",
- } as FormBlockModel,
- ];
-
- const wrapper = mount(BlockLogicRule, {
- props: {
- index: 0,
- rule: {
- name: "Rule",
- evaluate: "after",
- action: "goto",
- action_payload: "block1",
- conditions: [],
- } as unknown as FormBlockLogic,
- },
- global: { plugins: [pinia] },
- });
-
- expect(wrapper.find("b").exists()).toBe(false);
- expect(wrapper.find("i").exists()).toBe(false);
- expect(wrapper.find("span.bg-grey-700").text()).toBe("
age ");
- expect(wrapper.text()).toContain("
How old are you? ");
- });
-});
diff --git a/resources/js/components/Factory/Sidebar/BlockLogicRule.vue b/resources/js/components/Factory/Sidebar/BlockLogicRule.vue
deleted file mode 100644
index 9d3f0061..00000000
--- a/resources/js/components/Factory/Sidebar/BlockLogicRule.vue
+++ /dev/null
@@ -1,314 +0,0 @@
-
-
-
-
- {{ rule.name }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Source block
-
-
-
- {{ option.title }}
- {{ option.text }}
-
-
-
-
Operator
-
-
-
-
-
-
- Remove
-
-
-
-
-
-
-
You have no conditions set for this rule.
-
Click the button below to add a condition.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ option.title }}
- {{ option.text }}
-
-
-
-
-
-
-
-
-
-
-
diff --git a/resources/js/components/Factory/Sidebar/BlockLogicVisualizer.vue b/resources/js/components/Factory/Sidebar/BlockLogicVisualizer.vue
deleted file mode 100644
index 69c3738f..00000000
--- a/resources/js/components/Factory/Sidebar/BlockLogicVisualizer.vue
+++ /dev/null
@@ -1,79 +0,0 @@
-
-
-
-
-
-
-
- {{ condition.chainOperator }}
- If
-
-
-
-
-
- {{ condition.source }}
-
-
- {{ condition.operator }}
-
-
- {{ condition.value }}
-
-
-
-
-
-
-
- then
-
-
-
-
-
-
- show this block
-
-
-
- hide this block
-
-
-
- go to block
- {{ rule.action_payload }}
-
-
-
-
-
-
-
-
diff --git a/resources/js/components/Factory/Sidebar/ConsentBlockMessage.vue b/resources/js/components/Factory/Sidebar/ConsentBlockMessage.vue
deleted file mode 100644
index c9f795d1..00000000
--- a/resources/js/components/Factory/Sidebar/ConsentBlockMessage.vue
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
-
- Data Processing Consent
-
-
-
-
-
diff --git a/resources/js/components/Factory/Sidebar/DefaultBlockMessage.vue b/resources/js/components/Factory/Sidebar/DefaultBlockMessage.vue
deleted file mode 100644
index a6c98afc..00000000
--- a/resources/js/components/Factory/Sidebar/DefaultBlockMessage.vue
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
-
-
diff --git a/resources/js/components/Factory/Sidebar/Group.vue b/resources/js/components/Factory/Sidebar/Group.vue
deleted file mode 100644
index 098c5365..00000000
--- a/resources/js/components/Factory/Sidebar/Group.vue
+++ /dev/null
@@ -1,123 +0,0 @@
-
-
-
-
-
-
-
- {{ block.title ?? "Group" }}
- ({{ t("admin.blocks", groupCount) }})
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/resources/js/components/Factory/Sidebar/InsertAfterButton.vue b/resources/js/components/Factory/Sidebar/InsertAfterButton.vue
deleted file mode 100644
index 7dd11cb4..00000000
--- a/resources/js/components/Factory/Sidebar/InsertAfterButton.vue
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
-
-
-
-
-
-
diff --git a/resources/js/components/Factory/Sidebar/Storyboard.vue b/resources/js/components/Factory/Sidebar/Storyboard.vue
deleted file mode 100644
index 107ddae7..00000000
--- a/resources/js/components/Factory/Sidebar/Storyboard.vue
+++ /dev/null
@@ -1,121 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/resources/js/components/Factory/TopBar/FormMenu.vue b/resources/js/components/Factory/TopBar/FormMenu.vue
index f3700d07..916fd14b 100644
--- a/resources/js/components/Factory/TopBar/FormMenu.vue
+++ b/resources/js/components/Factory/TopBar/FormMenu.vue
@@ -15,11 +15,20 @@
-
+
+