remix-validated-form 4.6.12 → 5.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,4 +1,5 @@
1
- import React, { useMemo } from "react";
1
+ import { nanoid } from "nanoid";
2
+ import React, { useMemo, useRef, useState } from "react";
2
3
  import { useCallback } from "react";
3
4
  import invariant from "tiny-invariant";
4
5
  import { InternalFormContextValue } from "../formContext";
@@ -7,8 +8,9 @@ import {
7
8
  useFieldError,
8
9
  useInternalFormContext,
9
10
  useInternalHasBeenSubmitted,
10
- useValidateField,
11
+ useSmartValidate,
11
12
  } from "../hooks";
13
+ import * as arrayUtil from "./arrayUtil";
12
14
  import { useRegisterControlledField } from "./controlledFields";
13
15
  import { useFormStore } from "./storeHooks";
14
16
 
@@ -19,6 +21,19 @@ export type FieldArrayValidationBehaviorOptions = {
19
21
  whenSubmitted: FieldArrayValidationBehavior;
20
22
  };
21
23
 
24
+ export type FieldArrayItem<T> = {
25
+ /**
26
+ * The default value of the item.
27
+ * This does not update as the field is changed by the user.
28
+ */
29
+ defaultValue: T;
30
+ /**
31
+ * A unique key for the item.
32
+ * Use this as the key prop when rendering the item.
33
+ */
34
+ key: string;
35
+ };
36
+
22
37
  const useInternalFieldArray = (
23
38
  context: InternalFormContextValue,
24
39
  field: string,
@@ -27,7 +42,7 @@ const useInternalFieldArray = (
27
42
  const value = useFieldDefaultValue(field, context);
28
43
  useRegisterControlledField(context, field);
29
44
  const hasBeenSubmitted = useInternalHasBeenSubmitted(context.formId);
30
- const validateField = useValidateField(context.formId);
45
+ const validateField = useSmartValidate(context.formId);
31
46
  const error = useFieldError(field, context);
32
47
 
33
48
  const resolvedValidationBehavior: FieldArrayValidationBehaviorOptions = {
@@ -42,7 +57,7 @@ const useInternalFieldArray = (
42
57
 
43
58
  const maybeValidate = useCallback(() => {
44
59
  if (behavior === "onChange") {
45
- validateField(field);
60
+ validateField({ alwaysIncludeErrorsFromFields: [field] });
46
61
  }
47
62
  }, [behavior, field, validateField]);
48
63
 
@@ -56,47 +71,74 @@ const useInternalFieldArray = (
56
71
  (state) => state.controlledFields.array
57
72
  );
58
73
 
74
+ const arrayValue = useMemo<unknown[]>(() => value ?? [], [value]);
75
+ const keyRef = useRef<string[]>([]);
76
+
77
+ // If the lengths don't match up it means one of two things
78
+ // 1. The array has been modified outside of this hook
79
+ // 2. We're initializing the array
80
+ if (keyRef.current.length !== arrayValue.length) {
81
+ keyRef.current = arrayValue.map(() => nanoid());
82
+ }
83
+
59
84
  const helpers = useMemo(
60
85
  () => ({
61
86
  push: (item: any) => {
62
87
  arr.push(field, item);
88
+ keyRef.current.push(nanoid());
63
89
  maybeValidate();
64
90
  },
65
91
  swap: (indexA: number, indexB: number) => {
66
92
  arr.swap(field, indexA, indexB);
93
+ arrayUtil.swap(keyRef.current, indexA, indexB);
67
94
  maybeValidate();
68
95
  },
69
96
  move: (from: number, to: number) => {
70
97
  arr.move(field, from, to);
98
+ arrayUtil.move(keyRef.current, from, to);
71
99
  maybeValidate();
72
100
  },
73
101
  insert: (index: number, value: any) => {
74
102
  arr.insert(field, index, value);
103
+ arrayUtil.insert(keyRef.current, index, nanoid());
75
104
  maybeValidate();
76
105
  },
77
106
  unshift: (value: any) => {
78
107
  arr.unshift(field, value);
108
+ keyRef.current.unshift(nanoid());
79
109
  maybeValidate();
80
110
  },
81
111
  remove: (index: number) => {
82
112
  arr.remove(field, index);
113
+ arrayUtil.remove(keyRef.current, index);
83
114
  maybeValidate();
84
115
  },
85
116
  pop: () => {
86
117
  arr.pop(field);
118
+ keyRef.current.pop();
87
119
  maybeValidate();
88
120
  },
89
121
  replace: (index: number, value: any) => {
90
122
  arr.replace(field, index, value);
123
+ keyRef.current[index] = nanoid();
91
124
  maybeValidate();
92
125
  },
93
126
  }),
94
127
  [arr, field, maybeValidate]
95
128
  );
96
129
 
97
- const arrayValue = useMemo(() => value ?? [], [value]);
98
-
99
- return [arrayValue, helpers, error] as const;
130
+ const valueWithKeys = useMemo(() => {
131
+ const result: { defaultValue: any; key: string }[] = [];
132
+ arrayValue.forEach((item, index) => {
133
+ result[index] = {
134
+ key: keyRef.current[index],
135
+ defaultValue: item,
136
+ };
137
+ });
138
+ return result;
139
+ }, [arrayValue]);
140
+
141
+ return [valueWithKeys, helpers, error] as const;
100
142
  };
101
143
 
102
144
  export type FieldArrayHelpers<Item = any> = {
@@ -122,29 +164,29 @@ export function useFieldArray<Item = any>(
122
164
  const context = useInternalFormContext(formId, "FieldArray");
123
165
 
124
166
  return useInternalFieldArray(context, name, validationBehavior) as [
125
- itemDefaults: Item[],
167
+ items: FieldArrayItem<Item>[],
126
168
  helpers: FieldArrayHelpers,
127
169
  error: string | undefined
128
170
  ];
129
171
  }
130
172
 
131
- export type FieldArrayProps = {
173
+ export type FieldArrayProps<Item> = {
132
174
  name: string;
133
175
  children: (
134
- itemDefaults: any[],
135
- helpers: FieldArrayHelpers,
176
+ items: FieldArrayItem<Item>[],
177
+ helpers: FieldArrayHelpers<Item>,
136
178
  error: string | undefined
137
179
  ) => React.ReactNode;
138
180
  formId?: string;
139
181
  validationBehavior?: FieldArrayValidationBehaviorOptions;
140
182
  };
141
183
 
142
- export const FieldArray = ({
184
+ export function FieldArray<Item = any>({
143
185
  name,
144
186
  children,
145
187
  formId,
146
188
  validationBehavior,
147
- }: FieldArrayProps) => {
189
+ }: FieldArrayProps<Item>) {
148
190
  const context = useInternalFormContext(formId, "FieldArray");
149
191
  const [value, helpers, error] = useInternalFieldArray(
150
192
  context,
@@ -152,4 +194,4 @@ export const FieldArray = ({
152
194
  validationBehavior
153
195
  );
154
196
  return <>{children(value, helpers, error)}</>;
155
- };
197
+ }
package/src/server.ts CHANGED
@@ -42,6 +42,9 @@ export type FormDefaults = {
42
42
  [formDefaultsKey: `${typeof FORM_DEFAULTS_FIELD}_${string}`]: any;
43
43
  };
44
44
 
45
+ // FIXME: Remove after https://github.com/egoist/tsup/issues/813 is fixed
46
+ export type internal_FORM_DEFAULTS_FIELD = typeof FORM_DEFAULTS_FIELD;
47
+
45
48
  export const setFormDefaults = <DataType = any>(
46
49
  formId: string,
47
50
  defaultValues: Partial<DataType>
@@ -11,7 +11,6 @@ import {
11
11
  useTouchedFields,
12
12
  useInternalIsValid,
13
13
  useFieldErrors,
14
- useValidateField,
15
14
  useValidate,
16
15
  useSetFieldErrors,
17
16
  useResetFormElement,
@@ -20,6 +19,7 @@ import {
20
19
  useFormSubactionProp,
21
20
  useSubmitForm,
22
21
  useFormValues,
22
+ useSmartValidate,
23
23
  } from "../internal/hooks";
24
24
  import {
25
25
  FieldErrors,
@@ -133,7 +133,7 @@ export type FormHelpers = {
133
133
  export const useFormHelpers = (formId?: string): FormHelpers => {
134
134
  const formContext = useInternalFormContext(formId, "useFormHelpers");
135
135
  const setTouched = useSetTouched(formContext);
136
- const validateField = useValidateField(formContext.formId);
136
+ const validateField = useSmartValidate(formContext.formId);
137
137
  const validate = useValidate(formContext.formId);
138
138
  const clearError = useClearError(formContext);
139
139
  const setFieldErrors = useSetFieldErrors(formContext.formId);
@@ -143,7 +143,12 @@ export const useFormHelpers = (formId?: string): FormHelpers => {
143
143
  return useMemo(
144
144
  () => ({
145
145
  setTouched,
146
- validateField,
146
+ validateField: async (fieldName: string) => {
147
+ const res = await validateField({
148
+ alwaysIncludeErrorsFromFields: [fieldName],
149
+ });
150
+ return res.error?.fieldErrors[fieldName] ?? null;
151
+ },
147
152
  clearError,
148
153
  validate,
149
154
  clearAllErrors: () => setFieldErrors({}),
@@ -44,7 +44,10 @@ export type Validator<DataType> = {
44
44
  validate: (
45
45
  unvalidatedData: GenericObject
46
46
  ) => Promise<ValidationResult<DataType>>;
47
- validateField: (
47
+ /**
48
+ * @deprecated Will be removed in a future version of remix-validated-form
49
+ */
50
+ validateField?: (
48
51
  unvalidatedData: GenericObject,
49
52
  field: string
50
53
  ) => Promise<ValidateFieldResult>;
package/tsconfig.json CHANGED
@@ -4,5 +4,5 @@
4
4
  "module": "esnext"
5
5
  },
6
6
  "include": ["src/**/*.ts", "src/**/*.tsx"],
7
- "exclude": ["node_modules", "**/*.test.ts", "**/*.test.tsx"]
7
+ "exclude": ["node_modules"]
8
8
  }
@@ -1,330 +0,0 @@
1
- import { anyString, TestFormData } from "@remix-validated-form/test-utils";
2
- import { withYup } from "@remix-validated-form/with-yup/src";
3
- import { withZod } from "@remix-validated-form/with-zod";
4
- import * as R from "remeda";
5
- import { Validator } from "remix-validated-form/src";
6
- import { objectFromPathEntries } from "remix-validated-form/src/internal/flatten";
7
- import { describe, it, expect } from "vitest";
8
- import * as yup from "yup";
9
- import { z } from "zod";
10
- import { FORM_ID_FIELD } from "../internal/constants";
11
-
12
- // If adding an adapter, write a validator that validates this shape
13
- type Person = {
14
- firstName: string;
15
- lastName: string;
16
- age?: number;
17
- address: {
18
- streetAddress: string;
19
- city: string;
20
- country: string;
21
- };
22
- pets?: {
23
- animal: string;
24
- name: string;
25
- }[];
26
- };
27
-
28
- type ValidationTestCase = {
29
- name: string;
30
- validator: Validator<Person>;
31
- };
32
-
33
- const validationTestCases: ValidationTestCase[] = [
34
- {
35
- name: "yup",
36
- validator: withYup(
37
- yup.object({
38
- firstName: yup.string().required(),
39
- lastName: yup.string().required(),
40
- age: yup.number(),
41
- address: yup
42
- .object({
43
- streetAddress: yup.string().required(),
44
- city: yup.string().required(),
45
- country: yup.string().required(),
46
- })
47
- .required(),
48
- pets: yup.array().of(
49
- yup.object({
50
- animal: yup.string().required(),
51
- name: yup.string().required(),
52
- })
53
- ),
54
- })
55
- ),
56
- },
57
- {
58
- name: "zod",
59
- validator: withZod(
60
- z.object({
61
- firstName: z.string().min(1),
62
- lastName: z.string().min(1),
63
- age: z.optional(z.number()),
64
- address: z.preprocess(
65
- (value) => (value == null ? {} : value),
66
- z.object({
67
- streetAddress: z.string().min(1),
68
- city: z.string().min(1),
69
- country: z.string().min(1),
70
- })
71
- ),
72
- pets: z
73
- .object({
74
- animal: z.string().min(1),
75
- name: z.string().min(1),
76
- })
77
- .array()
78
- .optional(),
79
- })
80
- ),
81
- },
82
- ];
83
-
84
- describe("Validation", () => {
85
- describe.each(validationTestCases)("Adapter for $name", ({ validator }) => {
86
- describe("validate", () => {
87
- it("should return the data when valid", async () => {
88
- const person: Person = {
89
- firstName: "John",
90
- lastName: "Doe",
91
- age: 30,
92
- address: {
93
- streetAddress: "123 Main St",
94
- city: "Anytown",
95
- country: "USA",
96
- },
97
- pets: [{ animal: "dog", name: "Fido" }],
98
- };
99
- expect(await validator.validate(person)).toEqual({
100
- data: person,
101
- error: undefined,
102
- submittedData: person,
103
- });
104
- });
105
-
106
- it("should omit internal fields", async () => {
107
- const person: Person = {
108
- firstName: "John",
109
- lastName: "Doe",
110
- age: 30,
111
- address: {
112
- streetAddress: "123 Main St",
113
- city: "Anytown",
114
- country: "USA",
115
- },
116
- pets: [{ animal: "dog", name: "Fido" }],
117
-
118
- // @ts-expect-error
119
- // internal filed technically not part of person type
120
- [FORM_ID_FIELD]: "something",
121
- };
122
- expect(await validator.validate(person)).toEqual({
123
- data: R.omit(person as any, [FORM_ID_FIELD]),
124
- error: undefined,
125
- submittedData: person,
126
- formId: "something",
127
- });
128
- });
129
-
130
- it("should return field errors when invalid", async () => {
131
- const obj = { age: "hi!", pets: [{ animal: "dog" }] };
132
- expect(await validator.validate(obj)).toEqual({
133
- data: undefined,
134
- error: {
135
- fieldErrors: {
136
- firstName: anyString,
137
- lastName: anyString,
138
- age: anyString,
139
- "address.city": anyString,
140
- "address.country": anyString,
141
- "address.streetAddress": anyString,
142
- "pets[0].name": anyString,
143
- },
144
- subaction: undefined,
145
- },
146
- submittedData: obj,
147
- });
148
- });
149
-
150
- it("should unflatten data when validating", async () => {
151
- const data = {
152
- firstName: "John",
153
- lastName: "Doe",
154
- age: 30,
155
- "address.streetAddress": "123 Main St",
156
- "address.city": "Anytown",
157
- "address.country": "USA",
158
- "pets[0].animal": "dog",
159
- "pets[0].name": "Fido",
160
- };
161
- expect(await validator.validate(data)).toEqual({
162
- data: {
163
- firstName: "John",
164
- lastName: "Doe",
165
- age: 30,
166
- address: {
167
- streetAddress: "123 Main St",
168
- city: "Anytown",
169
- country: "USA",
170
- },
171
- pets: [{ animal: "dog", name: "Fido" }],
172
- },
173
- error: undefined,
174
- submittedData: objectFromPathEntries(Object.entries(data)),
175
- });
176
- });
177
-
178
- it("should accept FormData directly and return errors", async () => {
179
- const formData = new TestFormData();
180
- formData.set("firstName", "John");
181
- formData.set("lastName", "Doe");
182
- formData.set("address.streetAddress", "123 Main St");
183
- formData.set("address.country", "USA");
184
- formData.set("pets[0].animal", "dog");
185
-
186
- expect(await validator.validate(formData)).toEqual({
187
- data: undefined,
188
- error: {
189
- fieldErrors: {
190
- "address.city": anyString,
191
- "pets[0].name": anyString,
192
- },
193
- subaction: undefined,
194
- },
195
- submittedData: objectFromPathEntries([...formData.entries()]),
196
- });
197
- });
198
-
199
- it("should accept FormData directly and return valid data", async () => {
200
- const formData = new TestFormData();
201
- formData.set("firstName", "John");
202
- formData.set("lastName", "Doe");
203
- formData.set("address.streetAddress", "123 Main St");
204
- formData.set("address.country", "USA");
205
- formData.set("address.city", "Anytown");
206
- formData.set("pets[0].animal", "dog");
207
- formData.set("pets[0].name", "Fido");
208
-
209
- expect(await validator.validate(formData)).toEqual({
210
- data: {
211
- firstName: "John",
212
- lastName: "Doe",
213
- address: {
214
- streetAddress: "123 Main St",
215
- country: "USA",
216
- city: "Anytown",
217
- },
218
- pets: [{ animal: "dog", name: "Fido" }],
219
- },
220
- error: undefined,
221
- subaction: undefined,
222
- submittedData: objectFromPathEntries([...formData.entries()]),
223
- });
224
- });
225
-
226
- it("should return the subaction in the ValidatorError if there is one", async () => {
227
- const person = {
228
- lastName: "Doe",
229
- age: 20,
230
- address: {
231
- streetAddress: "123 Main St",
232
- city: "Anytown",
233
- country: "USA",
234
- },
235
- pets: [{ animal: "dog", name: "Fido" }],
236
- subaction: "updatePerson",
237
- };
238
- expect(await validator.validate(person)).toEqual({
239
- error: {
240
- fieldErrors: {
241
- firstName: anyString,
242
- },
243
- subaction: "updatePerson",
244
- },
245
- data: undefined,
246
- submittedData: person,
247
- });
248
- });
249
- });
250
-
251
- describe("validateField", () => {
252
- it("should not return an error if field is valid", async () => {
253
- const person = {
254
- firstName: "John",
255
- lastName: {}, // invalid, but we should only be validating firstName
256
- };
257
- expect(await validator.validateField(person, "firstName")).toEqual({
258
- error: undefined,
259
- });
260
- });
261
- it("should not return an error if a nested field is valid", async () => {
262
- const person = {
263
- firstName: "John",
264
- lastName: {}, // invalid, but we should only be validating firstName
265
- address: {
266
- streetAddress: "123 Main St",
267
- city: "Anytown",
268
- country: "USA",
269
- },
270
- pets: [{ animal: "dog", name: "Fido" }],
271
- };
272
- expect(
273
- await validator.validateField(person, "address.streetAddress")
274
- ).toEqual({
275
- error: undefined,
276
- });
277
- expect(await validator.validateField(person, "address.city")).toEqual({
278
- error: undefined,
279
- });
280
- expect(
281
- await validator.validateField(person, "address.country")
282
- ).toEqual({
283
- error: undefined,
284
- });
285
- expect(await validator.validateField(person, "pets[0].animal")).toEqual(
286
- {
287
- error: undefined,
288
- }
289
- );
290
- expect(await validator.validateField(person, "pets[0].name")).toEqual({
291
- error: undefined,
292
- });
293
- });
294
-
295
- it("should return an error if field is invalid", async () => {
296
- const person = {
297
- firstName: "John",
298
- lastName: {},
299
- address: {
300
- streetAddress: "123 Main St",
301
- city: 1234,
302
- },
303
- };
304
- expect(await validator.validateField(person, "lastName")).toEqual({
305
- error: anyString,
306
- });
307
- });
308
-
309
- it("should return an error if a nested field is invalid", async () => {
310
- const person = {
311
- firstName: "John",
312
- lastName: {},
313
- address: {
314
- streetAddress: "123 Main St",
315
- city: 1234,
316
- },
317
- pets: [{ animal: "dog" }],
318
- };
319
- expect(
320
- await validator.validateField(person, "address.country")
321
- ).toEqual({
322
- error: anyString,
323
- });
324
- expect(await validator.validateField(person, "pets[0].name")).toEqual({
325
- error: anyString,
326
- });
327
- });
328
- });
329
- });
330
- });