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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "remix-validated-form",
3
- "version": "4.6.12",
3
+ "version": "5.0.1",
4
4
  "description": "Form component and utils for easy form validation in remix",
5
5
  "main": "./dist/index.cjs.js",
6
6
  "module": "./dist/index.esm.js",
@@ -33,14 +33,14 @@
33
33
  "validation"
34
34
  ],
35
35
  "peerDependencies": {
36
- "@remix-run/react": "1.x",
36
+ "@remix-run/react": ">= 1.15.0",
37
37
  "@remix-run/server-runtime": "1.x",
38
38
  "react": "^17.0.2 || ^18.0.0"
39
39
  },
40
40
  "devDependencies": {
41
- "@remix-run/node": "^1.9.0",
42
- "@remix-run/react": "^1.9.0",
43
- "@remix-run/server-runtime": "^1.9.0",
41
+ "@remix-run/node": "^1.16.1",
42
+ "@remix-run/react": "^1.16.1",
43
+ "@remix-run/server-runtime": "^1.16.1",
44
44
  "@testing-library/react": "^13.3.0",
45
45
  "@types/lodash.get": "^4.4.7",
46
46
  "@types/react": "^18.0.9",
@@ -55,6 +55,7 @@
55
55
  "dependencies": {
56
56
  "immer": "^9.0.12",
57
57
  "lodash.get": "^4.4.2",
58
+ "nanoid": "3.3.6",
58
59
  "remeda": "^1.2.0",
59
60
  "tiny-invariant": "^1.2.0",
60
61
  "zustand": "^4.3.0"
@@ -3,6 +3,8 @@ import {
3
3
  Form as RemixForm,
4
4
  FormMethod,
5
5
  useSubmit,
6
+ SubmitOptions,
7
+ FormEncType,
6
8
  } from "@remix-run/react";
7
9
  import React, {
8
10
  ComponentProps,
@@ -41,7 +43,24 @@ import {
41
43
  } from "./internal/util";
42
44
  import { FieldErrors, Validator } from "./validation/types";
43
45
 
44
- export type FormProps<DataType> = {
46
+ type SubactionData<
47
+ DataType,
48
+ Subaction extends string | undefined
49
+ > = DataType & { subaction: Subaction };
50
+
51
+ // Not all validation libraries support encoding a literal value in the schema type (e.g. yup).
52
+ // This condition here allows us to provide strictness for users who are using a validation library that does support it,
53
+ // but also allows us to support users who are using a validation library that doesn't support it.
54
+ type DataForSubaction<
55
+ DataType,
56
+ Subaction extends string | undefined
57
+ > = Subaction extends string // Not all validation libraries support encoding a literal value in the schema type.
58
+ ? SubactionData<DataType, Subaction> extends undefined
59
+ ? DataType
60
+ : SubactionData<DataType, Subaction>
61
+ : DataType;
62
+
63
+ export type FormProps<DataType, Subaction extends string | undefined> = {
45
64
  /**
46
65
  * A `Validator` object that describes how to validate the form.
47
66
  */
@@ -51,7 +70,7 @@ export type FormProps<DataType> = {
51
70
  * after all validations have been run.
52
71
  */
53
72
  onSubmit?: (
54
- data: DataType,
73
+ data: DataForSubaction<DataType, Subaction>,
55
74
  event: React.FormEvent<HTMLFormElement>
56
75
  ) => void | Promise<void>;
57
76
  /**
@@ -64,7 +83,7 @@ export type FormProps<DataType> = {
64
83
  * Accepts an object of default values for the form
65
84
  * that will automatically be propagated to the form fields via `useField`.
66
85
  */
67
- defaultValues?: Partial<DataType>;
86
+ defaultValues?: Partial<DataForSubaction<DataType, Subaction>>;
68
87
  /**
69
88
  * A ref to the form element.
70
89
  */
@@ -74,7 +93,7 @@ export type FormProps<DataType> = {
74
93
  * Setting a value here will cause the form to be submitted with an extra `subaction` value.
75
94
  * This can be useful when there are multiple forms on the screen handled by the same action.
76
95
  */
77
- subaction?: string;
96
+ subaction?: Subaction;
78
97
  /**
79
98
  * Reset the form to the default values after the form has been successfully submitted.
80
99
  * This is useful if you want to submit the same form multiple times,
@@ -202,7 +221,7 @@ type HTMLFormSubmitter = HTMLButtonElement | HTMLInputElement;
202
221
  /**
203
222
  * The primary form component of `remix-validated-form`.
204
223
  */
205
- export function ValidatedForm<DataType>({
224
+ export function ValidatedForm<DataType, Subaction extends string | undefined>({
206
225
  validator,
207
226
  onSubmit,
208
227
  children,
@@ -217,8 +236,11 @@ export function ValidatedForm<DataType>({
217
236
  method,
218
237
  replace,
219
238
  id,
239
+ preventScrollReset,
240
+ relative,
241
+ encType,
220
242
  ...rest
221
- }: FormProps<DataType>) {
243
+ }: FormProps<DataType, Subaction>) {
222
244
  const formId = useFormId(id);
223
245
  const providedDefaultValues = useDeepEqualsMemo(unMemoizedDefaults);
224
246
  const contextValue = useMemo<InternalFormContextValue>(
@@ -321,12 +343,12 @@ export function ValidatedForm<DataType>({
321
343
  startSubmit();
322
344
  const submitter = nativeEvent.submitter as HTMLFormSubmitter | null;
323
345
  const formMethod = (submitter?.formMethod as FormMethod) || method;
324
- const formDataToValidate = getDataFromForm(target);
346
+ const formData = getDataFromForm(target);
325
347
  if (submitter?.name) {
326
- formDataToValidate.append(submitter.name, submitter.value);
348
+ formData.append(submitter.name, submitter.value);
327
349
  }
328
350
 
329
- const result = await validator.validate(formDataToValidate);
351
+ const result = await validator.validate(formData);
330
352
  if (result.error) {
331
353
  setFieldErrors(result.error.fieldErrors);
332
354
  endSubmit();
@@ -340,23 +362,28 @@ export function ValidatedForm<DataType>({
340
362
  } else {
341
363
  setFieldErrors({});
342
364
  const eventProxy = formEventProxy(e);
343
- await onSubmit?.(result.data, eventProxy);
365
+ await onSubmit?.(result.data as any, eventProxy);
344
366
  if (eventProxy.defaultPrevented) {
345
367
  endSubmit();
346
368
  return;
347
369
  }
348
370
 
371
+ const opts: SubmitOptions = {
372
+ method: formMethod,
373
+ replace,
374
+ preventScrollReset,
375
+ relative,
376
+ action,
377
+ encType: encType as FormEncType | undefined,
378
+ };
379
+
349
380
  // We deviate from the Remix code here a bit because of our async submit.
350
381
  // In Remix's `FormImpl`, they use `event.currentTarget` to get the form,
351
382
  // but we already have the form in `formRef.current` so we can just use that.
352
383
  // If we use `event.currentTarget` here, it will break because `currentTarget`
353
384
  // will have changed since the start of the submission.
354
- if (fetcher) fetcher.submit(submitter || target, { method: formMethod });
355
- else
356
- submit(submitter || target, {
357
- replace,
358
- method: formMethod,
359
- });
385
+ if (fetcher) fetcher.submit(formData, opts);
386
+ else submit(formData, opts);
360
387
  }
361
388
  };
362
389
 
@@ -367,7 +394,10 @@ export function ValidatedForm<DataType>({
367
394
  id={id}
368
395
  action={action}
369
396
  method={method}
397
+ encType={encType}
370
398
  replace={replace}
399
+ preventScrollReset={preventScrollReset}
400
+ relative={relative}
371
401
  onSubmit={(e) => {
372
402
  e.preventDefault();
373
403
  handleSubmit(
package/src/hooks.ts CHANGED
@@ -13,8 +13,8 @@ import {
13
13
  useInternalIsSubmitting,
14
14
  useInternalIsValid,
15
15
  useInternalHasBeenSubmitted,
16
- useValidateField,
17
16
  useRegisterReceiveFocus,
17
+ useSmartValidate,
18
18
  } from "./internal/hooks";
19
19
  import {
20
20
  useControllableValue,
@@ -23,7 +23,7 @@ import {
23
23
 
24
24
  /**
25
25
  * Returns whether or not the parent form is currently being submitted.
26
- * This is different from Remix's `useTransition().submission` in that it
26
+ * This is different from Remix's `useNavigation()` in that it
27
27
  * is aware of what form it's in and when _that_ form is being submitted.
28
28
  *
29
29
  * @param formId
@@ -106,7 +106,7 @@ export const useField = (
106
106
  const clearError = useClearError(formContext);
107
107
 
108
108
  const hasBeenSubmitted = useInternalHasBeenSubmitted(formContext.formId);
109
- const validateField = useValidateField(formContext.formId);
109
+ const smartValidate = useSmartValidate(formContext.formId);
110
110
  const registerReceiveFocus = useRegisterReceiveFocus(formContext.formId);
111
111
 
112
112
  useEffect(() => {
@@ -118,9 +118,7 @@ export const useField = (
118
118
  const helpers = {
119
119
  error,
120
120
  clearError: () => clearError(name),
121
- validate: () => {
122
- validateField(name);
123
- },
121
+ validate: () => smartValidate({ alwaysIncludeErrorsFromFields: [name] }),
124
122
  defaultValue,
125
123
  touched,
126
124
  setTouched,
@@ -144,7 +142,7 @@ export const useField = (
144
142
  name,
145
143
  hasBeenSubmitted,
146
144
  options?.validationBehavior,
147
- validateField,
145
+ smartValidate,
148
146
  ]);
149
147
 
150
148
  return field;
@@ -1,4 +1,4 @@
1
- import { useActionData, useMatches, useTransition } from "@remix-run/react";
1
+ import { useActionData, useMatches, useNavigation } from "@remix-run/react";
2
2
  import { useCallback, useContext } from "react";
3
3
  import { getPath } from "set-get";
4
4
  import invariant from "tiny-invariant";
@@ -107,10 +107,10 @@ export const useDefaultValuesForForm = (
107
107
  export const useHasActiveFormSubmit = ({
108
108
  fetcher,
109
109
  }: InternalFormContextValue): boolean => {
110
- const transition = useTransition();
110
+ let navigation = useNavigation();
111
111
  const hasActiveSubmission = fetcher
112
112
  ? fetcher.state === "submitting"
113
- : !!transition.submission;
113
+ : navigation.state === "submitting";
114
114
  return hasActiveSubmission;
115
115
  };
116
116
 
@@ -169,8 +169,8 @@ export const useInternalIsValid = (formId: InternalFormId) =>
169
169
  export const useInternalHasBeenSubmitted = (formId: InternalFormId) =>
170
170
  useFormStore(formId, (state) => state.hasBeenSubmitted);
171
171
 
172
- export const useValidateField = (formId: InternalFormId) =>
173
- useFormStore(formId, (state) => state.validateField);
172
+ export const useSmartValidate = (formId: InternalFormId) =>
173
+ useFormStore(formId, (state) => state.smartValidate);
174
174
 
175
175
  export const useValidate = (formId: InternalFormId) =>
176
176
  useFormStore(formId, (state) => state.validate);
@@ -20,6 +20,8 @@ export const getArray = (values: any, field: string): unknown[] => {
20
20
  return value;
21
21
  };
22
22
 
23
+ export const sparseCopy = <T>(array: T[]): T[] => array.slice();
24
+
23
25
  export const swap = (array: unknown[], indexA: number, indexB: number) => {
24
26
  const itemA = array[indexA];
25
27
  const itemB = array[indexB];
@@ -46,7 +48,7 @@ export const swap = (array: unknown[], indexA: number, indexB: number) => {
46
48
  function sparseSplice(
47
49
  array: unknown[],
48
50
  start: number,
49
- deleteCount: number,
51
+ deleteCount?: number,
50
52
  item?: unknown
51
53
  ) {
52
54
  // Inserting an item into an array won't behave as we need it to if the array isn't
@@ -56,8 +58,9 @@ function sparseSplice(
56
58
  }
57
59
 
58
60
  // If we just pass item in, it'll be undefined and splice will delete the item.
59
- if (arguments.length === 4) return array.splice(start, deleteCount, item);
60
- return array.splice(start, deleteCount);
61
+ if (arguments.length === 4) return array.splice(start, deleteCount!, item);
62
+ else if (arguments.length === 3) return array.splice(start, deleteCount);
63
+ return array.splice(start);
61
64
  }
62
65
 
63
66
  export const move = (array: unknown[], from: number, to: number) => {
@@ -69,6 +72,13 @@ export const insert = (array: unknown[], index: number, value: unknown) => {
69
72
  sparseSplice(array, index, 0, value);
70
73
  };
71
74
 
75
+ export const insertEmpty = (array: unknown[], index: number) => {
76
+ const tail = sparseSplice(array, index);
77
+ tail.forEach((item, i) => {
78
+ sparseSplice(array, index + i + 1, 0, item);
79
+ });
80
+ };
81
+
72
82
  export const remove = (array: unknown[], index: number) => {
73
83
  sparseSplice(array, index, 1);
74
84
  };
@@ -240,6 +250,34 @@ if (import.meta.vitest) {
240
250
  });
241
251
  });
242
252
 
253
+ describe("insertEmpty", () => {
254
+ it("should insert an empty item at a given index", () => {
255
+ const array = [1, 2, 3];
256
+ insertEmpty(array, 1);
257
+ // eslint-disable-next-line no-sparse-arrays
258
+ expect(array).toStrictEqual([1, , 2, 3]);
259
+ expect(array).not.toStrictEqual([1, undefined, 2, 3]);
260
+ });
261
+
262
+ it("should work with already sparse arrays", () => {
263
+ // eslint-disable-next-line no-sparse-arrays
264
+ const array = [, , 1, , 2, , 3];
265
+ insertEmpty(array, 3);
266
+ // eslint-disable-next-line no-sparse-arrays
267
+ expect(array).toStrictEqual([, , 1, , , 2, , 3]);
268
+ expect(array).not.toStrictEqual([
269
+ undefined,
270
+ undefined,
271
+ 1,
272
+ undefined,
273
+ undefined,
274
+ 2,
275
+ undefined,
276
+ 3,
277
+ ]);
278
+ });
279
+ });
280
+
243
281
  describe("remove", () => {
244
282
  it("should remove an item at a given index", () => {
245
283
  const array = [1, 2, 3];
@@ -22,6 +22,10 @@ export type SyncedFormProps = {
22
22
  validator: Validator<unknown>;
23
23
  };
24
24
 
25
+ export type SmartValidateOpts = {
26
+ alwaysIncludeErrorsFromFields?: string[];
27
+ };
28
+
25
29
  export type FormStoreState = {
26
30
  forms: { [formId: InternalFormId]: FormState };
27
31
  form: (formId: InternalFormId) => FormState;
@@ -49,8 +53,10 @@ export type FormState = {
49
53
  reset: () => void;
50
54
  syncFormProps: (props: SyncedFormProps) => void;
51
55
  setFormElement: (formElement: HTMLFormElement | null) => void;
52
- validateField: (fieldName: string) => Promise<string | null>;
53
56
  validate: () => Promise<ValidationResult<unknown>>;
57
+ smartValidate: (
58
+ opts?: SmartValidateOpts
59
+ ) => Promise<ValidationResult<unknown>>;
54
60
  resetFormElement: () => void;
55
61
  submit: () => void;
56
62
  getValues: () => FormData;
@@ -101,12 +107,15 @@ const defaultFormState: FormState = {
101
107
  reset: () => noOp,
102
108
  syncFormProps: noOp,
103
109
  setFormElement: noOp,
104
- validateField: async () => null,
105
110
 
106
111
  validate: async () => {
107
112
  throw new Error("Validate called before form was initialized.");
108
113
  },
109
114
 
115
+ smartValidate: async () => {
116
+ throw new Error("Validate called before form was initialized.");
117
+ },
118
+
110
119
  submit: async () => {
111
120
  throw new Error("Submit called before form was initialized.");
112
121
  },
@@ -210,7 +219,7 @@ const createFormState = (
210
219
  state.formElement = formElement as any;
211
220
  });
212
221
  },
213
- validateField: async (field: string) => {
222
+ validate: async () => {
214
223
  const formElement = get().formElement;
215
224
  invariant(
216
225
  formElement,
@@ -220,26 +229,15 @@ const createFormState = (
220
229
  const validator = get().formProps?.validator;
221
230
  invariant(
222
231
  validator,
223
- "Cannot validator. This is probably a bug in remix-validated-form."
232
+ "Cannot find validator. This is probably a bug in remix-validated-form."
224
233
  );
225
234
 
226
- await get().controlledFields.awaitValueUpdate?.(field);
227
-
228
- const { error } = await validator.validateField(
229
- new FormData(formElement),
230
- field
231
- );
232
-
233
- if (error) {
234
- get().setFieldError(field, error);
235
- return error;
236
- } else {
237
- get().clearFieldError(field);
238
- return null;
239
- }
235
+ const result = await validator.validate(new FormData(formElement));
236
+ if (result.error) get().setFieldErrors(result.error.fieldErrors);
237
+ return result;
240
238
  },
241
239
 
242
- validate: async () => {
240
+ smartValidate: async ({ alwaysIncludeErrorsFromFields = [] } = {}) => {
243
241
  const formElement = get().formElement;
244
242
  invariant(
245
243
  formElement,
@@ -249,12 +247,91 @@ const createFormState = (
249
247
  const validator = get().formProps?.validator;
250
248
  invariant(
251
249
  validator,
252
- "Cannot validator. This is probably a bug in remix-validated-form."
250
+ "Cannot find validator. This is probably a bug in remix-validated-form."
253
251
  );
254
252
 
255
- const result = await validator.validate(new FormData(formElement));
256
- if (result.error) get().setFieldErrors(result.error.fieldErrors);
257
- return result;
253
+ await Promise.all(
254
+ alwaysIncludeErrorsFromFields.map((field) =>
255
+ get().controlledFields.awaitValueUpdate?.(field)
256
+ )
257
+ );
258
+
259
+ const validationResult = await validator.validate(
260
+ new FormData(formElement)
261
+ );
262
+ if (!validationResult.error) {
263
+ // Only update the field errors if it hasn't changed
264
+ const hadErrors = Object.keys(get().fieldErrors).length > 0;
265
+ if (hadErrors) get().setFieldErrors({});
266
+ return validationResult;
267
+ }
268
+
269
+ const {
270
+ error: { fieldErrors },
271
+ } = validationResult;
272
+ const errorFields = new Set<string>();
273
+ const incomingErrors = new Set<string>();
274
+ const prevErrors = new Set<string>();
275
+
276
+ Object.keys(fieldErrors).forEach((field) => {
277
+ errorFields.add(field);
278
+ incomingErrors.add(field);
279
+ });
280
+
281
+ Object.keys(get().fieldErrors).forEach((field) => {
282
+ errorFields.add(field);
283
+ prevErrors.add(field);
284
+ });
285
+
286
+ const fieldsToUpdate = new Set<string>();
287
+ const fieldsToDelete = new Set<string>();
288
+
289
+ errorFields.forEach((field) => {
290
+ // If an error has been cleared, remove it.
291
+ if (!incomingErrors.has(field)) {
292
+ fieldsToDelete.add(field);
293
+ return;
294
+ }
295
+
296
+ // If an error has changed, we should update it.
297
+ if (prevErrors.has(field) && incomingErrors.has(field)) {
298
+ // Only update if the error has changed to avoid unnecessary rerenders
299
+ if (fieldErrors[field] !== get().fieldErrors[field])
300
+ fieldsToUpdate.add(field);
301
+ return;
302
+ }
303
+
304
+ // If the error is always included, then we should update it.
305
+ if (alwaysIncludeErrorsFromFields.includes(field)) {
306
+ fieldsToUpdate.add(field);
307
+ return;
308
+ }
309
+
310
+ // If the error is new, then only update if the field has been touched
311
+ // or if the form has been submitted
312
+ if (!prevErrors.has(field)) {
313
+ const fieldTouched = get().touchedFields[field];
314
+ const formHasBeenSubmitted = get().hasBeenSubmitted;
315
+ if (fieldTouched || formHasBeenSubmitted) fieldsToUpdate.add(field);
316
+ return;
317
+ }
318
+ });
319
+
320
+ if (fieldsToDelete.size === 0 && fieldsToUpdate.size === 0) {
321
+ return { ...validationResult, error: { fieldErrors: get().fieldErrors } };
322
+ }
323
+
324
+ set((state) => {
325
+ fieldsToDelete.forEach((field) => {
326
+ delete state.fieldErrors[field];
327
+ });
328
+
329
+ fieldsToUpdate.forEach((field) => {
330
+ state.fieldErrors[field] = fieldErrors[field];
331
+ });
332
+ });
333
+
334
+ return { ...validationResult, error: { fieldErrors: get().fieldErrors } };
258
335
  },
259
336
 
260
337
  submit: () => {
@@ -409,10 +486,10 @@ const createFormState = (
409
486
  );
410
487
  // Even though this is a new item, we need to push around other items.
411
488
  arrayUtil.mutateAsArray(fieldName, state.touchedFields, (array) =>
412
- arrayUtil.insert(array, index, false)
489
+ arrayUtil.insertEmpty(array, index)
413
490
  );
414
491
  arrayUtil.mutateAsArray(fieldName, state.fieldErrors, (array) =>
415
- arrayUtil.insert(array, index, undefined)
492
+ arrayUtil.insertEmpty(array, index)
416
493
  );
417
494
  });
418
495
  get().controlledFields.kickoffValueUpdate(fieldName);
@@ -458,10 +535,10 @@ const createFormState = (
458
535
  .getArray(state.currentDefaultValues, fieldName)
459
536
  .unshift(value);
460
537
  arrayUtil.mutateAsArray(fieldName, state.touchedFields, (array) =>
461
- array.unshift(false)
538
+ arrayUtil.insertEmpty(array, 0)
462
539
  );
463
540
  arrayUtil.mutateAsArray(fieldName, state.fieldErrors, (array) =>
464
- array.unshift(undefined)
541
+ arrayUtil.insertEmpty(array, 0)
465
542
  );
466
543
  });
467
544
  },