Skip to content

Commit 68d47cd

Browse files
committed
feat: add validation
1 parent fa2a50e commit 68d47cd

21 files changed

+532
-314
lines changed

examples/nestjs/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
"test:e2e": "jest --config ./test/jest-e2e.json"
2121
},
2222
"dependencies": {
23-
"@kinetic-io/actions-express": "^0.2.5",
23+
"@kinetic-io/actions-express": "^0.3.0",
2424
"@nestjs/common": "^9.1.2",
2525
"@nestjs/core": "^9.1.2",
2626
"@nestjs/platform-express": "^9.1.2",

packages/actions-app/app/api/actions.ts

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export interface Action {
1010
}
1111

1212
export type ValidationResponse = string | true;
13+
export type Validator<T> = (value: T) => ValidationResponse;
1314

1415
export type InputForm<T extends object> = {
1516
[P in keyof T]: FormPromise<T[P]>;
@@ -28,35 +29,31 @@ export interface InputOutput {
2829
helperText?: string;
2930
placeholder?: string;
3031
type?: 'text' | 'password' | 'email';
31-
validation?: (value: string) => ValidationResponse;
32+
validation?: Validator<string>;
3233
}): FormPromise<string>;
33-
number(opts: {
34-
label: string;
35-
helperText?: string;
36-
validation?: (value: number) => ValidationResponse;
37-
}): FormPromise<number>;
34+
number(opts: { label: string; helperText?: string; validation?: Validator<number> }): FormPromise<number>;
3835
};
3936
select: {
4037
radio<T>(opts: {
4138
label: string;
4239
helperText?: string;
43-
validation?: (value: string) => ValidationResponse;
40+
validation?: Validator<T>;
4441
data: T[];
4542
getLabel: (item: T) => string;
4643
getValue: (item: T) => string;
4744
}): FormPromise<T>;
4845
dropdown<T>(opts: {
4946
label: string;
5047
helperText?: string;
51-
validation?: (value: string) => ValidationResponse;
48+
validation?: Validator<T>;
5249
data: T[];
5350
getLabel: (item: T) => string;
5451
getValue: (item: T) => string;
5552
}): FormPromise<T>;
5653
table<T>(opts: {
5754
label: string;
5855
helperText?: string;
59-
validation?: (value: string) => ValidationResponse;
56+
validation?: Validator<T>;
6057
data: T[];
6158
headers: string[];
6259
initialSelection?: string;
@@ -68,23 +65,23 @@ export interface InputOutput {
6865
checkbox<T>(opts: {
6966
label: string;
7067
helperText?: string;
71-
validation?: (value: string) => ValidationResponse;
68+
validation?: Validator<T[]>;
7269
data: T[];
7370
getLabel: (item: T) => string;
7471
getValue: (item: T) => string;
7572
}): FormPromise<T[]>;
7673
dropdown<T>(opts: {
7774
label: string;
7875
helperText?: string;
79-
validation?: (value: string) => ValidationResponse;
76+
validation?: Validator<T[]>;
8077
data: T[];
8178
getLabel: (item: T) => string;
8279
getValue: (item: T) => string;
8380
}): FormPromise<T[]>;
8481
table<T>(opts: {
8582
label: string;
8683
helperText?: string;
87-
validation?: (value: string) => ValidationResponse;
84+
validation?: Validator<T[]>;
8885
data: T[];
8986
headers: string[];
9087
initialSelection?: string[];

packages/actions-app/app/components/forms/FormView.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ function renderFormField(name: string, field: IOForm<any>) {
9191
return (
9292
<FormControl isRequired>
9393
<FormLabel>{field.label}</FormLabel>
94-
<Select backgroundColor="white" placeholder={field.placeholder} name={name}>
94+
<Select backgroundColor="white" placeholder={field.placeholder} name={name} required>
9595
{field.data.map((option) => (
9696
<option key={option.value} value={option.value}>
9797
{option.label}
@@ -126,7 +126,7 @@ function renderFormField(name: string, field: IOForm<any>) {
126126
return (
127127
<FormControl isRequired>
128128
<FormLabel>{field.label}</FormLabel>
129-
<select placeholder={field.placeholder} name={name} multiple>
129+
<select placeholder={field.placeholder} name={name} multiple required>
130130
{field.data.map((option) => (
131131
<option key={option.value} value={option.value}>
132132
{option.label}
@@ -144,7 +144,7 @@ function renderFormField(name: string, field: IOForm<any>) {
144144
<CheckboxGroup>
145145
<Stack>
146146
{field.data.map((option) => (
147-
<Checkbox name={name} key={option.value} value={option.value}>
147+
<Checkbox required name={name} key={option.value} value={option.value}>
148148
{option.label}
149149
</Checkbox>
150150
))}
@@ -180,7 +180,8 @@ function renderFormField(name: string, field: IOForm<any>) {
180180
</FormControl>
181181
);
182182
}
183-
default:
183+
default: {
184184
break;
185+
}
185186
}
186187
}

packages/actions-app/app/models/default-actions.server.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export const DEFAULT_ACTIONS: Actions = {
1414
type: 'text',
1515
validation: (value) => {
1616
if (value.length < 3) {
17-
return 'Name must be at least 3 characters';
17+
return 'Name must be at least 3 characters. Got: ' + value.length;
1818
}
1919
return true;
2020
},
@@ -24,8 +24,8 @@ export const DEFAULT_ACTIONS: Actions = {
2424
helperText: 'Enter the email of the user',
2525
type: 'email',
2626
validation: (value) => {
27-
if (value.length < 3) {
28-
return 'Email must be at least 3 characters';
27+
if (value.length < 5) {
28+
return 'Email must be at least 5 characters. Got: ' + value.length;
2929
}
3030
return true;
3131
},
@@ -102,6 +102,6 @@ export const DEFAULT_ACTIONS: Actions = {
102102
},
103103
};
104104

105-
function sleep(ms = 2000) {
105+
function sleep(ms = 10) {
106106
return new Promise((resolve) => setTimeout(resolve, ms));
107107
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export class ValidationError extends Error {
2+
constructor(message: string) {
3+
super(message);
4+
this.name = 'ValidationError';
5+
}
6+
7+
public static is(err: unknown): err is ValidationError {
8+
return err instanceof ValidationError;
9+
}
10+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { Validator } from '../../api/actions';
2+
import { IOForm } from '../../types/response';
3+
import { BreadCrumb } from './bread-crumbs.server';
4+
import {
5+
Normalizer,
6+
normalizeAsString,
7+
normalizeAsNumber,
8+
normalizeAsArray,
9+
normalizeAsSingleton,
10+
} from './normalizers.server';
11+
12+
export function createInput(payload: IOForm) {
13+
return new InputBuilder<unknown>(payload);
14+
}
15+
16+
export interface Input<T> {
17+
form: IOForm<T>;
18+
normalize: Normalizer<T>;
19+
validator: Validator<T>;
20+
format: (value: T) => BreadCrumb[] | BreadCrumb;
21+
}
22+
23+
export class InputBuilder<T> {
24+
private normalize!: Normalizer<any>;
25+
private validator: Validator<any> = () => true;
26+
private format!: (value: T) => BreadCrumb[] | BreadCrumb;
27+
28+
constructor(private form: IOForm<T>) {}
29+
30+
public normalizeAsString(): InputBuilder<string> {
31+
this.normalize = normalizeAsString as any;
32+
return this as any;
33+
}
34+
35+
public normalizeAsNumber(): InputBuilder<number> {
36+
this.normalize = normalizeAsNumber as any;
37+
return this as any;
38+
}
39+
40+
public normalizeAsArray<U>(): InputBuilder<U[]> {
41+
this.normalize = normalizeAsArray as any;
42+
return this as any;
43+
}
44+
45+
public normalizeAsSingleton<U>(): InputBuilder<U> {
46+
this.normalize = normalizeAsSingleton as any;
47+
return this as any;
48+
}
49+
50+
public thenMap<U>(mapper: (value: T) => U): InputBuilder<U> {
51+
const normalize = this.normalize;
52+
this.normalize = (value) => mapper(normalize(value));
53+
return this as any;
54+
}
55+
56+
public validate(validator: Validator<T> | undefined): InputBuilder<T> {
57+
if (validator) {
58+
this.validator = validator;
59+
}
60+
return this as any;
61+
}
62+
63+
public formatBreadcrumbs(format: (value: T) => BreadCrumb[] | BreadCrumb): InputBuilder<T> {
64+
this.format = format;
65+
return this as any;
66+
}
67+
68+
public build(): Input<T> {
69+
return {
70+
form: this.form,
71+
normalize: this.normalize,
72+
validator: this.validator,
73+
format: this.format,
74+
};
75+
}
76+
}

packages/actions-app/app/models/workflows/bread-crumbs.server.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
export interface BreadCrumb {
22
key: string;
3-
value: string;
3+
value: string | number;
44
}
55

66
export class BreadCrumbs {
@@ -10,6 +10,14 @@ export class BreadCrumbs {
1010
this.breadcrumbs.push({ key, value });
1111
}
1212

13+
public addAll(breadcrumbs: BreadCrumb[] | BreadCrumb): void {
14+
if (Array.isArray(breadcrumbs)) {
15+
this.breadcrumbs.push(...breadcrumbs);
16+
} else {
17+
this.breadcrumbs.push(breadcrumbs);
18+
}
19+
}
20+
1321
public addList(key: string, value: string[]): void {
1422
this.breadcrumbs.push({ key, value: value.join(', ') });
1523
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { ActionViewResponse } from '../../types/response';
2+
import { defer, DeferredPromise } from '../defer';
3+
import { ClientFormValue } from './types';
4+
5+
/**
6+
* Lives on the server-side.
7+
*
8+
* Wrapper around client and server communication.
9+
* Very simple communication with 2 deferred promises, alternating who has the ball-in-their-court.
10+
*/
11+
export class ClientBridge {
12+
private clientView: DeferredPromise<ActionViewResponse> = defer();
13+
private clientResponse!: DeferredPromise<ClientFormValue>;
14+
15+
/**
16+
* Ask the client a question, send a view.
17+
*/
18+
public askClientQuestion(view: ActionViewResponse): void {
19+
// resolve the promise
20+
this.clientView.resolve(view);
21+
this.clientResponse = defer();
22+
}
23+
24+
/**
25+
* Consume response from client
26+
*/
27+
public consumeResponseFromClient(value: ClientFormValue): void {
28+
// resolve the promise
29+
this.clientResponse.resolve(value);
30+
this.clientView = defer();
31+
}
32+
33+
/**
34+
* Promise that resolves when the workflow wants as a question.
35+
*/
36+
public waitForWorkflowToAskAQuestion(): Promise<ActionViewResponse> {
37+
return this.clientView.promise;
38+
}
39+
40+
/**
41+
* Promise that resolves when the client responds.
42+
* If the client has already responded, the promise will resolve immediately.
43+
*/
44+
public waitForResponseFromClient(): Promise<ClientFormValue> {
45+
return this.clientResponse.promise;
46+
}
47+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { ValidationError } from '../errors';
2+
import { ClientFormValue } from './types';
3+
4+
export type Normalizer<T> = (value: ClientFormValue) => T;
5+
6+
export const normalizeAsString: Normalizer<string> = (value) => {
7+
if (typeof value === 'string') {
8+
return value;
9+
}
10+
11+
return normalizeAsSingleton(value);
12+
};
13+
14+
export const normalizeAsNumber: Normalizer<number> = (value) => {
15+
const str = normalizeAsString(value);
16+
const num = Number(str);
17+
if (Number.isNaN(num)) {
18+
throw new ValidationError('Expected a number, but got ' + str);
19+
}
20+
return num;
21+
};
22+
23+
export const normalizeAsArray: Normalizer<any> = <T>(value: T | T[] | undefined | null): T[] => {
24+
if (value === undefined || value === null) {
25+
return [];
26+
}
27+
if (Array.isArray(value)) {
28+
return value;
29+
}
30+
return [value];
31+
};
32+
33+
export const normalizeAsSingleton: Normalizer<any> = <T>(value: T | T[] | undefined | null): T => {
34+
if (value === undefined || value === null) {
35+
throw new ValidationError('Missing required field.');
36+
}
37+
38+
if (Array.isArray(value)) {
39+
if (value.length > 1) {
40+
console.log('Expected single value, but got ' + value.length);
41+
}
42+
if (value.length === 0) {
43+
throw new ValidationError('Missing required field.');
44+
}
45+
return value[0];
46+
}
47+
48+
return value;
49+
};
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { Action } from '../../types';
2+
import { ActionResponse, ActionRequest } from '../../types/response';
3+
import { WorkflowId } from '../ids';
4+
import { Workflow } from './workflow.server';
5+
6+
/**
7+
* Possible form values returned by the `form` method.
8+
*/
9+
export type ClientFormValue = string | string[] | null | undefined;
10+
11+
export interface WorkflowManager {
12+
/**
13+
* Get a workflow by its ID.
14+
* @param workflowId The ID of the workflow to get.
15+
* @returns The workflow, or `undefined` if it doesn't exist.
16+
*/
17+
getWorkflow(workflowId: WorkflowId): Workflow | undefined;
18+
/**
19+
* Start a workflow.
20+
* @param action The action to start the workflow with.
21+
* @returns The response to the action.
22+
*/
23+
startWorkflow(action: Action): Promise<ActionResponse>;
24+
/**
25+
* Pick up a workflow.
26+
* @param workflowId The ID of the workflow to pick up.
27+
* @returns The response to the action.
28+
* @throws If the workflow doesn't exist.
29+
*/
30+
pickUpWorkflow(workflowId: WorkflowId): Promise<ActionResponse>;
31+
/**
32+
* Continue a workflow.
33+
* @param workflowId The ID of the workflow to continue.
34+
* @param request The request to continue the workflow with.
35+
*/
36+
continueWorkflow(workflowId: WorkflowId, request: ActionRequest): Promise<ActionResponse>;
37+
}

0 commit comments

Comments
 (0)