Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 0 additions & 11 deletions .npmignore

This file was deleted.

19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<number>;
};

const q = new Quaire<QuaireItem, QuaireQuestion, QuaireNavigationItem, MyResult>({ items, navigationItems });
const result = q.getResult(); // { foo?: string | null; bar?: Array<number> | null }
```

# Extend quaire

- [Custom component types](https://github.com/devCrossNet/quaire/tree/main/examples/custom-component-types)
Expand Down
4 changes: 3 additions & 1 deletion examples/custom-component-types/MyQuaire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
10 changes: 8 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
58 changes: 57 additions & 1 deletion src/quaire.spec.ts
Original file line number Diff line number Diff line change
@@ -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<QuaireItem> = [
Expand Down Expand Up @@ -261,6 +268,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<QuaireNavigationItem> = [
{ 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 });
Expand Down Expand Up @@ -297,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<number>;
baz: string | number;
}

test('should type the result with a custom result type', () => {
const Q = new Quaire<QuaireItem, QuaireQuestion, QuaireNavigationItem, MyResult>({
items,
result: { foo: 'option 1' },
});

Q.saveAnswer([20, 50]);

const result = Q.getResult();

expectTypeOf(result.foo).toEqualTypeOf<string | null | undefined>();
expectTypeOf(result.bar).toEqualTypeOf<Array<number> | 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<QuairePartialResult>();
expectTypeOf(Q.getActiveQuestion().value).toEqualTypeOf<unknown>();
});
});

describe('restore state', () => {
test('should keep the result when there are no items', () => {
const Q = new Quaire({ items: [], result: { foo: 'option 1' } });
Expand Down
44 changes: 25 additions & 19 deletions src/quaire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ import {
QuaireItemOption,
QuaireNavigationItem,
QuaireOptions,
QuairePartialResult,
QuaireQuestion,
QuaireRangeItemOption,
QuaireResult,
} from './types';
import { QuaireComponentType, QuaireValidationError } from './enums';
import { getByPath, hasAnswer } from './utils';
Expand All @@ -16,12 +18,12 @@ export class Quaire<
Item extends QuaireItem = QuaireItem,
Question extends QuaireQuestion = QuaireQuestion,
NavigationItem extends QuaireNavigationItem = QuaireNavigationItem,
> implements QuaireBase<Question, NavigationItem> {
Result extends object = QuaireResult,
> implements QuaireBase<Question, NavigationItem, Result> {
protected _activeItemId: number | null = null;
protected readonly _items: Array<Item>;
protected readonly _navigationItems: Array<NavigationItem>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- answers can have any shape, consumers read them directly
protected readonly _result: Record<string, any> = {};
protected readonly _result: QuaireResult = {};
protected readonly _validationErrors: Record<number, QuaireValidationError> = {};
protected readonly _selectComponentTypes: Array<string> = [QuaireComponentType.SINGLE_SELECT];
protected readonly _rangeComponentTypes: Array<string> = [QuaireComponentType.RANGE_SLIDER];
Expand All @@ -31,7 +33,7 @@ export class Quaire<
QuaireComponentType.INPUT,
];

constructor({ items, navigationItems, result }: QuaireOptions<Item, NavigationItem>) {
constructor({ items, navigationItems, result }: QuaireOptions<Item, NavigationItem, Result>) {
this._items = items;
this._navigationItems = navigationItems || [];

Expand All @@ -40,7 +42,7 @@ export class Quaire<
}

if (result) {
this._result = result;
this._result = result as QuaireResult;
this._setActiveItemId();
}

Expand Down Expand Up @@ -136,7 +138,7 @@ export class Quaire<

if (resultPropertyValue) {
path.push(resultProperty);
path.push(resultPropertyValue);
path.push(String(resultPropertyValue));
}
});

Expand Down Expand Up @@ -274,7 +276,7 @@ export class Quaire<
}

public getResult() {
return this._result;
return this._result as QuairePartialResult<Result>;
}

public getValidationErrors() {
Expand Down Expand Up @@ -455,13 +457,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;
}
}

Expand All @@ -470,15 +473,18 @@ export class Quaire<
const parentNavigationItemIds = new Set<number>();
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[];
Expand Down
23 changes: 14 additions & 9 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
import { QuaireComponentType, QuaireValidationError } from './enums';

export type QuaireResult = Record<string, unknown>;

// answers are missing until they are given and null when they became invalid
export type QuairePartialResult<Result extends object = QuaireResult> = {
[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<string, any>;
getResult(): QuairePartialResult<Result>;
getValidationErrors(): Record<number, QuaireValidationError>;
setActiveQuestionByNavigationItemId(navigationItemId: number): void;
setActiveQuestionByQuestionId(questionId: number): void;
Expand All @@ -18,10 +25,11 @@ export type QuaireBase<
export type QuaireOptions<
Item extends QuaireItem = QuaireItem,
NavigationItem extends QuaireNavigationItem = QuaireNavigationItem,
Result extends object = QuaireResult,
> = {
items: Array<Item>;
navigationItems?: Array<NavigationItem>;
result?: Record<string, unknown>;
result?: QuairePartialResult<Result>;
};

export type QuaireItemOption = {
Expand Down Expand Up @@ -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;
Expand All @@ -81,16 +88,14 @@ 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;
dependsOnQuestions: Array<QuaireQuestion>;
selectOptions?: Array<QuaireItemOption> | 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;
};
Loading