diff --git a/README.md b/README.md index c6c46ef..2e6254f 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,10 @@ export const navigationItems: QuaireNavigationItem[] = [ ]; ``` +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. +Navigation items without any question are not shown. + ## Use quaire behaviour To use the default behavior you need to initialize `Quaire` with the data you diff --git a/examples/branched-and-merged-flow/branched-and-merged-flow.spec.ts b/examples/branched-and-merged-flow/branched-and-merged-flow.spec.ts index 02a157e..a2acbec 100644 --- a/examples/branched-and-merged-flow/branched-and-merged-flow.spec.ts +++ b/examples/branched-and-merged-flow/branched-and-merged-flow.spec.ts @@ -8,7 +8,7 @@ describe('branched-and-merged-flow', () => { Q = new Quaire({ items, navigationItems }); }); - test('should go through branch a', () => { + test('should go through branch A (range slider) and merge into question 4', () => { let activeQuestion = Q.getActiveQuestion(); expect(activeQuestion.question).toBe('Question 1'); @@ -67,7 +67,7 @@ describe('branched-and-merged-flow', () => { ]); }); - test('should go through branch b', () => { + test('should go through branch B (input) and merge into question 4', () => { let activeQuestion = Q.getActiveQuestion(); expect(activeQuestion.question).toBe('Question 1'); diff --git a/examples/custom-component-types/custom-component-types.spec.ts b/examples/custom-component-types/custom-component-types.spec.ts index 632e9c6..39ea077 100644 --- a/examples/custom-component-types/custom-component-types.spec.ts +++ b/examples/custom-component-types/custom-component-types.spec.ts @@ -8,7 +8,13 @@ describe('custom-component-types', () => { Q = new MyQuaire({ items, navigationItems }); }); - test('should validate new component types based on dependent answers', () => { + const answerAllQuestionsWithOption1 = () => { + Q.saveAnswer('option 1'); + Q.saveAnswer(['option 2', 'option 1']); + Q.saveAnswer(true); + }; + + test('should go through MULTI_SELECT and BOOLEAN questions', () => { let activeQuestion = Q.getActiveQuestion(); expect(activeQuestion.question).toBe('Question 1'); @@ -77,10 +83,13 @@ describe('custom-component-types', () => { value: true, }, ]); + }); + + test('should reset the MULTI_SELECT answer when the first answer changes', () => { + answerAllQuestionsWithOption1(); - // reset flow by choosing different answer Q.saveAnswer('option 2'); - activeQuestion = Q.getActiveQuestion(); + const activeQuestion = Q.getActiveQuestion(); expect(activeQuestion.question).toBe('Question 2'); expect(activeQuestion.componentType).toBe(MyComponentType.MULTI_SELECT); expect(activeQuestion.selectOptions).toEqual([ @@ -132,13 +141,16 @@ describe('custom-component-types', () => { value: true, }, ]); + }); + + test('should only accept MULTI_SELECT options that match the first answer', () => { + answerAllQuestionsWithOption1(); + Q.saveAnswer('option 2'); - // provide wrong answer Q.saveAnswer('option 1'); - activeQuestion = Q.getActiveQuestion(); + let activeQuestion = Q.getActiveQuestion(); expect(activeQuestion.question).toBe('Question 2'); - // provide correct answer Q.saveAnswer(['option 3', 'option 4']); activeQuestion = Q.getActiveQuestion(); expect(activeQuestion.question).toBe('Question 3'); diff --git a/examples/dependencies-between-questions/dependencies-between-questions.spec.ts b/examples/dependencies-between-questions/dependencies-between-questions.spec.ts index edbe95f..62547d1 100644 --- a/examples/dependencies-between-questions/dependencies-between-questions.spec.ts +++ b/examples/dependencies-between-questions/dependencies-between-questions.spec.ts @@ -8,7 +8,7 @@ describe('dependencies-between-questions', () => { Q = new Quaire({ items, navigationItems }); }); - test('should have select options based on interdependencies', () => { + test('should show select options based on the previous answers', () => { let activeQuestion = Q.getActiveQuestion(); expect(activeQuestion.selectOptions).toEqual([ { @@ -94,49 +94,11 @@ describe('dependencies-between-questions', () => { ]); }); - test('should react and validate later questions based on first question', () => { + test('should reset and invalidate dependent questions when the first answer changes', () => { Q.saveAnswer('option 1'); Q.saveAnswer('option 1.1'); Q.saveAnswer('option 1.1.1'); - expect(Q.getResult()).toEqual({ - foo: 'option 1', - bar: 'option 1.1', - baz: 'option 1.1.1', - }); - expect(Q.getNavigation()).toEqual([ - { - active: false, - componentType: 'SINGLE_SELECT', - hasValue: true, - id: 1, - isValid: true, - name: 'Category 1', - subNavigation: [ - { - active: false, - componentType: 'SINGLE_SELECT', - hasValue: true, - id: 2, - isValid: true, - name: 'Subcategory 1', - value: 'Option 1.1', - }, - ], - value: 'Option 1', - }, - { - active: true, - componentType: 'SINGLE_SELECT', - hasValue: true, - id: 3, - isValid: true, - name: 'Category 2', - subNavigation: [], - value: 'Option 1.1.1', - }, - ]); - Q.setActiveQuestionByQuestionId(1); Q.saveAnswer('option 2'); diff --git a/examples/extending-data-definition/extending-data-definition.spec.ts b/examples/extending-data-definition/extending-data-definition.spec.ts index 804abf3..80d560c 100644 --- a/examples/extending-data-definition/extending-data-definition.spec.ts +++ b/examples/extending-data-definition/extending-data-definition.spec.ts @@ -8,7 +8,7 @@ describe('extending-data-definition', () => { Q = new MyQuaire({ items, navigationItems }); }); - test('should validate new component types based on dependent answers', () => { + test('should add custom properties to questions and navigation items', () => { let activeQuestion = Q.getActiveQuestion(); expect(activeQuestion.question).toBe('Question 1'); expect(activeQuestion.progress).toBe(50); diff --git a/examples/linear-flow-skip-question/linear-flow-skip-question.spec.ts b/examples/linear-flow-skip-question/linear-flow-skip-question.spec.ts index 1aa1d94..02f6c70 100644 --- a/examples/linear-flow-skip-question/linear-flow-skip-question.spec.ts +++ b/examples/linear-flow-skip-question/linear-flow-skip-question.spec.ts @@ -1,14 +1,14 @@ import { NO_VALUE, Quaire } from '../../src'; import { items, navigationItems } from './data'; -describe('linear-flow', () => { +describe('linear-flow-skip-question', () => { let Q; beforeEach(() => { Q = new Quaire({ items, navigationItems }); }); - test('should go through the linear flow and skip 2. question', () => { + test('should skip question 2 when option 2 is chosen', () => { let activeQuestion = Q.getActiveQuestion(); expect(activeQuestion.question).toBe('Question 1'); @@ -61,7 +61,7 @@ describe('linear-flow', () => { ]); }); - test('should answer the 2. question with the skip option', () => { + test('should save NO_VALUE when question 2 is skipped', () => { let activeQuestion = Q.getActiveQuestion(); expect(activeQuestion.question).toBe('Question 1'); diff --git a/examples/linear-flow-with-loop/linear-flow-with-loop.spec.ts b/examples/linear-flow-with-loop/linear-flow-with-loop.spec.ts index 0243d2b..b920716 100644 --- a/examples/linear-flow-with-loop/linear-flow-with-loop.spec.ts +++ b/examples/linear-flow-with-loop/linear-flow-with-loop.spec.ts @@ -8,7 +8,7 @@ describe('linear-flow-with-loop', () => { Q = new Quaire({ items, navigationItems }); }); - test('should go through the linear flow and back to the first question', () => { + test('should go back to question 1 after the last question', () => { let activeQuestion = Q.getActiveQuestion(); expect(activeQuestion.question).toBe('Question 1'); diff --git a/examples/linear-flow/linear-flow.spec.ts b/examples/linear-flow/linear-flow.spec.ts index 03d36bd..494955f 100644 --- a/examples/linear-flow/linear-flow.spec.ts +++ b/examples/linear-flow/linear-flow.spec.ts @@ -8,12 +8,16 @@ describe('linear-flow', () => { Q = new Quaire({ items, navigationItems }); }); - test('should go through the linear flow', () => { - let activeQuestion = Q.getActiveQuestion(); - expect(activeQuestion.question).toBe('Question 1'); - + test('should stay on the question when the answer is not a valid option', () => { Q.saveAnswer('option 4'); - activeQuestion = Q.getActiveQuestion(); + + expect(Q.getActiveQuestion().question).toBe('Question 1'); + expect(Q.getResult()).toEqual({ foo: null }); + expect(Q.getValidationErrors()).toEqual({ '1': 'REQUIRED', '2': 'REQUIRED', '3': 'REQUIRED' }); + }); + + test('should go through all questions in order', () => { + let activeQuestion = Q.getActiveQuestion(); expect(activeQuestion.question).toBe('Question 1'); Q.saveAnswer('option 1'); diff --git a/examples/restore-state.spec.ts b/examples/restore-state.spec.ts index 3fb4716..5a07e51 100644 --- a/examples/restore-state.spec.ts +++ b/examples/restore-state.spec.ts @@ -1,8 +1,8 @@ import { Quaire } from '../src'; import { items, navigationItems } from './dependencies-between-questions/data'; -describe('restore state', () => { - test('should restore state from existing result', () => { +describe('restore-state', () => { + test('should restore the state from a complete result', () => { const Q = new Quaire({ items, navigationItems, @@ -58,7 +58,7 @@ describe('restore state', () => { ]); }); - test('should restore state from partially existing result', () => { + test('should restore the state from a partial result', () => { const Q = new Quaire({ items, navigationItems, result: { foo: 'option 1', bar: 'option 1.2' } }); expect(Q.getActiveQuestion().selectOptions).toEqual([ @@ -109,7 +109,7 @@ describe('restore state', () => { ]); }); - test('should restore state from empty existing result', () => { + test('should start at question 1 with an empty result', () => { const Q = new Quaire({ items, navigationItems, result: {} }); expect(Q.getActiveQuestion().selectOptions).toEqual([ diff --git a/examples/misc.spec.ts b/src/quaire.spec.ts similarity index 54% rename from examples/misc.spec.ts rename to src/quaire.spec.ts index ad8f92b..c0029db 100644 --- a/examples/misc.spec.ts +++ b/src/quaire.spec.ts @@ -1,6 +1,6 @@ -import { Quaire, QuaireComponentType, QuaireItem, QuaireNavigationItem } from '../src'; +import { Quaire, QuaireComponentType, QuaireItem, QuaireNavigationItem } from './index'; -describe('misc', () => { +describe('Quaire', () => { const items: Array = [ { id: 1, @@ -102,12 +102,12 @@ describe('misc', () => { { id: 4, parentId: 1, - name: 'Subcategory 2', + name: 'Subcategory 3', }, ]; - describe('components', () => { - test('different default values for input and range components based on dependencies', () => { + describe('dependent options', () => { + test('should use range, input and default values based on the dependent answer', () => { const Q = new Quaire({ items }); let activeQuestion = Q.getActiveQuestion(); @@ -169,14 +169,14 @@ describe('misc', () => { }); describe('navigation', () => { - test('empty items and navigation', () => { + test('should return an empty navigation without items', () => { const Q = new Quaire({ items: [] }); expect(Q.getActiveQuestion()).toBeNull(); expect(Q.getNavigation()).toEqual([]); }); - test('navigate via navigation item ID', () => { + test('should set the active question by navigation item ID', () => { const Q = new Quaire({ items, navigationItems }); Q.setActiveQuestionByNavigationItemId(3); @@ -188,5 +188,121 @@ describe('misc', () => { Q.setActiveQuestionByNavigationItemId(1); expect(Q.getActiveQuestion().id).toBe(1); }); + test('should derive a parent navigation item from its children when it has no question', () => { + const Q = new Quaire({ items, navigationItems }); + + expect(Q.getNavigation()).toEqual([ + { + id: 1, + name: 'Category 1', + value: null, + icon: undefined, + active: true, + isValid: false, + hasValue: false, + componentType: null, + subNavigation: [ + { + id: 2, + name: 'Subcategory 1', + value: null, + icon: undefined, + active: true, + isValid: false, + hasValue: false, + componentType: 'SINGLE_SELECT', + }, + { + id: 3, + name: 'Subcategory 2', + value: null, + icon: undefined, + active: false, + isValid: false, + hasValue: false, + componentType: 'RANGE_SLIDER', + }, + { + id: 4, + name: 'Subcategory 3', + value: null, + icon: undefined, + active: false, + isValid: false, + hasValue: false, + componentType: 'INPUT', + }, + ], + }, + ]); + + Q.saveAnswer('option 1'); + Q.saveAnswer([20, 50]); + Q.saveAnswer('text'); + + const [parent] = Q.getNavigation(); + expect(parent.hasValue).toBe(true); + expect(parent.isValid).toBe(true); + }); + + test('should skip navigation items without questions', () => { + const navigationItemsWithoutQuestions: Array = [ + { id: 1, parentId: null, name: 'Category 1' }, + { id: 2, parentId: 1, name: 'Subcategory 1' }, + { id: 3, parentId: 1, name: 'Subcategory without question' }, + { id: 5, parentId: null, name: 'Category without question' }, + ]; + const Q = new Quaire({ items: [items[0]], navigationItems: navigationItemsWithoutQuestions }); + + const navigation = Q.getNavigation(); + + expect(navigation.map((item) => item.name)).toEqual(['Category 1']); + expect(navigation[0].subNavigation.map((item) => item.name)).toEqual(['Subcategory 1']); + }); + }); + + describe('active question', () => { + test('should have no active question for an unknown question ID', () => { + const Q = new Quaire({ items }); + + Q.setActiveQuestionByQuestionId(99); + + expect(Q.getActiveQuestion()).toBeNull(); + }); + + test('should ignore answers when no question is active', () => { + const Q = new Quaire({ items, navigationItems }); + + Q.setActiveQuestionByNavigationItemId(99); + Q.saveAnswer('option 1'); + + expect(Q.getActiveQuestion()).toBeNull(); + expect(Q.getResult()).toEqual({}); + }); + }); + + describe('validation', () => { + test('should be invalid until all required questions are answered', () => { + const Q = new Quaire({ items }); + + expect(Q.isValid()).toBe(false); + expect(Q.getValidationErrors()).toEqual({ '1': 'REQUIRED', '2': 'REQUIRED', '3': 'REQUIRED' }); + + Q.saveAnswer('option 1'); + Q.saveAnswer([20, 50]); + Q.saveAnswer('text'); + + expect(Q.isValid()).toBe(true); + expect(Q.getValidationErrors()).toEqual({}); + }); + }); + + describe('restore state', () => { + test('should keep the result when there are no items', () => { + const Q = new Quaire({ items: [], result: { foo: 'option 1' } }); + + expect(Q.getActiveQuestion()).toBeNull(); + expect(Q.getResult()).toEqual({ foo: 'option 1' }); + }); }); }); diff --git a/src/quaire.ts b/src/quaire.ts index e9c378b..584089b 100644 --- a/src/quaire.ts +++ b/src/quaire.ts @@ -143,10 +143,10 @@ export class Quaire< protected _getQuestion(itemId: number | undefined) { const item = this._getItem(itemId); - if (!item) { - return null; - } + return item ? this._getQuestionFromItem(item) : null; + } + protected _getQuestionFromItem(item: IItem) { let selectOptions: Array | null; let rangeOption: QuaireRangeItemOption | null; let inputOption: QuaireInputItemOption | null; @@ -234,41 +234,38 @@ export class Quaire< protected _validate(activeQuestion: IQuestion | null) { this._items.forEach((item) => { - const question = this._getQuestion(item.id); - - if (question) { - const currentAnswer = this._getResultByValueProperty(question.resultProperty); - const possibleFollowUpQuestionIds: number[] = []; + const question = this._getQuestionFromItem(item); + const currentAnswer = this._getResultByValueProperty(question.resultProperty); + const possibleFollowUpQuestionIds: number[] = []; - question.dependsOnQuestions.forEach((q) => { - const dependsOnQuestionResult = this._result[q.resultProperty]; + question.dependsOnQuestions.forEach((q) => { + const dependsOnQuestionResult = this._result[q.resultProperty]; - // only take possible follow up id's based on the current result of the dependent question - q.selectOptions?.forEach((o) => { - if (o.value === dependsOnQuestionResult && o.nextItemId) { - possibleFollowUpQuestionIds.push(o.nextItemId); - } - }); + // only take possible follow up id's based on the current result of the dependent question + q.selectOptions?.forEach((o) => { + if (o.value === dependsOnQuestionResult && o.nextItemId) { + possibleFollowUpQuestionIds.push(o.nextItemId); + } }); + }); - // Some components always allow a dependent question to be in the flow - if (this._alwaysPossibleFollowUpQuestionComponents.includes(question.componentType)) { - possibleFollowUpQuestionIds.push(question.id); - } + // Some components always allow a dependent question to be in the flow + if (this._alwaysPossibleFollowUpQuestionComponents.includes(question.componentType)) { + possibleFollowUpQuestionIds.push(question.id); + } - const isQuestionInCurrentFlow = - question.dependsOnQuestions.length > 0 ? possibleFollowUpQuestionIds.includes(question.id) : true; + const isQuestionInCurrentFlow = + question.dependsOnQuestions.length > 0 ? possibleFollowUpQuestionIds.includes(question.id) : true; - if (!isQuestionInCurrentFlow) { - delete this._result[question.resultProperty]; - delete this._validationErrors[question.id]; - } else if (currentAnswer && this._selectComponentTypes.includes(question.componentType)) { - this._validateSelectComponent(question, currentAnswer); - } else if (currentAnswer && this._rangeComponentTypes.includes(question.componentType)) { - this._validateRangeComponent(question, activeQuestion); - } else { - this._validateGenericComponent(isQuestionInCurrentFlow, question, activeQuestion, currentAnswer); - } + if (!isQuestionInCurrentFlow) { + delete this._result[question.resultProperty]; + delete this._validationErrors[question.id]; + } else if (currentAnswer && this._selectComponentTypes.includes(question.componentType)) { + this._validateSelectComponent(question, currentAnswer); + } else if (currentAnswer && this._rangeComponentTypes.includes(question.componentType)) { + this._validateRangeComponent(question, activeQuestion); + } else { + this._validateGenericComponent(isQuestionInCurrentFlow, question, activeQuestion, currentAnswer); } }); } @@ -417,15 +414,36 @@ export class Quaire< return null; } + // used for parent navigation items without an own question, the values are derived from the children + protected _getParentNavigationItemObject(navigationItem: INavigationItem): INavigationItem { + const item: any = { + id: navigationItem.id, + name: navigationItem.name, + value: null, + icon: navigationItem.icon, + active: false, + isValid: true, + hasValue: false, + componentType: null, + subNavigation: [], + }; + + return item; + } + protected _addNavigationItem( navigationItems: { [key: string]: INavigationItem }, activeNavigationItem: INavigationItem | null, navigationItem: INavigationItem, + parentNavigationItemIds: Set, ) { const newNavigationItem = this._getNavigationItem(activeNavigationItem, navigationItem); if (newNavigationItem) { navigationItems[navigationItem.id] = newNavigationItem; + } else { + navigationItems[navigationItem.id] = this._getParentNavigationItemObject(navigationItem); + parentNavigationItemIds.add(navigationItem.id); } } @@ -446,18 +464,30 @@ export class Quaire< public getNavigation = (): INavigationItem[] => { const navigationItems: { [key: string]: INavigationItem } = {}; + const parentNavigationItemIds = new Set(); const activeNavigationItem = this._getActiveQuestionNavigationItem(); this._navigationItems.forEach((navigationItem) => { const hasParent = Boolean(navigationItem.parentId); if (!hasParent) { - this._addNavigationItem(navigationItems, activeNavigationItem, navigationItem); + this._addNavigationItem(navigationItems, activeNavigationItem, navigationItem, parentNavigationItemIds); } else { this._addChildNavigationItem(navigationItems, activeNavigationItem, navigationItem); } }); + parentNavigationItemIds.forEach((id) => { + const subNavigation = navigationItems[id].subNavigation as INavigationItem[]; + + if (subNavigation.length === 0) { + delete navigationItems[id]; + } else { + navigationItems[id].hasValue = subNavigation.some((child) => child.hasValue); + navigationItems[id].isValid = subNavigation.every((child) => child.isValid); + } + }); + return Object.values(navigationItems); }; diff --git a/src/utils.spec.ts b/src/utils.spec.ts index 3ab2e26..e49bdec 100644 --- a/src/utils.spec.ts +++ b/src/utils.spec.ts @@ -3,16 +3,16 @@ import { getByPath } from './utils'; describe('getByPath', () => { const object = { a: { b: [{ c: 'value' }] } }; - test('returns the value at the path', () => { + test('should return the value at the path', () => { expect(getByPath(object, ['a', 'b', '0', 'c'])).toBe('value'); }); - test('returns the default value for a missing path', () => { + test('should return the default value for a missing path', () => { expect(getByPath(object, ['a', 'x', 'c'], null)).toBeNull(); expect(getByPath(undefined, ['a'], null)).toBeNull(); }); - test('returns the default value for an empty path', () => { + test('should return the default value for an empty path', () => { expect(getByPath(object, [], null)).toBeNull(); }); }); diff --git a/vitest.config.mts b/vitest.config.mts index 28c50d1..2b836ee 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -11,10 +11,7 @@ export default defineConfig({ reportsDirectory: './coverage', reporter: ['html', 'text', 'lcov', 'json'], thresholds: { - branches: 88.31, - functions: 100, - lines: 99, - statements: 99, + 100: true, }, }, },