mayoiga 0.1.6 → 0.1.8

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.
@@ -9,7 +9,7 @@ jobs:
9
9
 
10
10
  strategy:
11
11
  matrix:
12
- node-version: [16.x]
12
+ node-version: [18.x]
13
13
 
14
14
  steps:
15
15
  - uses: actions/checkout@v1
package/dist/index.d.ts CHANGED
@@ -101,14 +101,18 @@ export type Controller<State extends StateRestriction> = {
101
101
  };
102
102
  type FormHook<State extends StateRestriction, Schema extends ZodType<any, any, any>> = {
103
103
  controller: Controller<State>;
104
- handleSubmit: (handler: (e: BaseSyntheticEvent) => (val: {
104
+ /**
105
+ * @param handler handling logic
106
+ * @returns created handler is always return Promise, because of the schema validation run it asynchronously.
107
+ */
108
+ handleSubmit: <R>(handler: (e: BaseSyntheticEvent) => (val: {
105
109
  success: true;
106
110
  data: zodInfer<Schema>;
107
111
  error: undefined;
108
112
  } | {
109
113
  success: false;
110
114
  error: ReadonlyArray<ZodIssue>;
111
- }) => void | Promise<void>) => (e: BaseSyntheticEvent) => void;
115
+ }) => R) => (e: BaseSyntheticEvent) => Promise<R>;
112
116
  api: Subscriber<FullFormState<State>, FormControllerBehavior<State>>;
113
117
  } & Controller<State>;
114
118
  /**
package/dist/index.js CHANGED
@@ -54,7 +54,8 @@ function createFormStore(initialState, schema) {
54
54
  },
55
55
  pushFormErrors: (validator) => (prev) => {
56
56
  const pushedErrors = validator(prev.value);
57
- return { ...prev, errors: { ...prev.errors, ...pushedErrors } };
57
+ const allErrors = { ...prev.errors, ...pushedErrors };
58
+ return { ...prev, isValid: Object.keys(allErrors).length === 0, errors: allErrors };
58
59
  },
59
60
  reduceFormErrors: (reducer) => (prev) => {
60
61
  return { ...prev, errors: reducer(prev.errors) };
@@ -98,8 +99,20 @@ export function createFormHook(initialState, schema) {
98
99
  // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
99
100
  return eventHandled({ success: true, data: result.data, error: undefined });
100
101
  }
101
- void eventHandled({ success: false, error: result.error.issues });
102
- store.actions.handleIssues(result.error.issues);
102
+ const handled = eventHandled({ success: false, error: result.error.issues });
103
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
104
+ if (typeof handled['then'] === 'function') {
105
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
106
+ return handled.then((x) => {
107
+ store.actions.handleIssues(result.error.issues);
108
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-return
109
+ return x;
110
+ });
111
+ }
112
+ else {
113
+ store.actions.handleIssues(result.error.issues);
114
+ return handled;
115
+ }
103
116
  });
104
117
  };
105
118
  },
@@ -53,11 +53,15 @@ test('zod parse value before submit', async () => {
53
53
  else {
54
54
  expect('should not called').toBe(result.error);
55
55
  }
56
+ return Promise.resolve('returns value');
56
57
  });
57
58
  const Top = () => {
58
59
  return (React.createElement("div", null,
59
60
  React.createElement(AgeInputComponent, { controller: TestFormHook }),
60
- React.createElement("button", { "data-testid": "submit-button", onClick: handleSubmitTester })));
61
+ React.createElement("button", { "data-testid": "submit-button", onClick: async (e) => {
62
+ const returnedValue = await handleSubmitTester(e);
63
+ expect(returnedValue).toBe('returns value');
64
+ } })));
61
65
  };
62
66
  render(React.createElement(Top, null));
63
67
  const ageInput = screen.getByTestId('age-input');
@@ -76,6 +80,49 @@ test('zod parse value before submit', async () => {
76
80
  });
77
81
  });
78
82
  });
83
+ test('able to handle parse error on submit', async () => {
84
+ const TestFormHook = createTestHook();
85
+ const INPUT_AGE = 'aaaa';
86
+ const EXPECTED_ISSUE = {
87
+ code: 'custom',
88
+ message: 'Invalid input',
89
+ path: ['age'],
90
+ };
91
+ const handleSubmitTester = TestFormHook.handleSubmit((e) => (result) => {
92
+ if (result.success) {
93
+ expect('should not succeed').toBe(false);
94
+ }
95
+ else {
96
+ expect(result.success).toBe(false);
97
+ expect(result.error).toStrictEqual([EXPECTED_ISSUE]);
98
+ }
99
+ return Promise.resolve('returns value');
100
+ });
101
+ const Top = () => {
102
+ return (React.createElement("div", null,
103
+ React.createElement(AgeInputComponent, { controller: TestFormHook }),
104
+ React.createElement("button", { "data-testid": "submit-button", onClick: async (e) => {
105
+ const returnedValue = await handleSubmitTester(e);
106
+ expect(returnedValue).toBe('returns value');
107
+ } })));
108
+ };
109
+ render(React.createElement(Top, null));
110
+ const ageInput = screen.getByTestId('age-input');
111
+ await userEvent.type(ageInput, INPUT_AGE);
112
+ await waitFor(() => {
113
+ const ageInput = screen.getByTestId('age-input');
114
+ expect(ageInput.value).toBe(INPUT_AGE);
115
+ });
116
+ const submitButton = screen.getByTestId('submit-button');
117
+ await userEvent.click(submitButton);
118
+ await waitFor(() => {
119
+ console.log('waiting');
120
+ TestFormHook.api.actions.peek((s) => {
121
+ expect(s.value.age).toBe(INPUT_AGE);
122
+ expect(s.errors.age).toBe(EXPECTED_ISSUE.message);
123
+ });
124
+ });
125
+ });
79
126
  test('User is able to re-initialize the form state', async () => {
80
127
  const TestFormHook = createTestHook();
81
128
  const Top = () => {
@@ -89,3 +136,25 @@ test('User is able to re-initialize the form state', async () => {
89
136
  TestFormHook.actions.initializeForm({}, { cleanup: true });
90
137
  expect(TestFormHook.api.getState().isDirty).toBeFalsy();
91
138
  });
139
+ test('User is able to push server side validation', async () => {
140
+ const TestFormHook = createTestHook();
141
+ const Top = () => {
142
+ return (React.createElement("div", null,
143
+ React.createElement(AgeInputComponent, { controller: TestFormHook.controller })));
144
+ };
145
+ render(React.createElement(Top, null));
146
+ const ageInput = screen.getByTestId('age-input');
147
+ await userEvent.type(ageInput, '9999');
148
+ TestFormHook.actions.pushFormErrors((_s) => ({ age: 'server side error' }));
149
+ const state1 = TestFormHook.api.getState();
150
+ expect(state1.isDirty).toBeTruthy();
151
+ expect(state1.isValid).toBeFalsy();
152
+ expect(state1.errors.age).toEqual('server side error');
153
+ await userEvent.type(ageInput, 'hgoehoge');
154
+ // confirm empty field won't overwrite any messages
155
+ TestFormHook.actions.pushFormErrors((_s) => ({}));
156
+ const state2 = TestFormHook.api.getState();
157
+ expect(state2.isDirty).toBeTruthy();
158
+ expect(state2.isValid).toBeFalsy();
159
+ expect(state2.errors.age).toEqual('Invalid input');
160
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mayoiga",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Type safe hook based form library for React",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -43,27 +43,32 @@
43
43
  "zod": "^3.20.2"
44
44
  },
45
45
  "dependencies": {
46
- "nozuchi": "^0.0.4"
46
+ "nozuchi": "^0.0.6"
47
+ },
48
+ "resolutions": {
49
+ "react": "^18.2.0",
50
+ "react-dom": "^18.2.0",
51
+ "@types/react": "^18.2.57"
47
52
  },
48
53
  "devDependencies": {
49
- "@testing-library/react": "^14.0.0",
50
- "@testing-library/user-event": "^14.4.3",
51
- "@types/node": "^17.0.23",
52
- "@types/react": "^18.2.7",
53
- "@types/use-sync-external-store": "^0.0.3",
54
- "@typescript-eslint/eslint-plugin": "^5.59.8",
55
- "@typescript-eslint/parser": "^5.59.8",
56
- "eslint": "^8.41.0",
57
- "eslint-plugin-import": "^2.27.5",
58
- "eslint-plugin-testing-library": "^5.11.0",
59
- "jsdom": "^22.1.0",
60
- "prettier": "^2.8.8",
54
+ "@testing-library/react": "^14.2.1",
55
+ "@testing-library/user-event": "^14.5.2",
56
+ "@types/node": "^20.11.19",
57
+ "@types/react": "^18.2.57",
58
+ "@types/use-sync-external-store": "^0.0.6",
59
+ "@typescript-eslint/eslint-plugin": "^7.0.2",
60
+ "@typescript-eslint/parser": "^7.0.2",
61
+ "eslint": "^8.56.0",
62
+ "eslint-plugin-import": "^2.29.1",
63
+ "eslint-plugin-testing-library": "^6.2.0",
64
+ "jsdom": "^24.0.0",
65
+ "prettier": "^3.2.5",
61
66
  "react": "^18.2.0",
62
67
  "react-dom": "^18.2.0",
63
- "typedoc": "^0.24.7",
64
- "typescript": "^5.0.4",
68
+ "typedoc": "^0.25.8",
69
+ "typescript": "^5.3.3",
65
70
  "use-sync-external-store": "^1.2.0",
66
- "vitest": "^0.31.1",
67
- "zod": "^3.20.2"
71
+ "vitest": "^1.3.0",
72
+ "zod": "^3.22.4"
68
73
  }
69
74
  }
@@ -1,5 +1,6 @@
1
1
  import {render, screen, waitFor} from '@testing-library/react';
2
2
  import userEvent from '@testing-library/user-event';
3
+
3
4
  import React from 'react';
4
5
 
5
6
  import {test, expect} from 'vitest';
@@ -89,13 +90,20 @@ test('zod parse value before submit', async () => {
89
90
  } else {
90
91
  expect('should not called').toBe(result.error);
91
92
  }
93
+ return Promise.resolve('returns value');
92
94
  });
93
95
 
94
96
  const Top = () => {
95
97
  return (
96
98
  <div>
97
99
  <AgeInputComponent controller={TestFormHook} />
98
- <button data-testid="submit-button" onClick={handleSubmitTester} />
100
+ <button
101
+ data-testid="submit-button"
102
+ onClick={async (e) => {
103
+ const returnedValue = await handleSubmitTester(e);
104
+ expect(returnedValue).toBe('returns value');
105
+ }}
106
+ />
99
107
  </div>
100
108
  );
101
109
  };
@@ -122,6 +130,63 @@ test('zod parse value before submit', async () => {
122
130
  });
123
131
  });
124
132
 
133
+ test('able to handle parse error on submit', async () => {
134
+ const TestFormHook = createTestHook();
135
+
136
+ const INPUT_AGE = 'aaaa';
137
+ const EXPECTED_ISSUE = {
138
+ code: 'custom',
139
+ message: 'Invalid input',
140
+ path: ['age'],
141
+ };
142
+
143
+ const handleSubmitTester = TestFormHook.handleSubmit((e) => (result) => {
144
+ if (result.success) {
145
+ expect('should not succeed').toBe(false);
146
+ } else {
147
+ expect(result.success).toBe(false);
148
+ expect(result.error).toStrictEqual([EXPECTED_ISSUE]);
149
+ }
150
+ return Promise.resolve('returns value');
151
+ });
152
+
153
+ const Top = () => {
154
+ return (
155
+ <div>
156
+ <AgeInputComponent controller={TestFormHook} />
157
+ <button
158
+ data-testid="submit-button"
159
+ onClick={async (e) => {
160
+ const returnedValue = await handleSubmitTester(e);
161
+ expect(returnedValue).toBe('returns value');
162
+ }}
163
+ />
164
+ </div>
165
+ );
166
+ };
167
+
168
+ render(<Top />);
169
+
170
+ const ageInput: HTMLInputElement = screen.getByTestId('age-input');
171
+ await userEvent.type(ageInput, INPUT_AGE);
172
+
173
+ await waitFor(() => {
174
+ const ageInput: HTMLInputElement = screen.getByTestId('age-input');
175
+ expect(ageInput.value).toBe(INPUT_AGE);
176
+ });
177
+
178
+ const submitButton = screen.getByTestId('submit-button');
179
+ await userEvent.click(submitButton);
180
+
181
+ await waitFor(() => {
182
+ console.log('waiting');
183
+ TestFormHook.api.actions.peek((s) => {
184
+ expect(s.value.age).toBe(INPUT_AGE);
185
+ expect(s.errors.age).toBe(EXPECTED_ISSUE.message);
186
+ });
187
+ });
188
+ });
189
+
125
190
  test('User is able to re-initialize the form state', async () => {
126
191
  const TestFormHook = createTestHook();
127
192
 
@@ -142,3 +207,35 @@ test('User is able to re-initialize the form state', async () => {
142
207
  TestFormHook.actions.initializeForm({}, {cleanup: true});
143
208
  expect(TestFormHook.api.getState().isDirty).toBeFalsy();
144
209
  });
210
+
211
+ test('User is able to push server side validation', async () => {
212
+ const TestFormHook = createTestHook();
213
+
214
+ const Top = () => {
215
+ return (
216
+ <div>
217
+ <AgeInputComponent controller={TestFormHook.controller} />
218
+ </div>
219
+ );
220
+ };
221
+
222
+ render(<Top />);
223
+
224
+ const ageInput: HTMLInputElement = screen.getByTestId('age-input');
225
+ await userEvent.type(ageInput, '9999');
226
+
227
+ TestFormHook.actions.pushFormErrors((_s) => ({age: 'server side error'}));
228
+ const state1 = TestFormHook.api.getState();
229
+ expect(state1.isDirty).toBeTruthy();
230
+ expect(state1.isValid).toBeFalsy();
231
+ expect(state1.errors.age).toEqual('server side error');
232
+
233
+ await userEvent.type(ageInput, 'hgoehoge');
234
+
235
+ // confirm empty field won't overwrite any messages
236
+ TestFormHook.actions.pushFormErrors((_s) => ({}));
237
+ const state2 = TestFormHook.api.getState();
238
+ expect(state2.isDirty).toBeTruthy();
239
+ expect(state2.isValid).toBeFalsy();
240
+ expect(state2.errors.age).toEqual('Invalid input');
241
+ });
package/src/index.ts CHANGED
@@ -120,10 +120,13 @@ function createFormStore<StateBeforeValidation extends StateRestriction, Schema
120
120
  type StateKeys = keyof StateBeforeValidation;
121
121
 
122
122
  const initialErrors = Object.freeze(
123
- Object.keys(initialState).reduce((buf, k) => {
124
- buf[k as StateKeys] = null;
125
- return buf;
126
- }, {} as any as Err),
123
+ Object.keys(initialState).reduce(
124
+ (buf, k) => {
125
+ buf[k as StateKeys] = null;
126
+ return buf;
127
+ },
128
+ {} as any as Err,
129
+ ),
127
130
  );
128
131
 
129
132
  const fullInitialState: FullState = Object.freeze({
@@ -137,15 +140,18 @@ function createFormStore<StateBeforeValidation extends StateRestriction, Schema
137
140
  if (result.success) {
138
141
  return {...prev, isDirty: true, isValid: true, value: newValue, errors: initialErrors};
139
142
  }
140
- const newErrors = result.error.issues.reduce((buf, iss) => {
141
- // TODO: should support nested value?
142
- const shallowPath = iss.path[0];
143
- if (shallowPath === undefined) {
143
+ const newErrors = result.error.issues.reduce(
144
+ (buf, iss) => {
145
+ // TODO: should support nested value?
146
+ const shallowPath = iss.path[0];
147
+ if (shallowPath === undefined) {
148
+ return buf;
149
+ }
150
+ buf[shallowPath as any as StateKeys] = iss.message;
144
151
  return buf;
145
- }
146
- buf[shallowPath as any as StateKeys] = iss.message;
147
- return buf;
148
- }, {} as any as Err);
152
+ },
153
+ {} as any as Err,
154
+ );
149
155
 
150
156
  return {...prev, isDirty: true, isValid: false, errors: newErrors, value: newValue};
151
157
  };
@@ -165,20 +171,24 @@ function createFormStore<StateBeforeValidation extends StateRestriction, Schema
165
171
  }),
166
172
  handleIssues: (issues) => (prev) => {
167
173
  // FIXME: handle duplication
168
- const newErrors = issues.reduce((buf, iss) => {
169
- const shallowPath = iss.path[0];
170
- if (shallowPath === undefined) {
174
+ const newErrors = issues.reduce(
175
+ (buf, iss) => {
176
+ const shallowPath = iss.path[0];
177
+ if (shallowPath === undefined) {
178
+ return buf;
179
+ }
180
+ buf[shallowPath as any as StateKeys] = iss.message;
171
181
  return buf;
172
- }
173
- buf[shallowPath as any as StateKeys] = iss.message;
174
- return buf;
175
- }, {} as any as Err);
182
+ },
183
+ {} as any as Err,
184
+ );
176
185
 
177
186
  return {...prev, isDirty: true, isValid: false, errors: newErrors};
178
187
  },
179
188
  pushFormErrors: (validator) => (prev) => {
180
189
  const pushedErrors = validator(prev.value);
181
- return {...prev, errors: {...prev.errors, ...pushedErrors}};
190
+ const allErrors = {...prev.errors, ...pushedErrors};
191
+ return {...prev, isValid: Object.keys(allErrors).length === 0, errors: allErrors};
182
192
  },
183
193
  reduceFormErrors: (reducer) => (prev) => {
184
194
  return {...prev, errors: reducer(prev.errors)};
@@ -239,13 +249,15 @@ export type Controller<State extends StateRestriction> = {
239
249
  type FormHook<State extends StateRestriction, Schema extends ZodType<any, any, any>> = {
240
250
  controller: Controller<State>;
241
251
 
242
- handleSubmit: (
252
+ /**
253
+ * @param handler handling logic
254
+ * @returns created handler is always return Promise, because of the schema validation run it asynchronously.
255
+ */
256
+ handleSubmit: <R>(
243
257
  handler: (
244
258
  e: BaseSyntheticEvent,
245
- ) => (
246
- val: {success: true; data: zodInfer<Schema>; error: undefined} | {success: false; error: ReadonlyArray<ZodIssue>},
247
- ) => void | Promise<void>,
248
- ) => (e: BaseSyntheticEvent) => void;
259
+ ) => (val: {success: true; data: zodInfer<Schema>; error: undefined} | {success: false; error: ReadonlyArray<ZodIssue>}) => R,
260
+ ) => (e: BaseSyntheticEvent) => Promise<R>;
249
261
 
250
262
  api: Subscriber<FullFormState<State>, FormControllerBehavior<State>>;
251
263
  } & Controller<State>;
@@ -266,12 +278,10 @@ export function createFormHook<StateBeforeValidation extends StateRestriction, S
266
278
  }, []);
267
279
  },
268
280
  useSelector: store.useSelector,
269
- handleSubmit: (
281
+ handleSubmit: <R>(
270
282
  handler: (
271
283
  e: BaseSyntheticEvent,
272
- ) => (
273
- val: {success: true; data: zodInfer<Schema>; error: undefined} | {success: false; error: ReadonlyArray<ZodIssue>},
274
- ) => void | Promise<void>,
284
+ ) => (val: {success: true; data: zodInfer<Schema>; error: undefined} | {success: false; error: ReadonlyArray<ZodIssue>}) => R,
275
285
  ) => {
276
286
  return (e: BaseSyntheticEvent) => {
277
287
  const eventHandled = handler(e);
@@ -282,8 +292,20 @@ export function createFormHook<StateBeforeValidation extends StateRestriction, S
282
292
  // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
283
293
  return eventHandled({success: true, data: result.data, error: undefined});
284
294
  }
285
- void eventHandled({success: false, error: result.error.issues});
286
- store.actions.handleIssues(result.error.issues);
295
+
296
+ const handled: any = eventHandled({success: false, error: result.error.issues});
297
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
298
+ if (typeof handled['then'] === 'function') {
299
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
300
+ return handled.then((x: any) => {
301
+ store.actions.handleIssues(result.error.issues);
302
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-return
303
+ return x;
304
+ }) as R;
305
+ } else {
306
+ store.actions.handleIssues(result.error.issues);
307
+ return handled as R;
308
+ }
287
309
  });
288
310
  };
289
311
  },
package/tsconfig.json CHANGED
@@ -26,8 +26,6 @@
26
26
  "noErrorTruncation": true,
27
27
 
28
28
  "lib": ["dom", "esnext"],
29
- // TODO: node is for vitest... I should isolate it probably?
30
- "types": ["react/next"],
31
29
 
32
30
  "rootDir": "./src",
33
31
  "outDir": "./dist"