mayoiga 0.1.1 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintrc.js +111 -0
- package/.github/workflows/checker.yml +42 -0
- package/.gitignore +217 -0
- package/.prettierrc +5 -0
- package/README.md +55 -35
- package/component.d.ts +1 -0
- package/component.js +1 -0
- package/dist/component.test.js +62 -3
- package/dist/index.d.ts +19 -1
- package/dist/index.js +11 -7
- package/dist/index.test.js +5 -2
- package/docs/.nojekyll +1 -0
- package/docs/assets/highlight.css +92 -0
- package/docs/assets/icons.css +1043 -0
- package/docs/assets/icons.png +0 -0
- package/docs/assets/icons@2x.png +0 -0
- package/docs/assets/main.js +52 -0
- package/docs/assets/search.js +1 -0
- package/docs/assets/style.css +1414 -0
- package/docs/assets/widgets.png +0 -0
- package/docs/assets/widgets@2x.png +0 -0
- package/docs/index.html +41 -0
- package/docs/modules.html +4 -0
- package/example/src/demo-app.tsx +123 -0
- package/example/src/index.tsx +15 -0
- package/package.json +10 -5
- package/setup/vite-global-setup.js +1 -0
- package/setup/vite-set-global.js +0 -0
- package/src/component.test.tsx +204 -0
- package/src/component.tsx +23 -0
- package/src/index.test.tsx +122 -0
- package/src/index.ts +332 -0
- package/src/utils.ts +8 -0
- package/tsconfig.json +38 -0
- package/vite.config.js +10 -0
- package/yarn.lock +2202 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import {render, screen, waitFor} from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import React from 'react';
|
|
4
|
+
|
|
5
|
+
import {test, expect} from 'vitest';
|
|
6
|
+
|
|
7
|
+
import * as zod from 'zod';
|
|
8
|
+
|
|
9
|
+
import {Field, Slicer} from './component';
|
|
10
|
+
|
|
11
|
+
import {createFormHook} from './index';
|
|
12
|
+
|
|
13
|
+
import type {Controller} from './index';
|
|
14
|
+
|
|
15
|
+
const testSchema = zod.object({
|
|
16
|
+
name: zod.string(),
|
|
17
|
+
description: zod.string(),
|
|
18
|
+
age: zod
|
|
19
|
+
.string()
|
|
20
|
+
.transform((val) => Number(val))
|
|
21
|
+
.refine((val) => (Number.isNaN(val) ? 0 : val)),
|
|
22
|
+
marked: zod.boolean(),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// every fields are optional for test
|
|
26
|
+
type FormStateBeforeValidation = {
|
|
27
|
+
name: string;
|
|
28
|
+
description: string;
|
|
29
|
+
age: string;
|
|
30
|
+
marked: boolean;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const createTestHook = () => createFormHook({name: '', description: '', age: '', marked: false} as FormStateBeforeValidation, testSchema);
|
|
34
|
+
|
|
35
|
+
// afterEach(cleanup);
|
|
36
|
+
|
|
37
|
+
test('Field component works fine in basic usage.', async () => {
|
|
38
|
+
const TestFormHook = createTestHook();
|
|
39
|
+
|
|
40
|
+
const Top = () => {
|
|
41
|
+
return (
|
|
42
|
+
<div>
|
|
43
|
+
<Field controller={TestFormHook.controller} name="age">
|
|
44
|
+
{(tool) => <input data-testid="age-input" {...tool} />}
|
|
45
|
+
</Field>
|
|
46
|
+
</div>
|
|
47
|
+
);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
render(<Top />);
|
|
51
|
+
|
|
52
|
+
const INPUT_AGE = '42';
|
|
53
|
+
|
|
54
|
+
// calling `userEvent` in here will cause race condition...
|
|
55
|
+
await userEvent.type(screen.getByTestId('age-input'), INPUT_AGE);
|
|
56
|
+
|
|
57
|
+
await waitFor(() => {
|
|
58
|
+
const ageInput: HTMLInputElement = screen.getByTestId('age-input');
|
|
59
|
+
expect(ageInput.value).toBe(INPUT_AGE);
|
|
60
|
+
TestFormHook.api.actions.peek((s) => {
|
|
61
|
+
expect(s.value.age).toBe(INPUT_AGE);
|
|
62
|
+
expect(s.errors.age).toBeNull();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('Slice component works fine in basic usage.', async () => {
|
|
68
|
+
const TestFormHook = createTestHook();
|
|
69
|
+
|
|
70
|
+
const Top = () => {
|
|
71
|
+
return (
|
|
72
|
+
<div>
|
|
73
|
+
<Slicer controller={TestFormHook.controller} selector={(s) => [s.value.age] as const}>
|
|
74
|
+
{(tool, age: string) => (
|
|
75
|
+
<input data-testid="age-input" name="age" value={age} onChange={(e) => tool.handleChange('age' as const, e.target.value)} />
|
|
76
|
+
)}
|
|
77
|
+
</Slicer>
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
render(<Top />);
|
|
83
|
+
|
|
84
|
+
const INPUT_AGE = '42';
|
|
85
|
+
|
|
86
|
+
// calling `userEvent` in here will cause race condition...
|
|
87
|
+
await userEvent.type(screen.getByTestId('age-input'), INPUT_AGE);
|
|
88
|
+
|
|
89
|
+
await waitFor(() => {
|
|
90
|
+
const ageInput: HTMLInputElement = screen.getByTestId('age-input');
|
|
91
|
+
expect(ageInput.value).toBe(INPUT_AGE);
|
|
92
|
+
TestFormHook.api.actions.peek((s) => {
|
|
93
|
+
expect(s.value.age).toBe(INPUT_AGE);
|
|
94
|
+
expect(s.errors.age).toBeNull();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('A user able to create a reusable component', async () => {
|
|
100
|
+
const TestFormHook = createTestHook();
|
|
101
|
+
|
|
102
|
+
function NarrowTypedExpected(props: {controller: Controller<{age: string}>}) {
|
|
103
|
+
return (
|
|
104
|
+
<Field controller={props.controller} name="age">
|
|
105
|
+
{(tool) => <input data-testid="age-input" {...tool} />}
|
|
106
|
+
</Field>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function ErrorDisplayer(props: {controller: Controller<{age: string}>}) {
|
|
111
|
+
const {controller} = props;
|
|
112
|
+
return (
|
|
113
|
+
<Slicer controller={controller} selector={(s) => [s.value.age] as const}>
|
|
114
|
+
{(tool, age: string) => (
|
|
115
|
+
<button
|
|
116
|
+
onClick={(e) => {
|
|
117
|
+
controller.actions.pushFormErrors((_state) => ({age: age === '42' ? 'yes!!!' : 'noooo'}));
|
|
118
|
+
}}
|
|
119
|
+
/>
|
|
120
|
+
)}
|
|
121
|
+
</Slicer>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
render(
|
|
126
|
+
<div>
|
|
127
|
+
<NarrowTypedExpected controller={TestFormHook.controller} />
|
|
128
|
+
<ErrorDisplayer controller={TestFormHook.controller} />
|
|
129
|
+
</div>,
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const INPUT_AGE = '42';
|
|
133
|
+
|
|
134
|
+
// calling `userEvent` in here will cause race condition...
|
|
135
|
+
await userEvent.type(screen.getByTestId('age-input'), INPUT_AGE);
|
|
136
|
+
await userEvent.click(screen.getByRole('button'));
|
|
137
|
+
|
|
138
|
+
await waitFor(() => {
|
|
139
|
+
const ageInput: HTMLInputElement = screen.getByTestId('age-input');
|
|
140
|
+
expect(ageInput.value).toBe(INPUT_AGE);
|
|
141
|
+
TestFormHook.api.actions.peek((s) => {
|
|
142
|
+
expect(s.value.age).toBe(INPUT_AGE);
|
|
143
|
+
expect(s.errors.age).toBe('yes!!!');
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('`deps` field could destruct memoization', async () => {
|
|
149
|
+
const TestFormHook = createTestHook();
|
|
150
|
+
// intentional sample to replicate the situation
|
|
151
|
+
const OtherFormHook = createFormHook({minAge: '30'}, zod.object({minAge: zod.string()}));
|
|
152
|
+
|
|
153
|
+
const DATA_ID_RE_RENDER_BY_DEPS = 'refresh-by-deps';
|
|
154
|
+
const DATA_ID_NOT_RERENDER_NO_DEPS = 'no-refersh-no-deps';
|
|
155
|
+
|
|
156
|
+
function App() {
|
|
157
|
+
const minAge = OtherFormHook.useSelector((s) => Number(s.value.minAge));
|
|
158
|
+
|
|
159
|
+
return (
|
|
160
|
+
<div>
|
|
161
|
+
<Field controller={OtherFormHook.controller} name="minAge">
|
|
162
|
+
{(tool) => <input data-testid="minAgeInput" type="number" {...tool} />}
|
|
163
|
+
</Field>
|
|
164
|
+
<Field controller={TestFormHook.controller} name="age">
|
|
165
|
+
{(tool) => <input data-testid="ageInput" type="number" {...tool} />}
|
|
166
|
+
</Field>
|
|
167
|
+
<Slicer controller={TestFormHook.controller} selector={(s) => [Number(s.value.age)]} deps={[minAge]}>
|
|
168
|
+
{(_tool, age: number) => (
|
|
169
|
+
<div data-testid={DATA_ID_RE_RENDER_BY_DEPS}>{age >= minAge ? 'You can buy this content' : 'go home'}</div>
|
|
170
|
+
)}
|
|
171
|
+
</Slicer>
|
|
172
|
+
<Slicer controller={TestFormHook.controller} selector={(s) => [Number(s.value.age)]}>
|
|
173
|
+
{(_tool, age: number) => (
|
|
174
|
+
<div data-testid={DATA_ID_NOT_RERENDER_NO_DEPS}>{age >= minAge ? 'You can buy this content' : 'go home'}</div>
|
|
175
|
+
)}
|
|
176
|
+
</Slicer>
|
|
177
|
+
</div>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
render(<App />);
|
|
182
|
+
|
|
183
|
+
await userEvent.type(screen.getByTestId('ageInput'), '16');
|
|
184
|
+
|
|
185
|
+
await waitFor(() => {
|
|
186
|
+
expect(screen.getByTestId(DATA_ID_RE_RENDER_BY_DEPS).textContent).toBe('go home');
|
|
187
|
+
// expect(await screen.findByText('You can buy this content')).toBeDefined();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
await userEvent.clear(screen.getByTestId('minAgeInput'));
|
|
191
|
+
await userEvent.type(screen.getByTestId('minAgeInput'), '10');
|
|
192
|
+
|
|
193
|
+
await waitFor(() => {
|
|
194
|
+
expect(screen.getByTestId(DATA_ID_RE_RENDER_BY_DEPS).textContent, 'Slicer should re-execute renderProps if `deps` are changed').toBe(
|
|
195
|
+
'You can buy this content',
|
|
196
|
+
);
|
|
197
|
+
});
|
|
198
|
+
await waitFor(() => {
|
|
199
|
+
expect(
|
|
200
|
+
screen.getByTestId(DATA_ID_NOT_RERENDER_NO_DEPS).textContent,
|
|
201
|
+
'Same logic but would not refresh because external dependencies are not applied',
|
|
202
|
+
).toBe('go home');
|
|
203
|
+
});
|
|
204
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
import type {SliceProps, FieldProps, StateRestriction, Controller} from './index';
|
|
4
|
+
|
|
5
|
+
type ControlledComponentProps<State, Props> = Props & {
|
|
6
|
+
controller: Controller<State>;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function Slicer<State extends StateRestriction, R extends ReadonlyArray<unknown>>(
|
|
10
|
+
props: ControlledComponentProps<State, SliceProps<State, R>>,
|
|
11
|
+
) {
|
|
12
|
+
const {controller, ...restProps} = props;
|
|
13
|
+
const {Slicer: Delegate} = controller.components;
|
|
14
|
+
return <Delegate {...restProps} />;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function Field<State extends StateRestriction, Name extends keyof State>(
|
|
18
|
+
props: ControlledComponentProps<State, FieldProps<State, Name>>,
|
|
19
|
+
) {
|
|
20
|
+
const {controller, ...restProps} = props;
|
|
21
|
+
const {Field: Delegate} = controller.components;
|
|
22
|
+
return <Delegate {...restProps} />;
|
|
23
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import {render, screen, waitFor} from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import React from 'react';
|
|
4
|
+
|
|
5
|
+
import {test, expect} from 'vitest';
|
|
6
|
+
|
|
7
|
+
import * as zod from 'zod';
|
|
8
|
+
|
|
9
|
+
import {createFormHook, useFormSlice} from './index';
|
|
10
|
+
|
|
11
|
+
import type {Controller} from './index';
|
|
12
|
+
|
|
13
|
+
const testSchema = zod.object({
|
|
14
|
+
name: zod.string(),
|
|
15
|
+
description: zod.string(),
|
|
16
|
+
age: zod
|
|
17
|
+
.string()
|
|
18
|
+
.transform((val) => Number(val))
|
|
19
|
+
.refine((val) => (Number.isNaN(val) ? 0 : val)),
|
|
20
|
+
marked: zod.boolean(),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// every fields are optional for test
|
|
24
|
+
type FormStateBeforeValidation = {
|
|
25
|
+
name: string;
|
|
26
|
+
description: string;
|
|
27
|
+
age: string;
|
|
28
|
+
marked: boolean;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const createTestHook = () => createFormHook({name: '', description: '', age: '', marked: false} as FormStateBeforeValidation, testSchema);
|
|
32
|
+
|
|
33
|
+
const AgeInputComponent = (props: {controller: Controller<FormStateBeforeValidation>}) => {
|
|
34
|
+
const {controller} = props;
|
|
35
|
+
const [[age, errorMessage], {handleChange}] = useFormSlice(controller, (s) => [s.value.age, s.errors.age] as const);
|
|
36
|
+
return (
|
|
37
|
+
<div>
|
|
38
|
+
<input
|
|
39
|
+
data-testid="age-input"
|
|
40
|
+
value={age}
|
|
41
|
+
name="age"
|
|
42
|
+
onChange={(e) => {
|
|
43
|
+
handleChange('age' as const, e.currentTarget.value);
|
|
44
|
+
}}
|
|
45
|
+
/>
|
|
46
|
+
<span data-testid="age-error">{errorMessage}</span>
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// afterEach(cleanup);
|
|
52
|
+
|
|
53
|
+
test('useSelector can select the fields', async () => {
|
|
54
|
+
const TestFormHook = createTestHook();
|
|
55
|
+
|
|
56
|
+
const Top = () => {
|
|
57
|
+
return (
|
|
58
|
+
<div>
|
|
59
|
+
<AgeInputComponent controller={TestFormHook.controller} />
|
|
60
|
+
</div>
|
|
61
|
+
);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
render(<Top />);
|
|
65
|
+
|
|
66
|
+
const INPUT_AGE = '4';
|
|
67
|
+
|
|
68
|
+
// fireEvent.change(screen.getByTestId('age-input') , {target: {value: INPUT_AGE}})
|
|
69
|
+
// calling `userEvent` in here will cause race condition...
|
|
70
|
+
await userEvent.type(screen.getByTestId('age-input'), INPUT_AGE);
|
|
71
|
+
// TestFormHook.actions.handleBulkChange(prev => ({...prev, name: 'test', description: 'desc'}));
|
|
72
|
+
|
|
73
|
+
await waitFor(() => {
|
|
74
|
+
const ageInput: HTMLInputElement = screen.getByTestId('age-input');
|
|
75
|
+
expect(ageInput.value).toBe(INPUT_AGE);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('zod parse value before submit', async () => {
|
|
80
|
+
const TestFormHook = createTestHook();
|
|
81
|
+
|
|
82
|
+
const INPUT_AGE = '9';
|
|
83
|
+
const PARSED_RESULT = Number(INPUT_AGE);
|
|
84
|
+
|
|
85
|
+
const handleSubmitTester = TestFormHook.handleSubmit((e) => (result) => {
|
|
86
|
+
if (result.success) {
|
|
87
|
+
expect(result.data.age).toBe(PARSED_RESULT);
|
|
88
|
+
} else {
|
|
89
|
+
expect('should not called').toBe(result.error);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const Top = () => {
|
|
94
|
+
return (
|
|
95
|
+
<div>
|
|
96
|
+
<AgeInputComponent controller={TestFormHook} />
|
|
97
|
+
<button data-testid="submit-button" onClick={handleSubmitTester} />
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
render(<Top />);
|
|
103
|
+
|
|
104
|
+
const ageInput: HTMLInputElement = screen.getByTestId('age-input');
|
|
105
|
+
await userEvent.type(ageInput, INPUT_AGE);
|
|
106
|
+
|
|
107
|
+
await waitFor(() => {
|
|
108
|
+
const ageInput: HTMLInputElement = screen.getByTestId('age-input');
|
|
109
|
+
expect(ageInput.value).toBe(INPUT_AGE);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const submitButton = screen.getByTestId('submit-button');
|
|
113
|
+
await userEvent.click(submitButton);
|
|
114
|
+
|
|
115
|
+
await waitFor(() => {
|
|
116
|
+
console.log('waiting');
|
|
117
|
+
TestFormHook.api.actions.peek((s) => {
|
|
118
|
+
expect(s.value.age).toBe(INPUT_AGE);
|
|
119
|
+
expect(s.errors.age).toBeNull();
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import {createStore} from 'nozuchi';
|
|
2
|
+
import {createElement, Fragment, useEffect, useMemo} from 'react';
|
|
3
|
+
|
|
4
|
+
import {isChangeEvent} from './utils';
|
|
5
|
+
|
|
6
|
+
import type {Subscriber} from 'nozuchi';
|
|
7
|
+
import type {BaseSyntheticEvent, ChangeEvent, ReactNode, ReactElement} from 'react';
|
|
8
|
+
|
|
9
|
+
import type {infer as zodInfer, SafeParseReturnType, ZodIssue, ZodType} from 'zod';
|
|
10
|
+
|
|
11
|
+
export type StateRestriction = Record<string, any>;
|
|
12
|
+
|
|
13
|
+
type FormStatus = {
|
|
14
|
+
isDirty: boolean;
|
|
15
|
+
isValid: boolean;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type FormErrors<State extends StateRestriction> = {
|
|
19
|
+
[k in keyof State]: string | null;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type FullFormState<State extends StateRestriction> = FormStatus & {value: State; errors: FormErrors<State>};
|
|
23
|
+
|
|
24
|
+
export type HandleChangeAction<State extends StateRestriction, R = void> = <Name extends keyof State>(name: Name, value: State[Name]) => R;
|
|
25
|
+
|
|
26
|
+
export type FieldProps<State extends StateRestriction, Name extends keyof State> = {
|
|
27
|
+
name: Name;
|
|
28
|
+
children: (
|
|
29
|
+
tool: {
|
|
30
|
+
name: Name;
|
|
31
|
+
value: State[Name];
|
|
32
|
+
onChange(this: void, name: Name, value: State[Name]): void;
|
|
33
|
+
onChange(this: void, e: ChangeEvent<HTMLElement>): void;
|
|
34
|
+
},
|
|
35
|
+
value: State[Name],
|
|
36
|
+
errorMessage: string | null,
|
|
37
|
+
) => ReactNode;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Mayoiga Field will memoize the result of renderProps for performance.
|
|
41
|
+
* If your renderProps depends on external variables, you should apply those on deps
|
|
42
|
+
*/
|
|
43
|
+
deps?: ReadonlyArray<unknown>;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type SliceProps<State extends StateRestriction, Selected extends ReadonlyArray<unknown>> = {
|
|
47
|
+
selector: (s: FullFormState<State>) => Selected;
|
|
48
|
+
children: (tool: {handleChange: HandleChangeAction<State>}, ...value: Readonly<Selected>) => ReactNode;
|
|
49
|
+
/**
|
|
50
|
+
* Mayoiga Slicer will memoize the result of renderProps for performance.
|
|
51
|
+
* If your renderProps depends on external variables, you should apply those on deps
|
|
52
|
+
*
|
|
53
|
+
* ```typescript
|
|
54
|
+
* <Slicer selector={s => [s.value.selected]} deps={[selection]}>{(tools, selected: string) => <AwesomeSelection value={selected} options={selection} />}</Slicer>
|
|
55
|
+
* ```
|
|
56
|
+
*/
|
|
57
|
+
deps?: ReadonlyArray<unknown>;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
type FormControllerBehavior<StateBeforeValidation> = {
|
|
61
|
+
/** For testing */
|
|
62
|
+
peek: (
|
|
63
|
+
eyeball: (prev: FullFormState<StateBeforeValidation>) => void,
|
|
64
|
+
) => (prev: FullFormState<StateBeforeValidation>) => FullFormState<StateBeforeValidation>;
|
|
65
|
+
|
|
66
|
+
reset: () => () => FullFormState<StateBeforeValidation>;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Initialize form state without validation.
|
|
70
|
+
*/
|
|
71
|
+
initializeForm: (
|
|
72
|
+
initialVal: Partial<StateBeforeValidation>,
|
|
73
|
+
) => (prev: FullFormState<StateBeforeValidation>) => FullFormState<StateBeforeValidation>;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Handle issue that zod reported
|
|
77
|
+
*/
|
|
78
|
+
handleIssues: (issues: ReadonlyArray<ZodIssue>) => (prev: FullFormState<StateBeforeValidation>) => FullFormState<StateBeforeValidation>;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* You can handle server side or other external error via this action.
|
|
82
|
+
*/
|
|
83
|
+
pushFormErrors: (
|
|
84
|
+
validator: (state: StateBeforeValidation) => Partial<FormErrors<StateBeforeValidation>>,
|
|
85
|
+
) => (prev: FullFormState<StateBeforeValidation>) => FullFormState<StateBeforeValidation>;
|
|
86
|
+
|
|
87
|
+
reduceFormErrors: (
|
|
88
|
+
reducer: (prev: FormErrors<StateBeforeValidation>) => FormErrors<StateBeforeValidation>,
|
|
89
|
+
) => (prev: FullFormState<StateBeforeValidation>) => FullFormState<StateBeforeValidation>;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
*/
|
|
93
|
+
handleChange: HandleChangeAction<
|
|
94
|
+
StateBeforeValidation,
|
|
95
|
+
(prev: FullFormState<StateBeforeValidation>) => FullFormState<StateBeforeValidation>
|
|
96
|
+
>;
|
|
97
|
+
|
|
98
|
+
reduceFormState: (
|
|
99
|
+
reducer: (prevValue: StateBeforeValidation) => StateBeforeValidation,
|
|
100
|
+
) => (prev: FullFormState<StateBeforeValidation>) => FullFormState<StateBeforeValidation>;
|
|
101
|
+
handleBulkChange: (
|
|
102
|
+
setter: (prev: StateBeforeValidation) => Partial<StateBeforeValidation>,
|
|
103
|
+
) => (prev: FullFormState<StateBeforeValidation>) => FullFormState<StateBeforeValidation>;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const initialFormState = Object.freeze({isDirty: false, isValid: false});
|
|
107
|
+
|
|
108
|
+
function createFormStore<StateBeforeValidation extends StateRestriction, Schema extends ZodType<any, any, any>>(
|
|
109
|
+
initialState: StateBeforeValidation,
|
|
110
|
+
schema: Schema,
|
|
111
|
+
): Subscriber<FullFormState<StateBeforeValidation>, FormControllerBehavior<StateBeforeValidation>> {
|
|
112
|
+
type FullState = FullFormState<StateBeforeValidation>;
|
|
113
|
+
type Err = FormErrors<StateBeforeValidation>;
|
|
114
|
+
type StateKeys = keyof StateBeforeValidation;
|
|
115
|
+
|
|
116
|
+
const initialErrors = Object.freeze(
|
|
117
|
+
Object.keys(initialState).reduce((buf, k) => {
|
|
118
|
+
buf[k as StateKeys] = null;
|
|
119
|
+
return buf;
|
|
120
|
+
}, {} as any as Err),
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const fullInitialState: FullState = Object.freeze({
|
|
124
|
+
...initialFormState,
|
|
125
|
+
value: {...initialState},
|
|
126
|
+
errors: {...initialErrors},
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const withValidation = (prev: FullState, newValue: StateBeforeValidation) => {
|
|
130
|
+
const result = schema.safeParse(newValue);
|
|
131
|
+
if (result.success) {
|
|
132
|
+
return {...prev, isDirty: true, isValid: true, value: newValue, errors: initialErrors};
|
|
133
|
+
}
|
|
134
|
+
const newErrors = result.error.issues.reduce((buf, iss) => {
|
|
135
|
+
// TODO: should support nested value?
|
|
136
|
+
const shallowPath = iss.path[0];
|
|
137
|
+
if (shallowPath === undefined) {
|
|
138
|
+
return buf;
|
|
139
|
+
}
|
|
140
|
+
buf[shallowPath as any as StateKeys] = iss.message;
|
|
141
|
+
return buf;
|
|
142
|
+
}, {} as any as Err);
|
|
143
|
+
|
|
144
|
+
return {...prev, isDirty: true, isValid: false, errors: newErrors, value: newValue};
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
return createStore(fullInitialState, {
|
|
148
|
+
peek: (eyeball) => (prev) => {
|
|
149
|
+
eyeball(prev);
|
|
150
|
+
return prev;
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
reset: () => () => fullInitialState,
|
|
154
|
+
initializeForm: (initialVal) => (prev) => ({
|
|
155
|
+
...prev,
|
|
156
|
+
value: {...prev.value, ...initialVal},
|
|
157
|
+
errors: {...initialErrors},
|
|
158
|
+
}),
|
|
159
|
+
handleIssues: (issues) => (prev) => {
|
|
160
|
+
// FIXME: handle duplication
|
|
161
|
+
const newErrors = issues.reduce((buf, iss) => {
|
|
162
|
+
const shallowPath = iss.path[0];
|
|
163
|
+
if (shallowPath === undefined) {
|
|
164
|
+
return buf;
|
|
165
|
+
}
|
|
166
|
+
buf[shallowPath as any as StateKeys] = iss.message;
|
|
167
|
+
return buf;
|
|
168
|
+
}, {} as any as Err);
|
|
169
|
+
|
|
170
|
+
return {...prev, isDirty: true, isValid: false, errors: newErrors};
|
|
171
|
+
},
|
|
172
|
+
pushFormErrors: (validator) => (prev) => {
|
|
173
|
+
const pushedErrors = validator(prev.value);
|
|
174
|
+
return {...prev, errors: {...prev.errors, ...pushedErrors}};
|
|
175
|
+
},
|
|
176
|
+
reduceFormErrors: (reducer) => (prev) => {
|
|
177
|
+
return {...prev, errors: reducer(prev.errors)};
|
|
178
|
+
},
|
|
179
|
+
handleChange: (name, value) => (prev) => {
|
|
180
|
+
if (name in prev.value === false) {
|
|
181
|
+
return prev;
|
|
182
|
+
}
|
|
183
|
+
const newValue = {...prev.value, [name]: value};
|
|
184
|
+
return withValidation(prev, newValue);
|
|
185
|
+
},
|
|
186
|
+
reduceFormState: (reducer) => (prev) => {
|
|
187
|
+
const reducedState = reducer(prev.value);
|
|
188
|
+
return withValidation(prev, reducedState);
|
|
189
|
+
},
|
|
190
|
+
handleBulkChange: (setter) => (prev) => {
|
|
191
|
+
const mergedNewState = {...prev.value, ...setter(prev.value)};
|
|
192
|
+
return withValidation(prev, mergedNewState);
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
type ActionsCanBePublic<State extends StateRestriction> = {
|
|
198
|
+
reset: VoidFunction;
|
|
199
|
+
initializeForm: (initial: Partial<State>) => void;
|
|
200
|
+
pushFormErrors: (validator: (state: State) => Partial<FormErrors<State>>) => void;
|
|
201
|
+
handleChange: HandleChangeAction<State>;
|
|
202
|
+
handleBulkChange: (setter: (prev: State) => Partial<State>) => void;
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Restricted type interface of the form store
|
|
207
|
+
* TODO: more restriction
|
|
208
|
+
*/
|
|
209
|
+
export type Controller<State extends StateRestriction> = {
|
|
210
|
+
useInitialize: (initialValue: Partial<State>) => void;
|
|
211
|
+
useSelector: <R>(selector: (state: FullFormState<State>) => R, isEqual?: (prev: R, current: R) => boolean) => R;
|
|
212
|
+
|
|
213
|
+
actions: ActionsCanBePublic<State>;
|
|
214
|
+
components: {
|
|
215
|
+
Field: <Name extends keyof State>(props: FieldProps<State, Name>) => ReactElement<any, any> | null;
|
|
216
|
+
Slicer: <Selected extends ReadonlyArray<unknown>>(props: SliceProps<State, Selected>) => ReactElement<any, any> | null;
|
|
217
|
+
};
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
type FormHook<State extends StateRestriction, Schema extends ZodType<any, any, any>> = {
|
|
221
|
+
controller: Controller<State>;
|
|
222
|
+
|
|
223
|
+
handleSubmit: (
|
|
224
|
+
handler: (
|
|
225
|
+
e: BaseSyntheticEvent,
|
|
226
|
+
) => (
|
|
227
|
+
val: {success: true; data: zodInfer<Schema>; error: undefined} | {success: false; error: ReadonlyArray<ZodIssue>},
|
|
228
|
+
) => void | Promise<void>,
|
|
229
|
+
) => (e: BaseSyntheticEvent) => void;
|
|
230
|
+
|
|
231
|
+
api: Subscriber<FullFormState<State>, FormControllerBehavior<State>>;
|
|
232
|
+
} & Controller<State>;
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* @typeParam InitialState should be shallow
|
|
236
|
+
*/
|
|
237
|
+
export function createFormHook<StateBeforeValidation, Schema extends ZodType<any, any, any>>(
|
|
238
|
+
initialState: StateBeforeValidation,
|
|
239
|
+
schema: Schema,
|
|
240
|
+
): FormHook<StateBeforeValidation, Schema> {
|
|
241
|
+
const store = createFormStore(initialState, schema);
|
|
242
|
+
|
|
243
|
+
const formHook = {
|
|
244
|
+
useInitialize: (initialValue: Partial<StateBeforeValidation>) => {
|
|
245
|
+
useEffect(() => {
|
|
246
|
+
store.actions.initializeForm(initialValue);
|
|
247
|
+
}, []);
|
|
248
|
+
},
|
|
249
|
+
useSelector: store.useSelector,
|
|
250
|
+
handleSubmit: (
|
|
251
|
+
handler: (
|
|
252
|
+
e: BaseSyntheticEvent,
|
|
253
|
+
) => (
|
|
254
|
+
val: {success: true; data: zodInfer<Schema>; error: undefined} | {success: false; error: ReadonlyArray<ZodIssue>},
|
|
255
|
+
) => void | Promise<void>,
|
|
256
|
+
) => {
|
|
257
|
+
return (e: BaseSyntheticEvent) => {
|
|
258
|
+
const eventHandled = handler(e);
|
|
259
|
+
const value = store.getState().value;
|
|
260
|
+
return schema.safeParseAsync(value).then((result: SafeParseReturnType<StateBeforeValidation, zodInfer<typeof schema>>) => {
|
|
261
|
+
// FIXME: hook formを捨ててzodのバージョンをあげればこの辺に型をつけられる
|
|
262
|
+
if (result.success) {
|
|
263
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
|
264
|
+
return eventHandled({success: true, data: result.data, error: undefined});
|
|
265
|
+
}
|
|
266
|
+
void eventHandled({success: false, error: result.error.issues});
|
|
267
|
+
store.actions.handleIssues(result.error.issues);
|
|
268
|
+
});
|
|
269
|
+
};
|
|
270
|
+
},
|
|
271
|
+
|
|
272
|
+
components: {
|
|
273
|
+
Slicer<R extends ReadonlyArray<unknown>>(props: SliceProps<StateBeforeValidation, R>) {
|
|
274
|
+
const {selector, children, deps} = props;
|
|
275
|
+
const slicedValues = formHook.useSelector(selector);
|
|
276
|
+
return useMemo(
|
|
277
|
+
() => {
|
|
278
|
+
const renderContent = children({handleChange: formHook.actions.handleChange}, ...slicedValues);
|
|
279
|
+
return createElement(Fragment, {}, renderContent);
|
|
280
|
+
},
|
|
281
|
+
deps ? [...slicedValues, ...deps] : slicedValues,
|
|
282
|
+
);
|
|
283
|
+
},
|
|
284
|
+
Field<Name extends keyof StateBeforeValidation>(props: FieldProps<StateBeforeValidation, Name>) {
|
|
285
|
+
const {name, children, deps} = props;
|
|
286
|
+
const [value, errMsg] = store.useSelector((s) => [s.value[name], s.errors[name]] as const);
|
|
287
|
+
return useMemo(
|
|
288
|
+
() => {
|
|
289
|
+
const renderContent = children(
|
|
290
|
+
{
|
|
291
|
+
name,
|
|
292
|
+
value,
|
|
293
|
+
onChange: (name_or_event: unknown, value_or_none?: unknown) => {
|
|
294
|
+
if (typeof name_or_event === 'string') {
|
|
295
|
+
// assumes name is valid if it's string
|
|
296
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
|
297
|
+
return store.actions.handleChange(name_or_event as Name, value_or_none as any);
|
|
298
|
+
}
|
|
299
|
+
if (isChangeEvent(name_or_event)) {
|
|
300
|
+
const target: any = name_or_event.currentTarget || name_or_event.target;
|
|
301
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access
|
|
302
|
+
return store.actions.handleChange(target.name, target.value);
|
|
303
|
+
}
|
|
304
|
+
throw new Error('`handleChange` handles unexpected formed object.');
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
value,
|
|
308
|
+
errMsg,
|
|
309
|
+
);
|
|
310
|
+
return createElement(Fragment, {}, renderContent);
|
|
311
|
+
},
|
|
312
|
+
deps ? [value, errMsg, ...deps] : [value, errMsg],
|
|
313
|
+
);
|
|
314
|
+
},
|
|
315
|
+
},
|
|
316
|
+
actions: store.actions,
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
return {
|
|
320
|
+
controller: formHook,
|
|
321
|
+
api: store,
|
|
322
|
+
...formHook,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export function useFormSlice<State extends StateRestriction, Sliced>(
|
|
327
|
+
formController: Controller<State>,
|
|
328
|
+
selector: (s: FullFormState<State>) => Sliced,
|
|
329
|
+
) {
|
|
330
|
+
const sliced = formController.useSelector((s) => selector(s));
|
|
331
|
+
return [sliced, formController.actions] as const;
|
|
332
|
+
}
|