Skip to content

Commit eafe221

Browse files
committed
Implement validation
1 parent ea903c9 commit eafe221

15 files changed

+241
-28
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ The most important features of this component are:
6161
- ✅ DSV format detection
6262
- ✅ Fully compositable
6363
- ✅ Automatic testing with >90% coverage
64-
- Input validation
64+
- Input validation
6565
-[Material UI](https://material-ui.com/) integration
6666
-[ant.design](https://ant.design/) integration
6767

src/DSVImport.stories.tsx

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export const BasicUsage = () => {
2323
};
2424
BasicUsage.story = { name: 'Basic usage' };
2525

26-
export const UsingCallbacks = () => {
26+
export const UsingOnChangeCallback = () => {
2727
const columns: ColumnsType<BasicType> = [
2828
{ key: 'forename', label: 'Forename' },
2929
{ key: 'surname', label: 'Surname' },
@@ -48,20 +48,22 @@ export const UsingCallbacks = () => {
4848
</>
4949
);
5050
};
51-
UsingCallbacks.story = { name: 'Using callbacks a state' };
51+
UsingOnChangeCallback.story = { name: 'Using on change callback' };
5252

53-
export const UsingValidation = () => {
53+
export const UsingOnValidationCallback = () => {
5454
const columns: ColumnsType<BasicType> = [
5555
{ key: 'forename', label: 'Forename' },
5656
{ key: 'surname', label: 'Surname' },
57-
{ key: 'email', label: 'Email', rules: [{ constraint: { unique: true }, message: 'Must be unique' }] }
57+
{ key: 'email', label: 'Email', rules: [{ constraint: { unique: true }, message: 'Duplicates are not allowed' }] }
5858
];
5959
const onChangeAction = action('Parsed value has changed');
60+
const onValidationAction = action('Validation value has changed');
6061

6162
return (
62-
<DSVImport<BasicType> columns={columns} onChange={onChangeAction}>
63+
<DSVImport<BasicType> columns={columns} onChange={onChangeAction} onValidation={onValidationAction}>
6364
<DSVImport.TextareaInput />
6465
<DSVImport.TablePreview />
6566
</DSVImport>
6667
);
6768
};
69+
UsingOnValidationCallback.story = { name: 'Using on validation callback' };

src/DSVImport.test.tsx

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,36 +2,65 @@ import { ColumnsType } from './models/column';
22
import { DSVImport } from '.';
33
import React from 'react';
44
import { render, fireEvent } from '@testing-library/react';
5+
import { useDSVImport } from './features/context';
6+
import { ValidationError } from './models/validation';
57

68
describe('DSVImport', () => {
79
type TestType = { forename: string; surname: string; email: string };
810

911
const columns: ColumnsType<TestType> = [
1012
{ key: 'forename', label: 'Forename' },
1113
{ key: 'surname', label: 'Surname' },
12-
{ key: 'email', label: 'Email' }
14+
{ key: 'email', label: 'Email', rules: [{ constraint: { unique: true }, message: 'Contains duplicates' }] }
1315
];
1416

15-
const onChangeMock = jest.fn();
17+
interface Props<T> {
18+
onChange?: (value: T[]) => void;
19+
onValidation?: (errors: ValidationError<T>[]) => void;
20+
columns: ColumnsType<T>;
21+
}
1622

17-
const renderComponent = () => {
23+
const MinimalTextareaInput: React.FC = () => {
24+
const [, dispatch] = useDSVImport();
25+
26+
const handleChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
27+
dispatch({ type: 'setRaw', raw: event.target.value });
28+
};
29+
30+
return <textarea onChange={handleChange}></textarea>;
31+
};
32+
33+
const renderComponent = (props: Props<TestType>) => {
1834
return render(
19-
<DSVImport<TestType> columns={columns} onChange={onChangeMock}>
20-
<DSVImport.TextareaInput />
21-
<DSVImport.TablePreview />
35+
<DSVImport<TestType> {...props}>
36+
<MinimalTextareaInput />
2237
</DSVImport>
2338
);
2439
};
2540

26-
it('should invoke the onChange callback on context change', () => {
27-
const { container } = renderComponent();
41+
it('should invoke the onChange callback', () => {
42+
const onChangeMock = jest.fn();
43+
const { container } = renderComponent({ columns, onChange: onChangeMock });
2844
const textarea = container.querySelector('textarea');
2945

3046
if (textarea) {
3147
fireEvent.change(textarea, { target: { value: 'Max' } });
3248
}
3349

34-
expect(onChangeMock).toBeCalledTimes(1);
3550
expect(onChangeMock).toBeCalledWith([{ email: undefined, forename: 'Max', surname: undefined }]);
3651
});
52+
53+
it('should invoke the onValidation callback', () => {
54+
const onValidationMock = jest.fn();
55+
const { container } = renderComponent({ columns, onValidation: onValidationMock });
56+
const textarea = container.querySelector('textarea');
57+
58+
if (textarea) {
59+
fireEvent.change(textarea, {
60+
target: { value: 'Max,Muster,[email protected]\nMoritz,Muster,[email protected]' }
61+
});
62+
}
63+
64+
expect(onValidationMock).toBeCalledWith([{ column: 'email', message: 'Contains duplicates' }]);
65+
});
3766
});

src/DSVImport.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@ import { createParserMiddleware } from './middlewares/parserMiddleware';
55
import { State } from './models/state';
66
import { applyMiddlewares } from './middlewares/middleware';
77
import { createValidatorMiddleware } from './middlewares/validatorMiddleware';
8+
import { ValidationError } from './models/validation';
89

910
interface EventListenerProps<T> {
1011
onChange?: (value: T[]) => void;
12+
onValidation?: (errors: ValidationError<T>[]) => void;
1113
}
1214

1315
const EventListener = <T extends { [key: string]: string }>(props: EventListenerProps<T>) => {
@@ -19,11 +21,18 @@ const EventListener = <T extends { [key: string]: string }>(props: EventListener
1921
}
2022
}, [context.parsed]);
2123

24+
useEffect(() => {
25+
if (context.validation && props.onValidation) {
26+
props.onValidation(context.validation);
27+
}
28+
}, [context.validation]);
29+
2230
return null;
2331
};
2432

2533
export interface Props<T> {
2634
onChange?: (value: T[]) => void;
35+
onValidation?: (errors: ValidationError<T>[]) => void;
2736
columns: ColumnsType<T>;
2837
}
2938

@@ -35,7 +44,7 @@ export const DSVImport = <T extends { [key: string]: string }>(props: PropsWithC
3544

3645
return (
3746
<DSVImportContext.Provider value={[state, enhancedDispatch]}>
38-
<EventListener<T> onChange={props.onChange} />
47+
<EventListener<T> onChange={props.onChange} onValidation={props.onValidation} />
3948
{props.children}
4049
</DSVImportContext.Provider>
4150
);

src/features/context.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import { State, emptyState } from '../models/state';
22
import { createContext, Dispatch, useContext } from 'react';
3-
4-
export type Actions<T> = { type: 'setRaw'; raw: string } | { type: 'setParsed'; parsed: T[] };
3+
import { Actions } from '../models/actions';
54

65
export const reducer = <T>(state: State<T>, action: Actions<T>) => {
76
switch (action.type) {
87
case 'setRaw':
98
return { ...state, raw: action.raw };
109
case 'setParsed':
1110
return { ...state, parsed: action.parsed };
11+
case 'setValidation':
12+
return { ...state, validation: action.errors };
1213
default:
1314
return state;
1415
}

src/middlewares/middleware.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { ColumnsType } from '../models/column';
2+
import { State } from '../models/state';
3+
import { applyMiddlewares } from './middleware';
4+
5+
describe('middleware', () => {
6+
type TestType = { forename: string; surname: string; email: string };
7+
const columns: ColumnsType<TestType> = [
8+
{ key: 'forename', label: 'Forename' },
9+
{ key: 'surname', label: 'Surname' },
10+
{ key: 'email', label: 'Email' }
11+
];
12+
const defaultState: State<TestType> = { columns };
13+
14+
it('should dispatch to all middlewares', () => {
15+
const dispatchMock = jest.fn();
16+
const middlewareAMock = jest.fn();
17+
const middlewareBMock = jest.fn();
18+
const middlewareCMock = jest.fn();
19+
const enhancedDispatch = applyMiddlewares(
20+
defaultState,
21+
dispatchMock,
22+
middlewareAMock,
23+
middlewareBMock,
24+
middlewareCMock
25+
);
26+
enhancedDispatch({});
27+
28+
expect(middlewareAMock).toBeCalledTimes(1);
29+
expect(middlewareBMock).toBeCalledTimes(1);
30+
expect(middlewareCMock).toBeCalledTimes(1);
31+
});
32+
33+
it('should forward a dispatch to other middlewares', () => {
34+
const dispatchMock = jest.fn();
35+
const middlewareAMock = jest.fn((_state, dispatch) => {
36+
dispatch({ type: 'sequentCall' });
37+
});
38+
const middlewareBMock = jest.fn();
39+
const middlewareCMock = jest.fn();
40+
const enhancedDispatch = applyMiddlewares(
41+
defaultState,
42+
dispatchMock,
43+
middlewareAMock,
44+
middlewareBMock,
45+
middlewareCMock
46+
);
47+
enhancedDispatch({ type: 'initialCall' });
48+
49+
expect(middlewareAMock).toBeCalledTimes(1);
50+
expect(middlewareBMock).toBeCalledTimes(2);
51+
expect(middlewareBMock).toHaveBeenNthCalledWith(1, defaultState, expect.any(Function), { type: 'sequentCall' });
52+
expect(middlewareBMock).toHaveBeenNthCalledWith(2, defaultState, expect.any(Function), { type: 'initialCall' });
53+
expect(middlewareCMock).toBeCalledTimes(2);
54+
});
55+
});

src/middlewares/middleware.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,16 @@
11
import { Dispatch } from 'react';
2-
import { Actions } from '../features/context';
32
import { State } from '../models/state';
43

5-
type Middleware = <T>(state: State<T>, dispatch: Dispatch<Actions<T>>, action: Actions<T>) => void;
4+
type Middleware<T, A> = (state: State<T>, dispatch: Dispatch<A>, action: A) => void;
65

7-
export const applyMiddlewares = <T>(state: State<T>, dispatch: Dispatch<Actions<T>>, ...middlewares: Middleware[]) => (
8-
action: Actions<T>
6+
export const applyMiddlewares = <T, A>(state: State<T>, dispatch: Dispatch<A>, ...middlewares: Middleware<T, A>[]) => (
7+
action: A
98
) => {
109
const without = (i: number) => {
1110
return middlewares.filter((_, filterIndex) => i !== filterIndex);
1211
};
1312

14-
const next = (nextMiddlewares: Middleware[]) => (value: Actions<T>) => {
13+
const next = (nextMiddlewares: Middleware<T, A>[]) => (value: A) => {
1514
dispatch(value);
1615
nextMiddlewares.forEach((m, i) => {
1716
m(state, next(without(i)), value);

src/middlewares/parserMiddleware.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,13 @@ describe('parserMiddleware', () => {
2626
});
2727
});
2828

29+
it('should only be triggered on new raw data', () => {
30+
const dispatchMock = jest.fn();
31+
middleware(defaultState, dispatchMock, { type: 'setValidation', errors: [] });
32+
33+
expect(dispatchMock).toBeCalledTimes(0);
34+
});
35+
2936
it('should set parsed data to an empty array if there is no raw data', () => {
3037
const dispatchMock = jest.fn();
3138
const stateWithRawData = {

src/middlewares/parserMiddleware.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { State } from '../models/state';
2-
import { Actions } from '../features/context';
32
import { Delimiter } from '../models/delimiter';
43
import { ColumnsType } from '../models/column';
54
import { Dispatch } from 'react';
5+
import { Actions } from '../models/actions';
66

7-
const detectDelimiterFromValue = (value: string, defaultDelimiter = Delimiter.COMMA) => {
7+
const detectDelimiterFromValue = (value: string, defaultDelimiter: Delimiter) => {
88
let currentDelimiter = defaultDelimiter;
99
let maximumScore = 0;
1010
Object.values(Delimiter).forEach((s) => {
@@ -32,7 +32,7 @@ const parseData = <T>(value: string, columns: ColumnsType<T>, delimiter: Delimit
3232
export const createParserMiddleware = <T>() => {
3333
return (state: State<T>, next: Dispatch<Actions<T>>, action: Actions<T>) => {
3434
if (action.type === 'setRaw') {
35-
const delimiter = detectDelimiterFromValue(action.raw);
35+
const delimiter = detectDelimiterFromValue(action.raw, Delimiter.COMMA);
3636

3737
let parsed: T[] = [];
3838
if (action.raw !== '') {
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { createValidatorMiddleware } from './validatorMiddleware';
2+
import { State } from '../models/state';
3+
import { ColumnsType } from '../models/column';
4+
5+
describe('validatorMiddleware', () => {
6+
type TestType = { forename: string; surname: string; email: string };
7+
const defaultColumns: ColumnsType<TestType> = [
8+
{ key: 'forename', label: 'Forename' },
9+
{ key: 'surname', label: 'Surname' },
10+
{ key: 'email', label: 'Email' }
11+
];
12+
const defaultState: State<TestType> = { columns: defaultColumns };
13+
const middleware = createValidatorMiddleware<TestType>();
14+
const parsed: TestType[] = [
15+
{ forename: 'Hans', surname: 'Muster', email: '[email protected]' },
16+
{ forename: 'Heidi', surname: 'Muster', email: '[email protected]' }
17+
];
18+
19+
it('should return an empty array if there are no errors', () => {
20+
const dispatchMock = jest.fn();
21+
22+
middleware(defaultState, dispatchMock, { type: 'setParsed', parsed });
23+
24+
expect(dispatchMock).toBeCalledWith({ type: 'setValidation', errors: [] });
25+
});
26+
27+
it('should validate unique constraints', () => {
28+
const dispatchMock = jest.fn();
29+
const columns: ColumnsType<TestType> = [...defaultColumns];
30+
columns[2] = { ...defaultColumns[2], rules: [{ constraint: { unique: true }, message: 'Contains duplicates' }] };
31+
const state: State<TestType> = { columns };
32+
33+
middleware(state, dispatchMock, { type: 'setParsed', parsed });
34+
35+
expect(dispatchMock).toBeCalledWith({
36+
type: 'setValidation',
37+
errors: [{ column: 'email', message: 'Contains duplicates' }]
38+
});
39+
});
40+
41+
it('should validate with callback constraints', () => {
42+
const dispatchMock = jest.fn();
43+
const columns: ColumnsType<TestType> = [...defaultColumns];
44+
columns[0] = {
45+
...defaultColumns[0],
46+
rules: [{ constraint: { callback: (value) => value === 'Hans' }, message: "No 'Hans' allowed" }]
47+
};
48+
const state: State<TestType> = { columns };
49+
middleware(state, dispatchMock, { type: 'setParsed', parsed });
50+
51+
expect(dispatchMock).toBeCalledWith({
52+
type: 'setValidation',
53+
errors: [{ column: 'forename', row: 1, message: "No 'Hans' allowed" }]
54+
});
55+
});
56+
});

0 commit comments

Comments
 (0)