diff --git a/README.md b/README.md index 2e6254f..52b29d0 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ npm i --save quaire ## Define quaire data First you need to define the data (decision tree) based on the -[QuaireItem interface](https://github.com/devCrossNet/quaire/blob/main/src/interfaces.ts#L43). This can be static +[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. diff --git a/eslint.config.mjs b/eslint.config.mjs index 5f1cef7..1ed8a6b 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -20,7 +20,11 @@ export default defineConfig( }, }, rules: { - '@typescript-eslint/no-explicit-any': 0, + '@typescript-eslint/consistent-type-definitions': ['error', 'type'], + '@typescript-eslint/naming-convention': [ + 'error', + { selector: 'typeLike', format: ['PascalCase'], custom: { regex: '^I[A-Z]', match: false } }, + ], '@typescript-eslint/no-require-imports': 0, 'no-async-promise-executor': 0, 'no-prototype-builtins': 0, diff --git a/examples/custom-component-types/MyQuaire.ts b/examples/custom-component-types/MyQuaire.ts index ae0638e..f374af6 100644 --- a/examples/custom-component-types/MyQuaire.ts +++ b/examples/custom-component-types/MyQuaire.ts @@ -32,7 +32,7 @@ export class MyQuaire extends Quaire { } // override default behaviour and apply new logic for MULTI_SELECT components - protected _validateSelectComponent(question: QuaireQuestion, currentAnswer: any[]) { + protected _validateSelectComponent(question: QuaireQuestion, currentAnswer: unknown[]) { if (question.componentType === MyComponentType.MULTI_SELECT) { const options = question.selectOptions.filter((o) => currentAnswer.includes(o.value)); @@ -46,7 +46,7 @@ export class MyQuaire extends Quaire { } // override default behaviour and apply new logic for MULTI_SELECT components - protected _getNextItemIdFromSelectComponents = (activeQuestion: QuaireQuestion, answer: any | any[]) => { + protected _getNextItemIdFromSelectComponents = (activeQuestion: QuaireQuestion, answer: unknown) => { const option = activeQuestion.selectOptions.find((o) => Array.isArray(answer) ? answer.includes(o.value) : o.value === answer, ); @@ -72,10 +72,12 @@ export class MyQuaire extends Quaire { } // override default behaviour and apply new logic for MULTI_SELECT components - protected _getNavigationValue(question: QuaireQuestion, answer: any) { + protected _getNavigationValue(question: QuaireQuestion, answer: unknown) { if (question.componentType === MyComponentType.MULTI_SELECT) { + const answers = answer as unknown[]; + return question.selectOptions - .filter((selectOption) => answer.includes(selectOption.value)) + .filter((selectOption) => answers.includes(selectOption.value)) .map((selectOption) => selectOption.label); } else { return super._getNavigationValue(question, answer); diff --git a/examples/extending-data-definition/MyQuaire.ts b/examples/extending-data-definition/MyQuaire.ts index 13b6f02..f3090b8 100644 --- a/examples/extending-data-definition/MyQuaire.ts +++ b/examples/extending-data-definition/MyQuaire.ts @@ -8,25 +8,25 @@ import { QuaireRangeItemOption, } from '../../src'; -export interface MyRangeItemOption extends QuaireRangeItemOption { +export type MyRangeItemOption = QuaireRangeItemOption & { divisor: number; unit: string; -} +}; -export interface MyItem extends QuaireItem { +export type MyItem = QuaireItem & { progress: number; rangeOption: MyRangeItemOption; -} +}; -export interface MyQuestion extends QuaireQuestion { +export type MyQuestion = QuaireQuestion & { progress: number; rangeOption: MyRangeItemOption; -} +}; -export interface MyNavigationItem extends QuaireNavigationItem { +export type MyNavigationItem = QuaireNavigationItem & { progress?: number; unit?: string; -} +}; export class MyQuaire extends Quaire { protected _getQuestionObject( @@ -35,7 +35,7 @@ export class MyQuaire extends Quaire { selectOptions: QuaireItemOption[], rangeOption: QuaireRangeItemOption, inputOption: QuaireInputItemOption, - defaultValue: any, + defaultValue: unknown, ): MyQuestion { return { ...super._getQuestionObject(item, dependsOnKeys, selectOptions, rangeOption, inputOption, defaultValue), @@ -47,8 +47,8 @@ export class MyQuaire extends Quaire { activeNavigationItem: MyNavigationItem, navigationItem: MyNavigationItem, question: MyQuestion, - answer: any, - isParent, + answer: unknown, + isParent: boolean, ): MyNavigationItem { return { ...super._getNavigationItemObject(activeNavigationItem, navigationItem, question, answer, isParent), 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 02f6c70..c223b3f 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 @@ -2,7 +2,7 @@ import { NO_VALUE, Quaire } from '../../src'; import { items, navigationItems } from './data'; describe('linear-flow-skip-question', () => { - let Q; + let Q: Quaire; beforeEach(() => { Q = new Quaire({ items, navigationItems }); diff --git a/src/index.ts b/src/index.ts index 68af870..4278889 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ export * from './constants'; export * from './enums'; -export * from './interfaces'; +export * from './types'; export * from './quaire'; diff --git a/src/quaire.ts b/src/quaire.ts index 584089b..45ed714 100644 --- a/src/quaire.ts +++ b/src/quaire.ts @@ -8,18 +8,19 @@ import { QuaireOptions, QuaireQuestion, QuaireRangeItemOption, -} from './interfaces'; +} from './types'; import { QuaireComponentType, QuaireValidationError } from './enums'; import { getByPath, hasAnswer } from './utils'; export class Quaire< - IItem extends QuaireItem = QuaireItem, - IQuestion extends QuaireQuestion = QuaireQuestion, - INavigationItem extends QuaireNavigationItem = QuaireNavigationItem, -> implements QuaireBase { + Item extends QuaireItem = QuaireItem, + Question extends QuaireQuestion = QuaireQuestion, + NavigationItem extends QuaireNavigationItem = QuaireNavigationItem, +> implements QuaireBase { protected _activeItemId: number | null = null; - protected readonly _items: Array; - protected readonly _navigationItems: Array; + protected readonly _items: Array; + protected readonly _navigationItems: Array; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- answers can have any shape, consumers read them directly protected readonly _result: Record = {}; protected readonly _validationErrors: Record = {}; protected readonly _selectComponentTypes: Array = [QuaireComponentType.SINGLE_SELECT]; @@ -30,7 +31,7 @@ export class Quaire< QuaireComponentType.INPUT, ]; - constructor({ items, navigationItems, result }: QuaireOptions) { + constructor({ items, navigationItems, result }: QuaireOptions) { this._items = items; this._navigationItems = navigationItems || []; @@ -82,13 +83,13 @@ export class Quaire< } protected _getQuestionObject( - item: IItem, + item: Item, dependsOnKeys: string[], selectOptions: QuaireItemOption[] | null, rangeOption: QuaireRangeItemOption | null, inputOption: QuaireInputItemOption | null, - defaultValue: any, - ): IQuestion { + defaultValue: unknown, + ): Question { const value = this._getResultByValueProperty(item.resultProperty); let isValid = true; @@ -97,7 +98,7 @@ export class Quaire< isValid = false; } - return { + const question: QuaireQuestion = { id: item.id, navigationItemId: item.navigationItemId, question: item.question, @@ -122,10 +123,12 @@ export class Quaire< isValid, nextItemId: item.nextItemId, valueHasChanged: false, - } as any; + }; + + return question as Question; } - protected _getDependencyPath(item: IItem) { + protected _getDependencyPath(item: Item) { const path: Array = []; item.dependsOnResultProperties.forEach((resultProperty) => { @@ -146,17 +149,17 @@ export class Quaire< return item ? this._getQuestionFromItem(item) : null; } - protected _getQuestionFromItem(item: IItem) { + protected _getQuestionFromItem(item: Item) { let selectOptions: Array | null; let rangeOption: QuaireRangeItemOption | null; let inputOption: QuaireInputItemOption | null; - let defaultValue: any; + let defaultValue: unknown; if (item.dependsOnResultProperties.length > 0) { const path = this._getDependencyPath(item); - selectOptions = getByPath(item.selectOptions, path, null); - rangeOption = getByPath(item.rangeOption, path, null); - inputOption = getByPath(item.inputOption, path, null); + selectOptions = getByPath | null>(item.selectOptions, path, null); + rangeOption = getByPath(item.rangeOption, path, null); + inputOption = getByPath(item.inputOption, path, null); defaultValue = getByPath(item.defaultValue, path, null); } else { selectOptions = item.selectOptions ? (item.selectOptions as Array) : null; @@ -196,7 +199,7 @@ export class Quaire< return navigationItem || null; } - protected _validateSelectComponent(question: IQuestion, currentAnswer: any) { + protected _validateSelectComponent(question: Question, currentAnswer: unknown) { const option = question.selectOptions && question.selectOptions.find((o) => o.value === currentAnswer); if (question.required && !option) { @@ -205,7 +208,7 @@ export class Quaire< } } - protected _validateRangeComponent(question: IQuestion, activeQuestion: IQuestion | null) { + protected _validateRangeComponent(question: Question, activeQuestion: Question | null) { const isActiveQuestionADependency = !!question.dependsOnQuestions.find( (dq) => dq.resultProperty === activeQuestion?.resultProperty, ); @@ -218,9 +221,9 @@ export class Quaire< protected _validateGenericComponent( isQuestionInCurrentFlow: boolean, - question: IQuestion, - activeQuestion: IQuestion | null, - currentAnswer: any, + question: Question, + activeQuestion: Question | null, + currentAnswer: unknown, ) { if ( isQuestionInCurrentFlow && @@ -232,7 +235,7 @@ export class Quaire< } } - protected _validate(activeQuestion: IQuestion | null) { + protected _validate(activeQuestion: Question | null) { this._items.forEach((item) => { const question = this._getQuestionFromItem(item); const currentAnswer = this._getResultByValueProperty(question.resultProperty); @@ -303,22 +306,22 @@ export class Quaire< return null; } - protected _getNextItemIdFromSelectComponents = (activeQuestion: IQuestion | null, answer: any) => { + protected _getNextItemIdFromSelectComponents = (activeQuestion: Question | null, answer: unknown) => { const option = activeQuestion?.selectOptions?.find((o) => o.value === answer); return option?.nextItemId; }; // eslint-disable-next-line - protected _getNextItemIdFromRangeComponents = (activeQuestion: IQuestion | null, answer: any) => { + protected _getNextItemIdFromRangeComponents = (activeQuestion: Question | null, answer: unknown) => { return activeQuestion?.rangeOption?.nextItemId; }; // eslint-disable-next-line - protected _getNextItemIdFromInputComponents = (activeQuestion: IQuestion | null, answer: any) => { + protected _getNextItemIdFromInputComponents = (activeQuestion: Question | null, answer: unknown) => { return activeQuestion?.inputOption?.nextItemId; }; - public saveAnswer(answer: any) { + public saveAnswer(answer: unknown) { const activeQuestion = this.getActiveQuestion(); if (activeQuestion) { @@ -350,8 +353,8 @@ export class Quaire< } } - protected _getNavigationValue(question: IQuestion | null, answer: any): any { - let value: string | null | undefined; + protected _getNavigationValue(question: Question | null, answer: unknown): unknown { + let value: unknown; if (question && this._selectComponentTypes.includes(question.componentType)) { value = question?.selectOptions?.find((selectOption) => selectOption.value === answer)?.label; @@ -363,16 +366,16 @@ export class Quaire< } protected _getNavigationItemObject( - activeNavigationItem: INavigationItem | null, - navigationItem: INavigationItem, - question: IQuestion, - answer: any, + activeNavigationItem: NavigationItem | null, + navigationItem: NavigationItem, + question: Question, + answer: unknown, isParent: boolean, - ): INavigationItem { + ): NavigationItem { const active = activeNavigationItem?.id === navigationItem.id; const isValid = question.isValid; const hasValue = Boolean(answer); - let value: any = null; + let value: unknown = null; if (answer === NO_VALUE) { value = NO_VALUE; @@ -380,7 +383,7 @@ export class Quaire< value = this._getNavigationValue(question, answer); } - const item: any = { + const item: QuaireNavigationItem = { id: navigationItem.id, name: navigationItem.name, value, @@ -395,12 +398,12 @@ export class Quaire< item.subNavigation = []; } - return item; + return item as NavigationItem; } protected _getNavigationItem( - activeNavigationItem: INavigationItem | null, - navigationItem: INavigationItem, + activeNavigationItem: NavigationItem | null, + navigationItem: NavigationItem, isParent = true, ) { const question = this._getQuestionByNavigationItemId(navigationItem.id); @@ -415,8 +418,8 @@ export class Quaire< } // used for parent navigation items without an own question, the values are derived from the children - protected _getParentNavigationItemObject(navigationItem: INavigationItem): INavigationItem { - const item: any = { + protected _getParentNavigationItemObject(navigationItem: NavigationItem): NavigationItem { + const item: QuaireNavigationItem = { id: navigationItem.id, name: navigationItem.name, value: null, @@ -428,13 +431,13 @@ export class Quaire< subNavigation: [], }; - return item; + return item as NavigationItem; } protected _addNavigationItem( - navigationItems: { [key: string]: INavigationItem }, - activeNavigationItem: INavigationItem | null, - navigationItem: INavigationItem, + navigationItems: { [key: string]: NavigationItem }, + activeNavigationItem: NavigationItem | null, + navigationItem: NavigationItem, parentNavigationItemIds: Set, ) { const newNavigationItem = this._getNavigationItem(activeNavigationItem, navigationItem); @@ -448,9 +451,9 @@ export class Quaire< } protected _addChildNavigationItem( - navigationItems: { [key: string]: INavigationItem }, - activeNavigationItem: INavigationItem | null, - navigationItem: INavigationItem, + navigationItems: { [key: string]: NavigationItem }, + activeNavigationItem: NavigationItem | null, + navigationItem: NavigationItem, ) { const subNavigationItem = this._getNavigationItem(activeNavigationItem, navigationItem, false); @@ -462,8 +465,8 @@ export class Quaire< } } - public getNavigation = (): INavigationItem[] => { - const navigationItems: { [key: string]: INavigationItem } = {}; + public getNavigation = (): NavigationItem[] => { + const navigationItems: { [key: string]: NavigationItem } = {}; const parentNavigationItemIds = new Set(); const activeNavigationItem = this._getActiveQuestionNavigationItem(); @@ -478,7 +481,7 @@ export class Quaire< }); parentNavigationItemIds.forEach((id) => { - const subNavigation = navigationItems[id].subNavigation as INavigationItem[]; + const subNavigation = navigationItems[id].subNavigation as NavigationItem[]; if (subNavigation.length === 0) { delete navigationItems[id]; diff --git a/src/interfaces.ts b/src/types.ts similarity index 51% rename from src/interfaces.ts rename to src/types.ts index c11e89e..003eb7b 100644 --- a/src/interfaces.ts +++ b/src/types.ts @@ -1,49 +1,50 @@ import { QuaireComponentType, QuaireValidationError } from './enums'; -export interface QuaireBase< - IQuestion extends QuaireQuestion = QuaireQuestion, - INavigationItem extends QuaireNavigationItem = QuaireNavigationItem, -> { - saveAnswer(answer: any): void; - getActiveQuestion(): IQuestion | null; +export type QuaireBase< + Question extends QuaireQuestion = QuaireQuestion, + NavigationItem extends QuaireNavigationItem = QuaireNavigationItem, +> = { + saveAnswer(answer: unknown): void; + getActiveQuestion(): Question | null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- answers can have any shape, consumers read them directly getResult(): Record; getValidationErrors(): Record; setActiveQuestionByNavigationItemId(navigationItemId: number): void; setActiveQuestionByQuestionId(questionId: number): void; isValid(): boolean; - getNavigation(): Array; -} + getNavigation(): Array; +}; -export interface QuaireOptions< - IItem extends QuaireItem = QuaireItem, - INavigationItem extends QuaireNavigationItem = QuaireNavigationItem, -> { - items: Array; - navigationItems?: Array; - result?: Record; -} +export type QuaireOptions< + Item extends QuaireItem = QuaireItem, + NavigationItem extends QuaireNavigationItem = QuaireNavigationItem, +> = { + items: Array; + navigationItems?: Array; + result?: Record; +}; -export interface QuaireItemOption { +export type QuaireItemOption = { [key: string]: unknown | QuaireItemOption; label?: string; value?: unknown; nextItemId?: number; -} +}; -export interface QuaireRangeItemOption { +export type QuaireRangeItemOption = { [key: string]: unknown | QuaireRangeItemOption; range?: Array; nextItemId?: number; -} +}; -export interface QuaireInputItemOption { +export type QuaireInputItemOption = { [key: string]: unknown | QuaireInputItemOption; type?: string; // HTML input type e.g. text, number, date, etc. placeholder?: string; nextItemId?: number; -} +}; -export interface QuaireItem { +export type QuaireItem = { id: number; question: string; description: string; @@ -55,38 +56,41 @@ export interface QuaireItem { selectOptions?: Array | QuaireItemOption; rangeOption?: QuaireRangeItemOption; inputOption?: QuaireInputItemOption; - defaultValue?: any; + defaultValue?: unknown; nextItemId?: number; -} +}; -export interface QuaireNavigationItem { +export type QuaireNavigationItem = { id: number; name: string; - value?: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- answers can have any shape, consumers read them directly + value?: any; icon?: string; parentId?: number | null; active?: boolean; isValid?: boolean; hasValue?: boolean; subNavigation?: Array; - componentType?: QuaireComponentType | string; -} + componentType?: QuaireComponentType | string | null; +}; -export interface QuaireQuestion { +export type QuaireQuestion = { id: number; - navigationItemId: number; + navigationItemId?: number; question: string; description: string; required: boolean; resultProperty: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- answers can have any shape, consumers read them directly value: any; valueHasChanged?: boolean; componentType: QuaireComponentType | string; isValid: boolean; dependsOnQuestions: Array; - selectOptions?: Array; - rangeOption?: QuaireRangeItemOption; - inputOption?: QuaireInputItemOption; + selectOptions?: Array | null; + rangeOption?: QuaireRangeItemOption | null; + inputOption?: QuaireInputItemOption | null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- answers can have any shape, consumers read them directly defaultValue?: any; nextItemId?: number; -} +}; diff --git a/src/utils.spec.ts b/src/utils.spec.ts index e49bdec..3be29af 100644 --- a/src/utils.spec.ts +++ b/src/utils.spec.ts @@ -4,7 +4,7 @@ describe('getByPath', () => { const object = { a: { b: [{ c: 'value' }] } }; test('should return the value at the path', () => { - expect(getByPath(object, ['a', 'b', '0', 'c'])).toBe('value'); + expect(getByPath(object, ['a', 'b', '0', 'c'], null)).toBe('value'); }); test('should return the default value for a missing path', () => { diff --git a/src/utils.ts b/src/utils.ts index d5d5ee1..f1e64f6 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,13 +1,16 @@ -export const isNil = (value: any) => value === null || value === undefined; +export const isNil = (value: unknown): value is null | undefined => value === null || value === undefined; -export const hasAnswer = (answer: any) => (Array.isArray(answer) ? answer.length > 0 : isNil(answer) === false); +export const hasAnswer = (answer: unknown) => (Array.isArray(answer) ? answer.length > 0 : isNil(answer) === false); -export const getByPath = (object: any, path: Array, defaultValue: any = undefined) => { +export const getByPath = (object: unknown, path: Array, defaultValue: T): T => { if (path.length === 0) { return defaultValue; } - const value = path.reduce((current, key) => (isNil(current) ? undefined : current[key]), object); + const value = path.reduce( + (current, key) => (isNil(current) ? undefined : (current as Record)[key]), + object, + ); - return value === undefined ? defaultValue : value; + return value === undefined ? defaultValue : (value as T); }; diff --git a/vitest.config.mts b/vitest.config.mts index 2b836ee..f6d6cc3 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -7,7 +7,7 @@ export default defineConfig({ coverage: { provider: 'v8', include: ['src/**/*.ts', 'examples/**/*.ts'], - exclude: ['**/*.{test,spec}.ts', 'src/interfaces.ts'], + exclude: ['**/*.{test,spec}.ts', 'src/types.ts'], reportsDirectory: './coverage', reporter: ['html', 'text', 'lcov', 'json'], thresholds: {