diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
index 8f5729c..6f804b2 100644
--- a/.github/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -59,7 +59,7 @@ Before you submit your Pull Request (PR) consider the following guidelines:
- Make your changes in a new git branch:
```shell
- git checkout -b my-fix-branch next
+ git checkout -b my-fix-branch main
```
- Create your patch, **including appropriate test cases**.
@@ -89,7 +89,7 @@ Before you submit your Pull Request (PR) consider the following guidelines:
- Rebase your branch and force push to your GitHub repository (this will update your Pull Request):
```shell
- git rebase next -i
+ git rebase main
git push -f
```
@@ -106,10 +106,10 @@ from the main (upstream) repository:
git push origin --delete my-fix-branch
```
-- Check out the next branch:
+- Check out the main branch:
```shell
- git checkout next -f
+ git checkout main -f
```
- Delete the local branch:
@@ -118,10 +118,10 @@ from the main (upstream) repository:
git branch -D my-fix-branch
```
-- Update your next branch with the latest upstream version:
+- Update your main branch with the latest upstream version:
```shell
- git pull --ff upstream next
+ git pull --ff upstream main
```
## Coding Rules
diff --git a/README.md b/README.md
index 02923ab..757e77a 100644
--- a/README.md
+++ b/README.md
@@ -1,10 +1,14 @@
# quaire
-a framework-agnostic library to create user-flows, surveys, and questionnaires
+A framework-agnostic library to create user flows, surveys, and questionnaires.
-# What type of application can I build with quaire?
+You describe the flow as data (a decision tree). quaire takes care of the behavior:
+which question comes next, which answers are valid, and what the navigation looks like.
+You only build the view.
-The library was already used for
+# What can I build with quaire?
+
+The library is already used for:
- Call center software (call scripts)
- Surveys
@@ -14,190 +18,266 @@ The library was already used for
# Why the name?
-Because I needed one and `Questionnaire` is terrible to type. Let me know if you have a better one!
+Because I needed one, and `Questionnaire` is hard to type. Let me know if you have a better one!
-# What use-case does it try to solve?
+# What problem does it solve?
-I have to implement more and more features that behave like questionnaires or surveys.
-Over the years I tried a couple of things to make it easier for me to implement those features.
+I often have to build features that behave like questionnaires or surveys.
+Over the years, I tried a few things to make this easier.
-Of course, the first approach I tried was to implement a static user flow, for example:
+The first approach was a static user flow, for example:
-- present first question on first page
-- user selects option a
+- show the first question on the first page
+- the user selects option A
- navigate to the next question
- etc.
-That was ok as long as I didn't have to change the flow but when I had to change the flow,
-I found myself changing big parts of the implementation.
+This worked well until the flow changed. Then I had to change big parts of the implementation.
-I also tried to use finite state machines,
-but I ended up with spaghetti state machines most of the time
-due to regular changes in the user-flow (re-arranging questions, adding questions, skip questions, etc.)
+I also tried finite state machines, but most of the time I ended up with spaghetti state machines,
+because the flow changed often (questions were re-arranged, added, skipped, etc.).
-The solution I found to work best for me was a concept of game-development called decision-tree.
-That way the whole user-flow is based on a static data structure that can be changed without the need of changing
-the view or the behaviour. This pattern provides a nice separation of:
+The solution that worked best for me is a concept from game development: the decision tree.
+The whole user flow is a static data structure. You can change it without changing the view or the behavior.
+This gives you a clean separation of:
- data
-- behaviour
+- behavior
- presentation
-# Features?
-
-- Framework-agnostic
-- View independent
-- [Linear user-flow](https://github.com/devCrossNet/quaire/tree/main/examples/linear-flow)
-- [Branched/merged user-flow](https://github.com/devCrossNet/quaire/tree/main/examples/branched-and-merged-flow)
-- [Loops](https://github.com/devCrossNet/quaire/tree/main/examples/linear-flow-with-loop)
-- [Dependencies between questions and validation](https://github.com/devCrossNet/quaire/tree/main/examples/dependencies-between-questions)
-- [Re-storing state of the questionnaire from the selected answers](https://github.com/devCrossNet/quaire/blob/main/examples/restore-state.spec.ts)
-- Navigation
+# Features
-# How does it work?
+- Framework-agnostic and view-independent
+- Written in TypeScript, types included
+- No runtime dependencies
+- Linear flows, branches, merges, and loops
+- Optional questions that can be skipped
+- Options that depend on former answers
+- Validation that resets answers when they are no longer valid
+- Navigation with categories and subcategories
+- Restore the state from an existing result
+- Typed result
+- Extensible with custom component types and custom data
-## Installation
+# Installation
```shell
-npm i --save quaire
+npm i quaire
```
-## Define quaire data
+# Getting started
-First you need to define the data (decision tree) based on the
-[QuaireItem type](https://github.com/devCrossNet/quaire/blob/main/src/types.ts#L46). This can be static
-data in a JS/TS file, a JSON file that you load on demand
-or a dynamic JSON from a CMS or backend API.
+## 1. Define the questions
-The structure for questions looks as follows:
+The questions are a list of [QuaireItem](https://github.com/devCrossNet/quaire/blob/main/src/types.ts) objects.
+This can be static data in a JS/TS file, a JSON file that you load on demand,
+or JSON from a CMS or backend API.
-```js
-import { QuaireComponentType, QuaireItem, QuaireNavigationItem } from 'quaire';
+```ts
+import { QuaireComponentType, QuaireItem } from 'quaire';
export const items: QuaireItem[] = [
{
id: 1,
- resultProperty: 'foo', // property that is used to save the answer
- navigationItemId: 1, // association with the navigation entry (optional)
- dependsOnResultProperties: [], // dependencies on answers from former questions, based on the result property - not question ID
- componentType: QuaireComponentType.SINGLE_SELECT, // component type as indication for custom presentation logic
+ resultProperty: 'foo', // property in the result that stores the answer
+ navigationItemId: 1, // navigation entry of this question (optional)
+ dependsOnResultProperties: [], // result properties of former questions that change the options (see below)
+ componentType: QuaireComponentType.SINGLE_SELECT, // tells your view which component to render
question: 'Question 1',
description: 'Description 1',
required: true,
- selectOptions: [], // options for select components (optional)
- rangeOption: {}, // option for range components (optional)
- inputOption: {}, // option for input components (optional)
- defaultValue: {}, // default value for any kind of component (optional)
- nextItemId: {}, // id of the follow up question (optional), usually you want to define this in selectOptions, rangeOption or inputOption
+ selectOptions: [
+ { label: 'Option 1', value: 'option 1', nextItemId: 2 },
+ { label: 'Option 2', value: 'option 2', nextItemId: 3 },
+ ],
+ defaultValue: 'option 1', // (optional)
},
// ...
];
```
-The structure for the navigation looks a follows:
+### Component types
-```js
-export const navigationItems: QuaireNavigationItem[] = [
- {
- id: 1,
- parentId: null, // has no parent
- name: 'Category 1',
- },
- {
- id: 2,
- parentId: 1, // has a parent (works only for one level)
- name: 'Subcategory 1',
- },
- {
- id: 3,
- parentId: null,
- name: 'Category 2',
+The component type defines where quaire finds the next question:
+
+| Component type | Options | Next question |
+| ---------------- | ------------------------------------------------ | ----------------------------------- |
+| `SINGLE_SELECT` | `selectOptions: [{ label, value, nextItemId }]` | `nextItemId` of the selected option |
+| `RANGE_SLIDER` | `rangeOption: { range, nextItemId }` | `rangeOption.nextItemId` |
+| `INPUT` | `inputOption: { type, placeholder, nextItemId }` | `inputOption.nextItemId` |
+| any other string | - | `nextItemId` of the item |
+
+If there is no next question, the active question stays the same.
+You can add your own component types, see [Custom component types](https://github.com/devCrossNet/quaire/tree/main/examples/custom-component-types).
+
+### Options that depend on former answers
+
+Use `dependsOnResultProperties` when the options of a question depend on the answer of a former question.
+The options are then nested by the result property and the answer:
+
+```ts
+{
+ id: 2,
+ resultProperty: 'bar',
+ dependsOnResultProperties: ['foo'],
+ // ...
+ selectOptions: {
+ foo: {
+ 'option 1': [{ label: 'Option 1.1', value: 'option 1.1', nextItemId: 3 }],
+ 'option 2': [{ label: 'Option 2.1', value: 'option 2.1', nextItemId: 3 }],
+ },
},
+}
+```
+
+This works the same way for `rangeOption`, `inputOption`, and `defaultValue`.
+With more than one dependency, the objects are nested in the same order, for example
+`{ foo: { 'option 1': { bar: { 'option 1.1': [...] } } } }`.
+See [Dependencies between questions](https://github.com/devCrossNet/quaire/tree/main/examples/dependencies-between-questions).
+
+## 2. Define the navigation (optional)
+
+```ts
+import { QuaireNavigationItem } from 'quaire';
+
+export const navigationItems: QuaireNavigationItem[] = [
+ { id: 1, parentId: null, name: 'Category 1' }, // has no parent
+ { id: 2, parentId: 1, name: 'Subcategory 1' }, // has a parent (only one level is supported)
+ { id: 3, parentId: null, name: 'Category 2', icon: 'phone' },
];
```
-A navigation item without an own question is shown only if it has children with questions.
-For a parent item, `hasValue` and `isValid` are derived from its children.
+`getNavigation()` returns the navigation items with these additional properties:
+
+| Property | Description |
+| --------------- | -------------------------------------------------------------------------------- |
+| `value` | Answer of the question. For select components, the option label |
+| `active` | The active question belongs to this item (or one of its children) |
+| `hasValue` | The question of this item (or one of its children) has an answer |
+| `isValid` | The question of this item is valid. Without own question: all children are valid |
+| `componentType` | Component type of the question |
+| `subNavigation` | Children of a parent item |
+
+A parent item without its own question is shown only if it has children with questions.
Navigation items without any question are not shown.
+See [Navigation](https://github.com/devCrossNet/quaire/tree/main/examples/navigation).
-## Use quaire behaviour
+## 3. Use quaire
-To use the default behavior you need to initialize `Quaire` with the data you
-defined in the former step.
+Create a `Quaire` instance with your data:
-```js
+```ts
import { Quaire } from 'quaire';
-const q = new Quaire({ items, navigationItems }); // (optional) you can pass an existing result to restore the questionnaire
+const q = new Quaire({ items, navigationItems });
```
-Next you can get the first active Question and display it in any way you want
+Get the active question and show it in any way you want:
-```js
+```ts
let activeQuestion = q.getActiveQuestion(); // first question
-let navigation = q.getNavigation(); // initial navigation
-let result = q.getResult(); // initial result
+let navigation = q.getNavigation();
+let result = q.getResult(); // {}
+```
-// display activeQuestion.question and the related component presentation logic
-// Vue.js pseudo code example
+```vue
+
-
-
- {{ activeQuestion.question }}
- // loop through activeQuestion.selectOptions, etc.
-
-
+
+
{{ activeQuestion.question }}
+
+
```
-After the user selected an answer you can save the answer and get the next question.
-It's also a good idea to update the navigation and result
+When the user answers, save the answer. Then get the next question and update the navigation and the result:
-```js
-onSubmit(value: any) {
- q.saveAnswer(value);
- activeQuestion = q.getActiveQuestion(); // follow up question
- navigation = q.getNavigation(); // update navigation
- result = q.getResult(); // update result
+```ts
+function onSubmit(value: unknown) {
+ q.saveAnswer(value);
- // ...
+ activeQuestion = q.getActiveQuestion(); // next question
+ navigation = q.getNavigation();
+ result = q.getResult();
}
```
-You need to identify the end of the user-flow on your own.
-One way to do it is via the question ID or you create some logic around
-the result object of the Questionnaire.
-
-```js
-onSubmit(value: any) {
- // ...
- const isValid = q.isValid();
-
- // check if the questionnaire is valid
- if(!isValid) {
- return;
- }
-
- // via ID
- if(activeQuestion.id === 3) {
- // persist result to the backend, redirect to another page,
- // whatever you want after the questionnaire is filled out
- }
-
- // via result
- if(result.foo && result.bar && result.baz) {
- // persist result to the backend, redirect to another page,
- // whatever you want after the questionnaire is filled out
- }
+`defaultValue` is not saved automatically. To use it, pass it to `saveAnswer()`, for example as the initial value of your input.
+
+## 4. Detect the end of the flow
+
+You need to detect the end of the user flow yourself, for example via the question ID or via the result:
+
+```ts
+function onSubmit(value: unknown) {
+ // ...
+
+ if (!q.isValid()) {
+ return;
+ }
+
+ // via question ID
+ if (activeQuestion.id === 3) {
+ // save the result, redirect to another page, etc.
+ }
+
+ // via result
+ if (result.foo && result.bar && result.baz) {
+ // save the result, redirect to another page, etc.
+ }
}
```
+# Guides
+
+## Validation
+
+- A required question without an answer gets a `REQUIRED` error.
+- `isValid()` is `true` when there are no errors. `getValidationErrors()` returns the errors by question ID.
+- If an answer changes, dependent answers that are no longer valid are reset to `null` or removed.
+ Questions that are no longer part of the flow are removed from the result.
+
+## Skip an optional question
+
+There are two ways to skip a question:
+
+- Branch around it: an option of a former question points to the question after it.
+- Save the `NO_VALUE` constant as the answer. The navigation then shows the question as answered.
+
+```ts
+import { NO_VALUE } from 'quaire';
+
+q.saveAnswer(NO_VALUE);
+```
+
+See [Linear flow with an optional question](https://github.com/devCrossNet/quaire/tree/main/examples/linear-flow-skip-question).
+
+## Jump to a question
+
+```ts
+q.setActiveQuestionByQuestionId(2);
+q.setActiveQuestionByNavigationItemId(1); // a parent item without question jumps to its first child
+```
+
+After the user changes the answer, the flow continues with the next question. The other answers stay.
+
+## Restore the state
+
+Save the result (for example in your backend) and pass it back later.
+quaire replays the answers and continues with the first question that has no answer:
+
+```ts
+const q = new Quaire({ items, navigationItems, result: { foo: 'option 1', bar: 'option 1.2' } });
+```
+
+See [Restore state](https://github.com/devCrossNet/quaire/tree/main/examples/restore-state).
+
## Typed result
-By default all answers are `unknown`. You can pass your own result type as
-fourth type parameter to get a typed result. Each answer is optional and can be
-`null`, because answers are missing until they are given and are reset to `null`
-when they become invalid.
+By default, all answers are `unknown`. Pass your own result type as the fourth type parameter to get a typed result.
+Each answer is optional and can be `null`: it is missing until the user answers, and it is reset to `null` when it becomes invalid.
```ts
import { Quaire, QuaireItem, QuaireNavigationItem, QuaireQuestion } from 'quaire';
@@ -211,28 +291,45 @@ const q = new Quaire
const result = q.getResult(); // { foo?: string | null; bar?: Array | null }
```
+# API
+
+| Method | Description |
+| ------------------------------------------------------- | ---------------------------------------------------- |
+| `getActiveQuestion()` | Returns the active question or `null` |
+| `saveAnswer(answer)` | Saves the answer of the active question and moves on |
+| `getResult()` | Returns all answers by result property |
+| `getNavigation()` | Returns the navigation with values and states |
+| `isValid()` | Returns `true` when there are no validation errors |
+| `getValidationErrors()` | Returns the validation errors by question ID |
+| `setActiveQuestionByQuestionId(questionId)` | Sets the active question |
+| `setActiveQuestionByNavigationItemId(navigationItemId)` | Sets the active question by navigation item |
+
# Extend quaire
-- [Custom component types](https://github.com/devCrossNet/quaire/tree/main/examples/custom-component-types)
-- [Extending data definition](https://github.com/devCrossNet/quaire/tree/main/examples/extending-data-definition)
+`Quaire` is a class. Extend it and override its protected methods to change the behavior:
+
+- [Custom component types](https://github.com/devCrossNet/quaire/tree/main/examples/custom-component-types): add multi-select and boolean components
+- [Extending the data definition](https://github.com/devCrossNet/quaire/tree/main/examples/extending-data-definition): add your own properties to questions and navigation items
# Examples
+Each example has a README with a diagram, the data, and tests that show the behavior step by step.
+
- [Linear flow](https://github.com/devCrossNet/quaire/tree/main/examples/linear-flow)
- [Linear flow with a loop](https://github.com/devCrossNet/quaire/tree/main/examples/linear-flow-with-loop)
-- [Linear flow with option question](https://github.com/devCrossNet/quaire/tree/main/examples/linear-flow-skip-question)
+- [Linear flow with an optional question](https://github.com/devCrossNet/quaire/tree/main/examples/linear-flow-skip-question)
- [Branched flow that merges back into one](https://github.com/devCrossNet/quaire/tree/main/examples/branched-and-merged-flow)
- [Dependencies between questions and validation](https://github.com/devCrossNet/quaire/tree/main/examples/dependencies-between-questions)
-- [Re-storing state of the questionnaire from the selected answers](https://github.com/devCrossNet/quaire/tree/main/examples/restore-state.spec.ts)
+- [Navigation](https://github.com/devCrossNet/quaire/tree/main/examples/navigation)
+- [Restore state](https://github.com/devCrossNet/quaire/tree/main/examples/restore-state)
# Contribute
-Contributions are always welcome! Please read the [contribution guidelines](https://github.com/devCrossNet/quaire/blob/master/.github/CONTRIBUTING.md) first.
+Contributions are always welcome! Please read the [contribution guidelines](https://github.com/devCrossNet/quaire/blob/main/.github/CONTRIBUTING.md) first.
# Contact
- [Discord](https://discord.gg/59x5cg2)
-- [Twitter](https://twitter.com/_jwerner_)
# License
diff --git a/examples/branched-and-merged-flow/README.md b/examples/branched-and-merged-flow/README.md
index c21e35c..fa7302b 100644
--- a/examples/branched-and-merged-flow/README.md
+++ b/examples/branched-and-merged-flow/README.md
@@ -1,7 +1,12 @@
-# Example for a branched flow that merges back together
+# Branched flow that merges back into one
-The flow branches in the first question in two completely different flows
-that merge back together after one question (you can have as many branches as you want).
+Question 1 splits the flow into two branches that merge back into question 4.
+You can have as many branches as you want.
+
+- **Branch A:** question 2 is a `RANGE_SLIDER`. Its next question is defined in `rangeOption.nextItemId`.
+- **Branch B:** question 3 is an `INPUT`. Its next question is defined in `inputOption.nextItemId`.
+
+Only the answers of the chosen branch are in the result.
```
┌────────────┐
@@ -35,3 +40,5 @@ that merge back together after one question (you can have as many branches as yo
│ Option 1 ├─────┴──────┤ Option 2 │
└──────────┘ └──────────┘
```
+
+See [data.ts](./data.ts) for the data and [branched-and-merged-flow.spec.ts](./branched-and-merged-flow.spec.ts) for the behavior step by step.
diff --git a/examples/custom-component-types/MyQuaire.ts b/examples/custom-component-types/MyQuaire.ts
index 052ad43..25796bc 100644
--- a/examples/custom-component-types/MyQuaire.ts
+++ b/examples/custom-component-types/MyQuaire.ts
@@ -18,8 +18,8 @@ export class MyQuaire extends Quaire {
QuaireComponentType.SINGLE_SELECT,
MyComponentType.MULTI_SELECT,
];
- // these components always allow a dependent question to be in the flow
- // e.g. a follow up question that depends on a BOOLEAN component question will always be in the flow
+ // questions with these components always stay in the flow, even if they depend on former answers
+ // e.g. a BOOLEAN question that depends on a MULTI_SELECT answer is never removed from the flow
protected readonly _alwaysPossibleFollowUpQuestionComponents: Array = [
QuaireComponentType.RANGE_SLIDER,
QuaireComponentType.INPUT,
diff --git a/examples/custom-component-types/README.md b/examples/custom-component-types/README.md
index c007df8..dc45001 100644
--- a/examples/custom-component-types/README.md
+++ b/examples/custom-component-types/README.md
@@ -1,4 +1,19 @@
-# Example for custom component types
+# Custom component types
-This is an example how to extend Quaire with custom component types.
-It only extends the behaviour to support Multi-select and boolean components.
+This example extends `Quaire` with two new component types:
+
+- `MULTI_SELECT`: the answer is an array of option values
+- `BOOLEAN`: the answer is `true` or `false`
+
+[MyQuaire.ts](./MyQuaire.ts) overrides these protected properties and methods:
+
+| Override | Why |
+| ------------------------------------------- | ------------------------------------------------------------------------------------------ |
+| `_selectComponentTypes` | `MULTI_SELECT` uses `selectOptions` like `SINGLE_SELECT` |
+| `_alwaysPossibleFollowUpQuestionComponents` | a `BOOLEAN` question that depends on former answers always stays in the flow |
+| `_validateSelectComponent` | a `MULTI_SELECT` answer is valid if at least one value is a valid option |
+| `_getNextItemIdFromSelectComponents` | find the next question for an array of values |
+| `_getDependencyPath` | a `MULTI_SELECT` answer is sorted and joined with `_` to one key, e.g. `option 1_option 2` |
+| `_getNavigationValue` | show the labels of all selected options in the navigation |
+
+See [data.ts](./data.ts) for the data and [custom-component-types.spec.ts](./custom-component-types.spec.ts) for the behavior step by step.
diff --git a/examples/dependencies-between-questions/README.md b/examples/dependencies-between-questions/README.md
index 4c7af13..bc8e065 100644
--- a/examples/dependencies-between-questions/README.md
+++ b/examples/dependencies-between-questions/README.md
@@ -1,8 +1,32 @@
-# Example for questions that have dependencies to answers of former questions
+# Dependencies between questions and validation
-This is an example of dependencies between possible answers for a question based on existing answers.
-That means that Question 2 has a different set of possible options based on the answer to Question 1.
-And Question 3 has different options based on the answer to Question 1 and Question 2.
+The options of a question can depend on the answers of former questions:
+
+- Question 2 has different options, based on the answer to question 1.
+- Question 3 has different options, based on the answers to question 1 and question 2.
+
+The options are nested by result property and answer:
+
+```ts
+{
+ resultProperty: 'baz',
+ dependsOnResultProperties: ['foo', 'bar'],
+ selectOptions: {
+ foo: {
+ 'option 1': {
+ bar: {
+ 'option 1.1': [{ label: 'Option 1.1.1', value: 'option 1.1.1' }],
+ // ...
+ },
+ },
+ },
+ },
+}
+```
+
+When the user changes the answer to question 1, the answers to question 2 and 3 are no longer valid.
+quaire resets the answer to question 2 to `null`, removes the answer to question 3,
+and adds a `REQUIRED` validation error until the user answers again.
```
┌────────────┐
@@ -59,3 +83,5 @@ And Question 3 has different options based on the answer to Question 1 and Quest
│ Option 2.2.1 ├─────┴──────┤ Option 2.2.2 │
└──────────────┘ └──────────────┘
```
+
+See [data.ts](./data.ts) for the data and [dependencies-between-questions.spec.ts](./dependencies-between-questions.spec.ts) for the behavior step by step.
diff --git a/examples/extending-data-definition/README.md b/examples/extending-data-definition/README.md
index 3ce4aee..f1b4cd0 100644
--- a/examples/extending-data-definition/README.md
+++ b/examples/extending-data-definition/README.md
@@ -1,4 +1,16 @@
-# Example for extending data definition
+# Extending the data definition
-This is an example how to extend the Quaire data structures if you need additional information for your behaviour
-or presentation.
+This example adds custom properties to the data, for example to show a progress bar or a unit in your view.
+
+- `MyItem` adds `progress` to the questions and `divisor` and `unit` to `rangeOption`.
+- `MyQuestion` and `MyNavigationItem` add the same properties to the output.
+
+[MyQuaire.ts](./MyQuaire.ts) passes the types as type parameters (`Quaire`)
+and overrides two protected methods to copy the properties:
+
+- `_getQuestionObject`: adds `progress` to the active question
+- `_getNavigationItemObject`: adds `progress` and `unit` to the navigation items
+
+Custom properties in `rangeOption`, `selectOptions`, and `inputOption` are passed to the question without any override.
+
+See [data.ts](./data.ts) for the data and [extending-data-definition.spec.ts](./extending-data-definition.spec.ts) for the behavior step by step.
diff --git a/examples/linear-flow-skip-question/README.md b/examples/linear-flow-skip-question/README.md
index 13457b7..b7cc0a2 100644
--- a/examples/linear-flow-skip-question/README.md
+++ b/examples/linear-flow-skip-question/README.md
@@ -1,6 +1,11 @@
-# Example for a linear flow with an optional question that can be skipped
+# Linear flow with an optional question
-The second option of the first question allows the user to skip the second question.
+Question 2 is optional (`required: false`). This example shows both ways to skip it:
+
+- **Branch around it:** Option 2 of question 1 leads directly to question 3.
+- **Answer with `NO_VALUE`:** Question 2 has an option with the value `NO_VALUE`.
+ The question counts as answered, and the navigation shows `NO_VALUE` as its value.
+ Use this for a "skip" or "I don't know" button.
```
┌────────────┐
@@ -40,3 +45,5 @@ The second option of the first question allows the user to skip the second quest
│ Option 1 ├─────┴──────┤ Option 2 │
└──────────┘ └──────────┘
```
+
+See [data.ts](./data.ts) for the data and [linear-flow-skip-question.spec.ts](./linear-flow-skip-question.spec.ts) for the behavior step by step.
diff --git a/examples/linear-flow-with-loop/README.md b/examples/linear-flow-with-loop/README.md
index 6e4fb48..6f794d5 100644
--- a/examples/linear-flow-with-loop/README.md
+++ b/examples/linear-flow-with-loop/README.md
@@ -1,6 +1,7 @@
-# Example for a linear flow with a loop
+# Linear flow with a loop
-The second option of the last question allows the user to go back to the first question.
+Option 2 of the last question leads back to the first question.
+A `nextItemId` can point to any question, also to a former one.
```
┌────────────┐
@@ -40,3 +41,5 @@ The second option of the last question allows the user to go back to the first q
│ Option 1 ├─────┴──────┤ Option 2 ├────┘
└──────────┘ └──────────┘
```
+
+See [data.ts](./data.ts) for the data and [linear-flow-with-loop.spec.ts](./linear-flow-with-loop.spec.ts) for the behavior step by step.
diff --git a/examples/linear-flow/README.md b/examples/linear-flow/README.md
index 78c197a..b311b0f 100644
--- a/examples/linear-flow/README.md
+++ b/examples/linear-flow/README.md
@@ -1,6 +1,8 @@
-# Example for a linear flow
+# Linear flow
-This is a very simple flow with a couple of single select questions.
+A simple flow with three single-select questions. Every option leads to the next question.
+
+After the last question, the active question stays the same, because its options have no `nextItemId`.
```
┌────────────┐
@@ -40,3 +42,5 @@ This is a very simple flow with a couple of single select questions.
│ Option 1 ├─────┴──────┤ Option 2 │
└──────────┘ └──────────┘
```
+
+See [data.ts](./data.ts) for the data and [linear-flow.spec.ts](./linear-flow.spec.ts) for the behavior step by step.
diff --git a/examples/navigation/README.md b/examples/navigation/README.md
new file mode 100644
index 0000000..21708ca
--- /dev/null
+++ b/examples/navigation/README.md
@@ -0,0 +1,21 @@
+# Navigation
+
+A navigation with two categories and subcategories:
+
+```
+Personal (no own question)
+├── Name What is your name?
+└── Age How old are you?
+Contact How should we contact you?
+└── Details Where can we reach you?
+```
+
+This example shows:
+
+- A category without its own question ("Personal"). Its `hasValue` and `isValid` come from its children.
+ Selecting it with `setActiveQuestionByNavigationItemId` jumps to its first subcategory.
+- A category with its own question and subcategories ("Contact").
+- The navigation `value` of a select question is the label of the selected option ("E-Mail").
+- Jumping back to a question and changing the answer keeps the other answers and continues with the next question.
+
+See [data.ts](./data.ts) for the data and [navigation.spec.ts](./navigation.spec.ts) for the behavior step by step.
diff --git a/examples/navigation/data.ts b/examples/navigation/data.ts
new file mode 100644
index 0000000..7d737f4
--- /dev/null
+++ b/examples/navigation/data.ts
@@ -0,0 +1,97 @@
+import { QuaireComponentType, QuaireItem, QuaireNavigationItem } from '../../src';
+
+export const items: Array = [
+ {
+ id: 1,
+ resultProperty: 'name',
+ navigationItemId: 2,
+ dependsOnResultProperties: [],
+ componentType: QuaireComponentType.INPUT,
+ question: 'What is your name?',
+ description: '',
+ required: true,
+ inputOption: {
+ type: 'text',
+ nextItemId: 2,
+ },
+ },
+ {
+ id: 2,
+ resultProperty: 'age',
+ navigationItemId: 3,
+ dependsOnResultProperties: [],
+ componentType: QuaireComponentType.INPUT,
+ question: 'How old are you?',
+ description: '',
+ required: false,
+ inputOption: {
+ type: 'number',
+ nextItemId: 3,
+ },
+ },
+ {
+ id: 3,
+ resultProperty: 'contact',
+ navigationItemId: 4,
+ dependsOnResultProperties: [],
+ componentType: QuaireComponentType.SINGLE_SELECT,
+ question: 'How should we contact you?',
+ description: '',
+ required: true,
+ selectOptions: [
+ {
+ label: 'E-Mail',
+ value: 'email',
+ nextItemId: 4,
+ },
+ {
+ label: 'Phone',
+ value: 'phone',
+ nextItemId: 4,
+ },
+ ],
+ },
+ {
+ id: 4,
+ resultProperty: 'contactDetails',
+ navigationItemId: 5,
+ dependsOnResultProperties: [],
+ componentType: QuaireComponentType.INPUT,
+ question: 'Where can we reach you?',
+ description: '',
+ required: true,
+ inputOption: {
+ type: 'text',
+ },
+ },
+];
+
+export const navigationItems: Array = [
+ {
+ id: 1,
+ parentId: null,
+ name: 'Personal', // has no own question, only children
+ icon: 'user',
+ },
+ {
+ id: 2,
+ parentId: 1,
+ name: 'Name',
+ },
+ {
+ id: 3,
+ parentId: 1,
+ name: 'Age',
+ },
+ {
+ id: 4,
+ parentId: null,
+ name: 'Contact', // has an own question and children
+ icon: 'phone',
+ },
+ {
+ id: 5,
+ parentId: 4,
+ name: 'Details',
+ },
+];
diff --git a/examples/navigation/navigation.spec.ts b/examples/navigation/navigation.spec.ts
new file mode 100644
index 0000000..75ae539
--- /dev/null
+++ b/examples/navigation/navigation.spec.ts
@@ -0,0 +1,169 @@
+import { Quaire } from '../../src';
+import { items, navigationItems } from './data';
+
+describe('navigation', () => {
+ let Q: Quaire;
+
+ beforeEach(() => {
+ Q = new Quaire({ items, navigationItems });
+ });
+
+ const answerAllQuestions = () => {
+ Q.saveAnswer('Jane');
+ Q.saveAnswer(42);
+ Q.saveAnswer('email');
+ Q.saveAnswer('jane@example.com');
+ };
+
+ test('should show the initial navigation with categories and subcategories', () => {
+ expect(Q.getNavigation()).toEqual([
+ {
+ id: 1,
+ name: 'Personal',
+ value: null,
+ icon: 'user',
+ active: true,
+ isValid: false,
+ hasValue: false,
+ componentType: null,
+ subNavigation: [
+ {
+ id: 2,
+ name: 'Name',
+ value: null,
+ icon: undefined,
+ active: true,
+ isValid: false,
+ hasValue: false,
+ componentType: 'INPUT',
+ },
+ {
+ id: 3,
+ name: 'Age',
+ value: null,
+ icon: undefined,
+ active: false,
+ isValid: true,
+ hasValue: false,
+ componentType: 'INPUT',
+ },
+ ],
+ },
+ {
+ id: 4,
+ name: 'Contact',
+ value: null,
+ icon: 'phone',
+ active: false,
+ isValid: false,
+ hasValue: false,
+ componentType: 'SINGLE_SELECT',
+ subNavigation: [
+ {
+ id: 5,
+ name: 'Details',
+ value: null,
+ icon: undefined,
+ active: false,
+ isValid: false,
+ hasValue: false,
+ componentType: 'INPUT',
+ },
+ ],
+ },
+ ]);
+ });
+
+ test('should show the answers in the navigation', () => {
+ answerAllQuestions();
+
+ expect(Q.isValid()).toBe(true);
+ expect(Q.getNavigation()).toEqual([
+ {
+ id: 1,
+ name: 'Personal',
+ value: null,
+ icon: 'user',
+ active: false,
+ isValid: true,
+ hasValue: true,
+ componentType: null,
+ subNavigation: [
+ {
+ id: 2,
+ name: 'Name',
+ value: 'Jane',
+ icon: undefined,
+ active: false,
+ isValid: true,
+ hasValue: true,
+ componentType: 'INPUT',
+ },
+ {
+ id: 3,
+ name: 'Age',
+ value: 42,
+ icon: undefined,
+ active: false,
+ isValid: true,
+ hasValue: true,
+ componentType: 'INPUT',
+ },
+ ],
+ },
+ {
+ id: 4,
+ name: 'Contact',
+ value: 'E-Mail', // label of the selected option
+ icon: 'phone',
+ active: true,
+ isValid: true,
+ hasValue: true,
+ componentType: 'SINGLE_SELECT',
+ subNavigation: [
+ {
+ id: 5,
+ name: 'Details',
+ value: 'jane@example.com',
+ icon: undefined,
+ active: true,
+ isValid: true,
+ hasValue: true,
+ componentType: 'INPUT',
+ },
+ ],
+ },
+ ]);
+ });
+
+ test('should jump to a question via the navigation', () => {
+ answerAllQuestions();
+
+ // category without own question: jumps to the first subcategory
+ Q.setActiveQuestionByNavigationItemId(1);
+ expect(Q.getActiveQuestion().question).toBe('What is your name?');
+
+ // category with own question
+ Q.setActiveQuestionByNavigationItemId(4);
+ expect(Q.getActiveQuestion().question).toBe('How should we contact you?');
+
+ // subcategory
+ Q.setActiveQuestionByNavigationItemId(3);
+ expect(Q.getActiveQuestion().question).toBe('How old are you?');
+ });
+
+ test('should continue with the next question after changing an answer', () => {
+ answerAllQuestions();
+
+ Q.setActiveQuestionByQuestionId(1);
+ Q.saveAnswer('John');
+
+ expect(Q.getActiveQuestion().question).toBe('How old are you?');
+ expect(Q.getResult()).toEqual({
+ name: 'John',
+ age: 42,
+ contact: 'email',
+ contactDetails: 'jane@example.com',
+ });
+ });
+});
diff --git a/examples/restore-state/README.md b/examples/restore-state/README.md
new file mode 100644
index 0000000..52de34a
--- /dev/null
+++ b/examples/restore-state/README.md
@@ -0,0 +1,14 @@
+# Restore state
+
+Pass an existing result to restore a questionnaire, for example after a page reload
+or when the user continues later:
+
+```ts
+const q = new Quaire({ items, navigationItems, result: { foo: 'option 1', bar: 'option 1.2' } });
+```
+
+quaire replays the answers in the order of the flow. It continues with the first question that has no answer
+and shows the options that match the former answers.
+
+This example uses the data of [dependencies-between-questions](../dependencies-between-questions/data.ts).
+See [restore-state.spec.ts](./restore-state.spec.ts) for a complete, a partial, and an empty result.
diff --git a/examples/restore-state.spec.ts b/examples/restore-state/restore-state.spec.ts
similarity index 96%
rename from examples/restore-state.spec.ts
rename to examples/restore-state/restore-state.spec.ts
index 5a07e51..908d1fc 100644
--- a/examples/restore-state.spec.ts
+++ b/examples/restore-state/restore-state.spec.ts
@@ -1,5 +1,5 @@
-import { Quaire } from '../src';
-import { items, navigationItems } from './dependencies-between-questions/data';
+import { Quaire } from '../../src';
+import { items, navigationItems } from '../dependencies-between-questions/data';
describe('restore-state', () => {
test('should restore the state from a complete result', () => {
@@ -129,7 +129,7 @@ describe('restore-state', () => {
{
active: true,
componentType: 'SINGLE_SELECT',
- hasValue: true,
+ hasValue: false,
id: 1,
isValid: false,
name: 'Category 1',
diff --git a/src/quaire.ts b/src/quaire.ts
index 082e57c..dfcb637 100644
--- a/src/quaire.ts
+++ b/src/quaire.ts
@@ -254,7 +254,7 @@ export class Quaire<
});
});
- // Some components always allow a dependent question to be in the flow
+ // questions with some components always stay in the flow, even if they depend on former answers
if (this._alwaysPossibleFollowUpQuestionComponents.includes(question.componentType)) {
possibleFollowUpQuestionIds.push(question.id);
}
@@ -464,7 +464,7 @@ export class Quaire<
if (parent && subNavigationItem) {
parent.subNavigation?.push(subNavigationItem);
parent.active = parent.active || activeNavigationItem?.parentId === navigationItem.parentId;
- parent.hasValue = true;
+ parent.hasValue = parent.hasValue || subNavigationItem.hasValue;
}
}
@@ -492,7 +492,6 @@ export class Quaire<
if (subNavigation.length === 0) {
delete navigationItems[id];
} else {
- navigationItems[id].hasValue = subNavigation.some((child) => child.hasValue);
navigationItems[id].isValid = subNavigation.every((child) => child.isValid);
}
});