From 8df03209ecda3eed34a0fd673fa7864949d9ad99 Mon Sep 17 00:00:00 2001 From: Johannes Werner Date: Thu, 8 Oct 2026 19:31:04 +0200 Subject: [PATCH 1/3] fix(navigation)!: derive hasValue of parent items from their children A parent navigation item with an own question got hasValue: true as soon as it had a child, even if no question had an answer. hasValue is now true only if the parent or one of its children has a value. Also move the restore-state example into its own folder. BREAKING CHANGE: hasValue of parent navigation items is false until the parent or one of its children has an answer. Co-Authored-By: Claude Opus 5.5 --- examples/{ => restore-state}/restore-state.spec.ts | 6 +++--- src/quaire.ts | 3 +-- 2 files changed, 4 insertions(+), 5 deletions(-) rename examples/{ => restore-state}/restore-state.spec.ts (96%) 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..884c905 100644 --- a/src/quaire.ts +++ b/src/quaire.ts @@ -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); } }); From 49019ba48805562adce30a9c10d8fa2a8d9a2036 Mon Sep 17 00:00:00 2001 From: Johannes Werner Date: Thu, 8 Oct 2026 19:33:05 +0200 Subject: [PATCH 2/3] docs: rewrite readme and example docs, add navigation example - restructure readme: getting started, guides, api table - document component types, dependent options, navigation output, validation, NO_VALUE, jumping to questions and restoring state - fix wrong code samples and broken links - explain every example and link its data and tests - add navigation example with tests - fix misleading comments about always possible follow up questions - replace outdated next branch in contributing guide Co-Authored-By: Claude Opus 5.5 --- .github/CONTRIBUTING.md | 12 +- README.md | 354 +++++++++++------- examples/branched-and-merged-flow/README.md | 13 +- examples/custom-component-types/MyQuaire.ts | 4 +- examples/custom-component-types/README.md | 21 +- .../dependencies-between-questions/README.md | 34 +- examples/extending-data-definition/README.md | 18 +- examples/linear-flow-skip-question/README.md | 11 +- examples/linear-flow-with-loop/README.md | 7 +- examples/linear-flow/README.md | 8 +- examples/navigation/README.md | 21 ++ examples/navigation/data.ts | 97 +++++ examples/navigation/navigation.spec.ts | 169 +++++++++ examples/restore-state/README.md | 14 + src/quaire.ts | 2 +- 15 files changed, 629 insertions(+), 156 deletions(-) create mode 100644 examples/navigation/README.md create mode 100644 examples/navigation/data.ts create mode 100644 examples/navigation/navigation.spec.ts create mode 100644 examples/restore-state/README.md 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..425b257 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 +