From eff874d8dfcc16289205501b4b8fba5afd5df051 Mon Sep 17 00:00:00 2001 From: Johannes Werner Date: Thu, 8 Oct 2026 19:19:33 +0200 Subject: [PATCH 1/3] fix(navigation): support children before their parent in navigation items getNavigation() threw a TypeError when a child navigation item came before its parent. Parents are now added first. Children with an unknown parent are ignored. Co-Authored-By: Claude Opus 5.5 --- src/quaire.spec.ts | 18 ++++++++++++++++++ src/quaire.ts | 28 ++++++++++++++++------------ 2 files changed, 34 insertions(+), 12 deletions(-) diff --git a/src/quaire.spec.ts b/src/quaire.spec.ts index c0029db..2bd1a97 100644 --- a/src/quaire.spec.ts +++ b/src/quaire.spec.ts @@ -261,6 +261,24 @@ describe('Quaire', () => { }); }); + describe('navigation order', () => { + test('should add children that come before their parent', () => { + const [parent, ...children] = navigationItems; + const Q = new Quaire({ items, navigationItems: [...children, parent] }); + + expect(Q.getNavigation()).toEqual(new Quaire({ items, navigationItems }).getNavigation()); + }); + + test('should ignore children with an unknown parent', () => { + const navigationItemsWithUnknownParent: Array = [ + { id: 2, parentId: 99, name: 'Subcategory with unknown parent' }, + ]; + const Q = new Quaire({ items, navigationItems: navigationItemsWithUnknownParent }); + + expect(Q.getNavigation()).toEqual([]); + }); + }); + describe('active question', () => { test('should have no active question for an unknown question ID', () => { const Q = new Quaire({ items }); diff --git a/src/quaire.ts b/src/quaire.ts index 45ed714..a3d6f2d 100644 --- a/src/quaire.ts +++ b/src/quaire.ts @@ -455,13 +455,14 @@ export class Quaire< activeNavigationItem: NavigationItem | null, navigationItem: NavigationItem, ) { + const parent = navigationItems[navigationItem.parentId as number]; const subNavigationItem = this._getNavigationItem(activeNavigationItem, navigationItem, false); - if (subNavigationItem && navigationItem.parentId) { - navigationItems[navigationItem.parentId]?.subNavigation?.push(subNavigationItem); - navigationItems[navigationItem.parentId].active = - navigationItems[navigationItem.parentId].active || activeNavigationItem?.parentId === navigationItem.parentId; - navigationItems[navigationItem.parentId].hasValue = true; + // ignore children with unknown parents + if (parent && subNavigationItem) { + parent.subNavigation?.push(subNavigationItem); + parent.active = parent.active || activeNavigationItem?.parentId === navigationItem.parentId; + parent.hasValue = true; } } @@ -470,15 +471,18 @@ export class Quaire< const parentNavigationItemIds = new Set(); const activeNavigationItem = this._getActiveQuestionNavigationItem(); - this._navigationItems.forEach((navigationItem) => { - const hasParent = Boolean(navigationItem.parentId); - - if (!hasParent) { + // add all parents first, so children can be added independent of their position in the list + this._navigationItems + .filter((navigationItem) => !navigationItem.parentId) + .forEach((navigationItem) => { this._addNavigationItem(navigationItems, activeNavigationItem, navigationItem, parentNavigationItemIds); - } else { + }); + + this._navigationItems + .filter((navigationItem) => navigationItem.parentId) + .forEach((navigationItem) => { this._addChildNavigationItem(navigationItems, activeNavigationItem, navigationItem); - } - }); + }); parentNavigationItemIds.forEach((id) => { const subNavigation = navigationItems[id].subNavigation as NavigationItem[]; From 7bf622e63ef753f03de7cf28a3871b8d1b21ba9d Mon Sep 17 00:00:00 2001 From: Johannes Werner Date: Thu, 8 Oct 2026 19:21:03 +0200 Subject: [PATCH 2/3] feat!: add generic result type and remove remaining any types - add Result type parameter to Quaire, QuaireBase and QuaireOptions - getResult() returns QuairePartialResult - question and navigation values are unknown instead of any - add type tests with expectTypeOf and document typed results BREAKING CHANGE: getResult(), QuaireQuestion.value, QuaireQuestion.defaultValue and QuaireNavigationItem.value are no longer any. Pass a result type as fourth type parameter or narrow the values. Co-Authored-By: Claude Opus 5.5 --- README.md | 19 ++++++++++ examples/custom-component-types/MyQuaire.ts | 4 ++- src/quaire.spec.ts | 40 ++++++++++++++++++++- src/quaire.ts | 16 +++++---- src/types.ts | 23 +++++++----- 5 files changed, 84 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 52b29d0..02923ab 100644 --- a/README.md +++ b/README.md @@ -192,6 +192,25 @@ onSubmit(value: any) { } ``` +## 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. + +```ts +import { Quaire, QuaireItem, QuaireNavigationItem, QuaireQuestion } from 'quaire'; + +type MyResult = { + foo: string; + bar: Array; +}; + +const q = new Quaire({ items, navigationItems }); +const result = q.getResult(); // { foo?: string | null; bar?: Array | null } +``` + # Extend quaire - [Custom component types](https://github.com/devCrossNet/quaire/tree/main/examples/custom-component-types) diff --git a/examples/custom-component-types/MyQuaire.ts b/examples/custom-component-types/MyQuaire.ts index f374af6..052ad43 100644 --- a/examples/custom-component-types/MyQuaire.ts +++ b/examples/custom-component-types/MyQuaire.ts @@ -65,7 +65,9 @@ export class MyQuaire extends Quaire { if (resultPropertyValue) { path.push(resultProperty); - path.push(Array.isArray(resultPropertyValue) ? resultPropertyValue.sort().join('_') : resultPropertyValue); + path.push( + Array.isArray(resultPropertyValue) ? resultPropertyValue.sort().join('_') : String(resultPropertyValue), + ); } }); return path; diff --git a/src/quaire.spec.ts b/src/quaire.spec.ts index 2bd1a97..54fe92e 100644 --- a/src/quaire.spec.ts +++ b/src/quaire.spec.ts @@ -1,4 +1,11 @@ -import { Quaire, QuaireComponentType, QuaireItem, QuaireNavigationItem } from './index'; +import { + Quaire, + QuaireComponentType, + QuaireItem, + QuaireNavigationItem, + QuairePartialResult, + QuaireQuestion, +} from './index'; describe('Quaire', () => { const items: Array = [ @@ -315,6 +322,37 @@ describe('Quaire', () => { }); }); + describe('result type', () => { + // eslint-disable-next-line @typescript-eslint/consistent-type-definitions -- interfaces must work as result type + interface MyResult { + foo: string; + bar: Array; + baz: string | number; + } + + test('should type the result with a custom result type', () => { + const Q = new Quaire({ + items, + result: { foo: 'option 1' }, + }); + + Q.saveAnswer([20, 50]); + + const result = Q.getResult(); + + expectTypeOf(result.foo).toEqualTypeOf(); + expectTypeOf(result.bar).toEqualTypeOf | null | undefined>(); + expect(result).toEqual({ foo: 'option 1', bar: [20, 50] }); + }); + + test('should use unknown values without a custom result type', () => { + const Q = new Quaire({ items }); + + expectTypeOf(Q.getResult()).toEqualTypeOf(); + expectTypeOf(Q.getActiveQuestion().value).toEqualTypeOf(); + }); + }); + describe('restore state', () => { test('should keep the result when there are no items', () => { const Q = new Quaire({ items: [], result: { foo: 'option 1' } }); diff --git a/src/quaire.ts b/src/quaire.ts index a3d6f2d..082e57c 100644 --- a/src/quaire.ts +++ b/src/quaire.ts @@ -6,8 +6,10 @@ import { QuaireItemOption, QuaireNavigationItem, QuaireOptions, + QuairePartialResult, QuaireQuestion, QuaireRangeItemOption, + QuaireResult, } from './types'; import { QuaireComponentType, QuaireValidationError } from './enums'; import { getByPath, hasAnswer } from './utils'; @@ -16,12 +18,12 @@ export class Quaire< Item extends QuaireItem = QuaireItem, Question extends QuaireQuestion = QuaireQuestion, NavigationItem extends QuaireNavigationItem = QuaireNavigationItem, -> implements QuaireBase { + Result extends object = QuaireResult, +> implements QuaireBase { protected _activeItemId: number | null = null; 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 _result: QuaireResult = {}; protected readonly _validationErrors: Record = {}; protected readonly _selectComponentTypes: Array = [QuaireComponentType.SINGLE_SELECT]; protected readonly _rangeComponentTypes: Array = [QuaireComponentType.RANGE_SLIDER]; @@ -31,7 +33,7 @@ export class Quaire< QuaireComponentType.INPUT, ]; - constructor({ items, navigationItems, result }: QuaireOptions) { + constructor({ items, navigationItems, result }: QuaireOptions) { this._items = items; this._navigationItems = navigationItems || []; @@ -40,7 +42,7 @@ export class Quaire< } if (result) { - this._result = result; + this._result = result as QuaireResult; this._setActiveItemId(); } @@ -136,7 +138,7 @@ export class Quaire< if (resultPropertyValue) { path.push(resultProperty); - path.push(resultPropertyValue); + path.push(String(resultPropertyValue)); } }); @@ -274,7 +276,7 @@ export class Quaire< } public getResult() { - return this._result; + return this._result as QuairePartialResult; } public getValidationErrors() { diff --git a/src/types.ts b/src/types.ts index 003eb7b..72fe5f5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,13 +1,20 @@ import { QuaireComponentType, QuaireValidationError } from './enums'; +export type QuaireResult = Record; + +// answers are missing until they are given and null when they became invalid +export type QuairePartialResult = { + [Key in keyof Result]?: Result[Key] | null; +}; + export type QuaireBase< Question extends QuaireQuestion = QuaireQuestion, NavigationItem extends QuaireNavigationItem = QuaireNavigationItem, + Result extends object = QuaireResult, > = { 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; + getResult(): QuairePartialResult; getValidationErrors(): Record; setActiveQuestionByNavigationItemId(navigationItemId: number): void; setActiveQuestionByQuestionId(questionId: number): void; @@ -18,10 +25,11 @@ export type QuaireBase< export type QuaireOptions< Item extends QuaireItem = QuaireItem, NavigationItem extends QuaireNavigationItem = QuaireNavigationItem, + Result extends object = QuaireResult, > = { items: Array; navigationItems?: Array; - result?: Record; + result?: QuairePartialResult; }; export type QuaireItemOption = { @@ -63,8 +71,7 @@ export type QuaireItem = { export type QuaireNavigationItem = { id: number; name: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- answers can have any shape, consumers read them directly - value?: any; + value?: unknown; icon?: string; parentId?: number | null; active?: boolean; @@ -81,8 +88,7 @@ export type QuaireQuestion = { 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; + value: unknown; valueHasChanged?: boolean; componentType: QuaireComponentType | string; isValid: boolean; @@ -90,7 +96,6 @@ export type QuaireQuestion = { 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; + defaultValue?: unknown; nextItemId?: number; }; From 8d265ee5bc0efa74d3e0ab6921286dd16f526adb Mon Sep 17 00:00:00 2001 From: Johannes Werner Date: Thu, 8 Oct 2026 19:21:39 +0200 Subject: [PATCH 3/3] build!: define package exports and publish only dist - add exports map with types and default condition - remove examples from the published package - remove .npmignore, files already defines the package content BREAKING CHANGE: deep imports like quaire/dist/types are no longer possible and examples are no longer part of the npm package. Co-Authored-By: Claude Opus 5.5 --- .npmignore | 11 ----------- package.json | 10 ++++++++-- 2 files changed, 8 insertions(+), 13 deletions(-) delete mode 100644 .npmignore diff --git a/.npmignore b/.npmignore deleted file mode 100644 index 7d79863..0000000 --- a/.npmignore +++ /dev/null @@ -1,11 +0,0 @@ -.github -coverage -src -.eslintcache -eslint.config.mjs -vitest.config.mts -.prettierignore -.prettierrc -commitlint.config.mjs -tsconfig.json -tsconfig.test.json diff --git a/package.json b/package.json index 36e2375..6db9d19 100644 --- a/package.json +++ b/package.json @@ -4,9 +4,15 @@ "version": "0.1.0", "main": "./dist/index.js", "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./package.json": "./package.json" + }, "files": [ - "dist", - "examples" + "dist" ], "keywords": [ "user-flow",