mayoiga 0.1.2 → 0.1.5

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.
@@ -1,4 +1,3 @@
1
-
2
1
  import {render, screen, waitFor} from '@testing-library/react';
3
2
  import userEvent from '@testing-library/user-event';
4
3
  import React from 'react';
@@ -7,14 +6,19 @@ import {test, expect} from 'vitest';
7
6
 
8
7
  import * as zod from 'zod';
9
8
 
10
- import { Field, Slicer } from './component';
9
+ import {Field, Slicer} from './component';
10
+
11
+ import {createFormHook} from './index';
11
12
 
12
- import {createFormHook, } from './index';
13
+ import type {Controller} from './index';
13
14
 
14
15
  const testSchema = zod.object({
15
16
  name: zod.string(),
16
17
  description: zod.string(),
17
- age: zod.string().transform(val => Number(val)).refine(val => Number.isNaN(val) ? 0 : val),
18
+ age: zod
19
+ .string()
20
+ .transform((val) => Number(val))
21
+ .refine((val) => (Number.isNaN(val) ? 0 : val)),
18
22
  marked: zod.boolean(),
19
23
  });
20
24
 
@@ -24,9 +28,9 @@ type FormStateBeforeValidation = {
24
28
  description: string;
25
29
  age: string;
26
30
  marked: boolean;
27
- }
31
+ };
28
32
 
29
- const createTestHook = () => createFormHook({name:'', description: '', age: '', marked: false} as FormStateBeforeValidation, testSchema);
33
+ const createTestHook = () => createFormHook({name: '', description: '', age: '', marked: false} as FormStateBeforeValidation, testSchema);
30
34
 
31
35
  // afterEach(cleanup);
32
36
 
@@ -36,11 +40,11 @@ test('Field component works fine in basic usage.', async () => {
36
40
  const Top = () => {
37
41
  return (
38
42
  <div>
39
- <Field controller={TestFormHook.controller} name="age">{
40
- (tool) => <input data-testid="age-input" {...tool} />
41
- }</Field>
43
+ <Field controller={TestFormHook.controller} name="age">
44
+ {(tool) => <input data-testid="age-input" {...tool} />}
45
+ </Field>
42
46
  </div>
43
- )
47
+ );
44
48
  };
45
49
 
46
50
  render(<Top />);
@@ -66,11 +70,13 @@ test('Slice component works fine in basic usage.', async () => {
66
70
  const Top = () => {
67
71
  return (
68
72
  <div>
69
- <Slicer controller={TestFormHook.controller} selector={s => [s.value.age] as const}>{
70
- (tool, age: string) => <input data-testid="age-input" name="age" value={age} onChange={e => tool.handleChange('age' as const, e.target.value)} />
71
- }</Slicer>
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>
72
78
  </div>
73
- )
79
+ );
74
80
  };
75
81
 
76
82
  render(<Top />);
@@ -89,3 +95,110 @@ test('Slice component works fine in basic usage.', async () => {
89
95
  });
90
96
  });
91
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
+ });
package/src/component.tsx CHANGED
@@ -1,18 +1,22 @@
1
1
  import React from 'react';
2
2
 
3
- import type {SliceProps, FieldProps, StateRestriction, Controller, } from './index';
3
+ import type {SliceProps, FieldProps, StateRestriction, Controller} from './index';
4
4
 
5
- type ControlledComponentProps<State, Props> = Props & {
5
+ type ControlledComponentProps<State extends StateRestriction, Props> = Props & {
6
6
  controller: Controller<State>;
7
- }
7
+ };
8
8
 
9
- export function Slicer<State extends StateRestriction, R extends ReadonlyArray<unknown>>(props: ControlledComponentProps<State, SliceProps<State, R>>) {
9
+ export function Slicer<State extends StateRestriction, R extends ReadonlyArray<unknown>>(
10
+ props: ControlledComponentProps<State, SliceProps<State, R>>,
11
+ ) {
10
12
  const {controller, ...restProps} = props;
11
13
  const {Slicer: Delegate} = controller.components;
12
14
  return <Delegate {...restProps} />;
13
15
  }
14
16
 
15
- export function Field<State extends StateRestriction, Name extends keyof State>(props: ControlledComponentProps<State, FieldProps<State, Name>>) {
17
+ export function Field<State extends StateRestriction, Name extends keyof State>(
18
+ props: ControlledComponentProps<State, FieldProps<State, Name>>,
19
+ ) {
16
20
  const {controller, ...restProps} = props;
17
21
  const {Field: Delegate} = controller.components;
18
22
  return <Delegate {...restProps} />;
@@ -7,14 +7,16 @@ import {test, expect} from 'vitest';
7
7
  import * as zod from 'zod';
8
8
 
9
9
  import {createFormHook, useFormSlice} from './index';
10
-
11
- import type {Controller} from './index';
12
10
 
11
+ import type {Controller} from './index';
13
12
 
14
13
  const testSchema = zod.object({
15
14
  name: zod.string(),
16
15
  description: zod.string(),
17
- age: zod.string().transform(val => Number(val)).refine(val => Number.isNaN(val) ? 0 : val),
16
+ age: zod
17
+ .string()
18
+ .transform((val) => Number(val))
19
+ .refine((val) => (Number.isNaN(val) ? 0 : val)),
18
20
  marked: zod.boolean(),
19
21
  });
20
22
 
@@ -24,13 +26,13 @@ type FormStateBeforeValidation = {
24
26
  description: string;
25
27
  age: string;
26
28
  marked: boolean;
27
- }
29
+ };
28
30
 
29
- const createTestHook = () => createFormHook({name:'', description: '', age: '', marked: false} as FormStateBeforeValidation, testSchema);
31
+ const createTestHook = () => createFormHook({name: '', description: '', age: '', marked: false} as FormStateBeforeValidation, testSchema);
30
32
 
31
33
  const AgeInputComponent = (props: {controller: Controller<FormStateBeforeValidation>}) => {
32
34
  const {controller} = props;
33
- const [[age, errorMessage], {handleChange}] = useFormSlice(controller, s => [s.value.age, s.errors.age] as const);
35
+ const [[age, errorMessage], {handleChange}] = useFormSlice(controller, (s) => [s.value.age, s.errors.age] as const);
34
36
  return (
35
37
  <div>
36
38
  <input
@@ -56,7 +58,7 @@ test('useSelector can select the fields', async () => {
56
58
  <div>
57
59
  <AgeInputComponent controller={TestFormHook.controller} />
58
60
  </div>
59
- )
61
+ );
60
62
  };
61
63
 
62
64
  render(<Top />);
@@ -81,12 +83,12 @@ test('zod parse value before submit', async () => {
81
83
  const PARSED_RESULT = Number(INPUT_AGE);
82
84
 
83
85
  const handleSubmitTester = TestFormHook.handleSubmit((e) => (result) => {
84
- if (result.success) {
85
- expect(result.data.age).toBe(PARSED_RESULT);
86
- } else {
87
- expect('should not called').toBe(result.error);
88
- }
89
- });
86
+ if (result.success) {
87
+ expect(result.data.age).toBe(PARSED_RESULT);
88
+ } else {
89
+ expect('should not called').toBe(result.error);
90
+ }
91
+ });
90
92
 
91
93
  const Top = () => {
92
94
  return (
@@ -94,16 +96,16 @@ test('zod parse value before submit', async () => {
94
96
  <AgeInputComponent controller={TestFormHook} />
95
97
  <button data-testid="submit-button" onClick={handleSubmitTester} />
96
98
  </div>
97
- )
99
+ );
98
100
  };
99
101
 
100
102
  render(<Top />);
101
103
 
102
- const ageInput: HTMLInputElement = screen.getByTestId('age-input') ;
104
+ const ageInput: HTMLInputElement = screen.getByTestId('age-input');
103
105
  await userEvent.type(ageInput, INPUT_AGE);
104
106
 
105
107
  await waitFor(() => {
106
- const ageInput: HTMLInputElement = screen.getByTestId('age-input') ;
108
+ const ageInput: HTMLInputElement = screen.getByTestId('age-input');
107
109
  expect(ageInput.value).toBe(INPUT_AGE);
108
110
  });
109
111
 
@@ -117,4 +119,4 @@ test('zod parse value before submit', async () => {
117
119
  expect(s.errors.age).toBeNull();
118
120
  });
119
121
  });
120
- });
122
+ });
package/src/index.ts CHANGED
@@ -35,14 +35,29 @@ export type FieldProps<State extends StateRestriction, Name extends keyof State>
35
35
  value: State[Name],
36
36
  errorMessage: string | null,
37
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>;
38
44
  };
39
45
 
40
46
  export type SliceProps<State extends StateRestriction, Selected extends ReadonlyArray<unknown>> = {
41
47
  selector: (s: FullFormState<State>) => Selected;
42
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>;
43
58
  };
44
59
 
45
- type FormControllerBehavior<StateBeforeValidation> = {
60
+ type FormControllerBehavior<StateBeforeValidation extends StateRestriction> = {
46
61
  /** For testing */
47
62
  peek: (
48
63
  eyeball: (prev: FullFormState<StateBeforeValidation>) => void,
@@ -65,7 +80,11 @@ type FormControllerBehavior<StateBeforeValidation> = {
65
80
  /**
66
81
  * You can handle server side or other external error via this action.
67
82
  */
68
- handleError: (
83
+ pushFormErrors: (
84
+ validator: (state: StateBeforeValidation) => Partial<FormErrors<StateBeforeValidation>>,
85
+ ) => (prev: FullFormState<StateBeforeValidation>) => FullFormState<StateBeforeValidation>;
86
+
87
+ reduceFormErrors: (
69
88
  reducer: (prev: FormErrors<StateBeforeValidation>) => FormErrors<StateBeforeValidation>,
70
89
  ) => (prev: FullFormState<StateBeforeValidation>) => FullFormState<StateBeforeValidation>;
71
90
 
@@ -150,7 +169,11 @@ function createFormStore<StateBeforeValidation extends StateRestriction, Schema
150
169
 
151
170
  return {...prev, isDirty: true, isValid: false, errors: newErrors};
152
171
  },
153
- handleError: (reducer) => (prev) => {
172
+ pushFormErrors: (validator) => (prev) => {
173
+ const pushedErrors = validator(prev.value);
174
+ return {...prev, errors: {...prev.errors, ...pushedErrors}};
175
+ },
176
+ reduceFormErrors: (reducer) => (prev) => {
154
177
  return {...prev, errors: reducer(prev.errors)};
155
178
  },
156
179
  handleChange: (name, value) => (prev) => {
@@ -174,9 +197,9 @@ function createFormStore<StateBeforeValidation extends StateRestriction, Schema
174
197
  type ActionsCanBePublic<State extends StateRestriction> = {
175
198
  reset: VoidFunction;
176
199
  initializeForm: (initial: Partial<State>) => void;
200
+ pushFormErrors: (validator: (state: State) => Partial<FormErrors<State>>) => void;
177
201
  handleChange: HandleChangeAction<State>;
178
202
  handleBulkChange: (setter: (prev: State) => Partial<State>) => void;
179
- handleError: (reducer: (prev: FormErrors<State>) => FormErrors<State>) => void;
180
203
  };
181
204
 
182
205
  /**
@@ -211,7 +234,7 @@ type FormHook<State extends StateRestriction, Schema extends ZodType<any, any, a
211
234
  /**
212
235
  * @typeParam InitialState should be shallow
213
236
  */
214
- export function createFormHook<StateBeforeValidation, Schema extends ZodType<any, any, any>>(
237
+ export function createFormHook<StateBeforeValidation extends StateRestriction, Schema extends ZodType<any, any, any>>(
215
238
  initialState: StateBeforeValidation,
216
239
  schema: Schema,
217
240
  ): FormHook<StateBeforeValidation, Schema> {
@@ -248,40 +271,46 @@ export function createFormHook<StateBeforeValidation, Schema extends ZodType<any
248
271
 
249
272
  components: {
250
273
  Slicer<R extends ReadonlyArray<unknown>>(props: SliceProps<StateBeforeValidation, R>) {
251
- const {selector, children} = props;
252
- const value = formHook.useSelector(selector);
253
- return useMemo(() => {
254
- const renderContent = children({handleChange: formHook.actions.handleChange}, ...value);
255
- return createElement(Fragment, {}, renderContent);
256
- }, value);
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
+ );
257
283
  },
258
284
  Field<Name extends keyof StateBeforeValidation>(props: FieldProps<StateBeforeValidation, Name>) {
259
- const {name, children} = props;
285
+ const {name, children, deps} = props;
260
286
  const [value, errMsg] = store.useSelector((s) => [s.value[name], s.errors[name]] as const);
261
- return useMemo(() => {
262
- const renderContent = children(
263
- {
264
- name,
265
- value,
266
- onChange: (name_or_event: unknown, value_or_none?: unknown) => {
267
- if (typeof name_or_event === 'string') {
268
- // assumes name is valid if it's string
269
- // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
270
- return store.actions.handleChange(name_or_event as Name, value_or_none as any);
271
- }
272
- if (isChangeEvent(name_or_event)) {
273
- const target: any = name_or_event.currentTarget || name_or_event.target;
274
- // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access
275
- return store.actions.handleChange(target.name, target.value);
276
- }
277
- throw new Error('`handleChange` handles unexpected formed object.');
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
+ },
278
306
  },
279
- },
280
- value,
281
- errMsg,
282
- );
283
- return createElement(Fragment, {}, renderContent);
284
- }, [value, errMsg]);
307
+ value,
308
+ errMsg,
309
+ );
310
+ return createElement(Fragment, {}, renderContent);
311
+ },
312
+ deps ? [value, errMsg, ...deps] : [value, errMsg],
313
+ );
285
314
  },
286
315
  },
287
316
  actions: store.actions,