remix-validated-form 3.4.2 → 4.0.0-beta.0

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.
@@ -3,8 +3,10 @@ import {
3
3
  useActionData,
4
4
  useFetcher,
5
5
  useFormAction,
6
+ useSubmit,
6
7
  useTransition,
7
8
  } from "@remix-run/react";
9
+ import uniq from "lodash/uniq";
8
10
  import React, {
9
11
  ComponentProps,
10
12
  useEffect,
@@ -20,8 +22,8 @@ import { omit, mergeRefs } from "./internal/util";
20
22
  import {
21
23
  FieldErrors,
22
24
  Validator,
23
- FieldErrorsWithData,
24
25
  TouchedFields,
26
+ ValidationErrorResponseData,
25
27
  } from "./validation/types";
26
28
 
27
29
  export type FormProps<DataType> = {
@@ -33,7 +35,10 @@ export type FormProps<DataType> = {
33
35
  * A submit callback that gets called when the form is submitted
34
36
  * after all validations have been run.
35
37
  */
36
- onSubmit?: (data: DataType, event: React.FormEvent<HTMLFormElement>) => void;
38
+ onSubmit?: (
39
+ data: DataType,
40
+ event: React.FormEvent<HTMLFormElement>
41
+ ) => Promise<void>;
37
42
  /**
38
43
  * Allows you to provide a `fetcher` from remix's `useFetcher` hook.
39
44
  * The form will use the fetcher for loading states, action data, etc
@@ -68,25 +73,27 @@ export type FormProps<DataType> = {
68
73
  disableFocusOnError?: boolean;
69
74
  } & Omit<ComponentProps<typeof RemixForm>, "onSubmit">;
70
75
 
71
- function useFieldErrorsFromBackend(
76
+ function useErrorResponseForThisForm(
72
77
  fetcher?: ReturnType<typeof useFetcher>,
73
78
  subaction?: string
74
- ): FieldErrorsWithData | null {
79
+ ): ValidationErrorResponseData | null {
75
80
  const actionData = useActionData<any>();
76
- if (fetcher) return (fetcher.data as any)?.fieldErrors;
77
- if (!actionData) return null;
78
- if (actionData.fieldErrors) {
79
- const submittedData = actionData.fieldErrors?._submittedData;
80
- const subactionsMatch = subaction
81
- ? subaction === submittedData?.subaction
82
- : !submittedData?.subaction;
83
- return subactionsMatch ? actionData.fieldErrors : null;
81
+ if (fetcher) {
82
+ if ((fetcher.data as any)?.fieldErrors) return fetcher.data as any;
83
+ return null;
84
84
  }
85
+
86
+ if (!actionData?.fieldErrors) return null;
87
+ if (
88
+ (!subaction && !actionData.subaction) ||
89
+ actionData.subaction === subaction
90
+ )
91
+ return actionData;
85
92
  return null;
86
93
  }
87
94
 
88
95
  function useFieldErrors(
89
- fieldErrorsFromBackend?: any
96
+ fieldErrorsFromBackend?: FieldErrors
90
97
  ): [FieldErrors, React.Dispatch<React.SetStateAction<FieldErrors>>] {
91
98
  const [fieldErrors, setFieldErrors] = useState<FieldErrors>(
92
99
  fieldErrorsFromBackend ?? {}
@@ -136,11 +143,14 @@ const getDataFromForm = (el: HTMLFormElement) => new FormData(el);
136
143
  * and only if JS is disabled.
137
144
  */
138
145
  function useDefaultValues<DataType>(
139
- fieldErrors?: FieldErrorsWithData | null,
146
+ repopulateFieldsFromBackend?: any,
140
147
  defaultValues?: Partial<DataType>
141
148
  ) {
142
- const defaultsFromValidationError = fieldErrors?._submittedData;
143
- return defaultsFromValidationError ?? defaultValues;
149
+ return repopulateFieldsFromBackend ?? defaultValues;
150
+ }
151
+
152
+ function nonNull<T>(value: T | null | undefined): value is T {
153
+ return value !== null;
144
154
  }
145
155
 
146
156
  const focusFirstInvalidInput = (
@@ -148,28 +158,48 @@ const focusFirstInvalidInput = (
148
158
  customFocusHandlers: MultiValueMap<string, () => void>,
149
159
  formElement: HTMLFormElement
150
160
  ) => {
151
- const invalidInputSelector = Object.keys(fieldErrors)
152
- .map((fieldName) => `input[name="${fieldName}"]`)
153
- .join(",");
154
- const invalidInputs = formElement.querySelectorAll(invalidInputSelector);
155
- for (const element of invalidInputs) {
156
- const input = element as HTMLInputElement;
161
+ const namesInOrder = [...formElement.elements]
162
+ .map((el) => {
163
+ const input = el instanceof RadioNodeList ? el[0] : el;
164
+ if (input instanceof HTMLInputElement) return input.name;
165
+ return null;
166
+ })
167
+ .filter(nonNull)
168
+ .filter((name) => name in fieldErrors);
169
+ const uniqueNamesInOrder = uniq(namesInOrder);
157
170
 
158
- if (customFocusHandlers.has(input.name)) {
159
- customFocusHandlers.getAll(input.name).forEach((handler) => {
171
+ for (const fieldName of uniqueNamesInOrder) {
172
+ if (customFocusHandlers.has(fieldName)) {
173
+ customFocusHandlers.getAll(fieldName).forEach((handler) => {
160
174
  handler();
161
175
  });
162
176
  break;
163
177
  }
164
178
 
165
- // We don't filter these out ahead of time because
166
- // they could have a custom focus handler
167
- if (input.type === "hidden") {
168
- continue;
179
+ const elem = formElement.elements.namedItem(fieldName);
180
+ if (!elem) continue;
181
+
182
+ if (elem instanceof RadioNodeList) {
183
+ const selectedRadio =
184
+ [...elem]
185
+ .filter(
186
+ (item): item is HTMLInputElement => item instanceof HTMLInputElement
187
+ )
188
+ .find((item) => item.value === elem.value) ?? elem[0];
189
+ if (selectedRadio && selectedRadio instanceof HTMLInputElement) {
190
+ selectedRadio.focus();
191
+ break;
192
+ }
169
193
  }
170
194
 
171
- input.focus();
172
- break;
195
+ if (elem instanceof HTMLInputElement) {
196
+ if (elem.type === "hidden") {
197
+ continue;
198
+ }
199
+
200
+ elem.focus();
201
+ break;
202
+ }
173
203
  }
174
204
  };
175
205
 
@@ -190,15 +220,21 @@ export function ValidatedForm<DataType>({
190
220
  disableFocusOnError,
191
221
  ...rest
192
222
  }: FormProps<DataType>) {
193
- const fieldErrorsFromBackend = useFieldErrorsFromBackend(fetcher, subaction);
194
- const [fieldErrors, setFieldErrors] = useFieldErrors(fieldErrorsFromBackend);
223
+ const backendError = useErrorResponseForThisForm(fetcher, subaction);
224
+ const [fieldErrors, setFieldErrors] = useFieldErrors(
225
+ backendError?.fieldErrors
226
+ );
195
227
  const isSubmitting = useIsSubmitting(action, subaction, fetcher);
196
- const defaultsToUse = useDefaultValues(fieldErrorsFromBackend, defaultValues);
228
+ const defaultsToUse = useDefaultValues(
229
+ backendError?.repopulateFields,
230
+ defaultValues
231
+ );
197
232
  const [touchedFields, setTouchedFields] = useState<TouchedFields>({});
198
233
  const [hasBeenSubmitted, setHasBeenSubmitted] = useState(false);
234
+ const submit = useSubmit();
199
235
  const formRef = useRef<HTMLFormElement>(null);
200
236
  useSubmitComplete(isSubmitting, () => {
201
- if (!fieldErrorsFromBackend && resetAfterSubmit) {
237
+ if (!backendError && resetAfterSubmit) {
202
238
  formRef.current?.reset();
203
239
  }
204
240
  });
@@ -220,9 +256,9 @@ export function ValidatedForm<DataType>({
220
256
  clearError: (fieldName) => {
221
257
  setFieldErrors((prev) => omit(prev, fieldName));
222
258
  },
223
- validateField: (fieldName) => {
259
+ validateField: async (fieldName) => {
224
260
  invariant(formRef.current, "Cannot find reference to form");
225
- const { error } = validator.validateField(
261
+ const { error } = await validator.validateField(
226
262
  getDataFromForm(formRef.current),
227
263
  fieldName as any
228
264
  );
@@ -237,11 +273,13 @@ export function ValidatedForm<DataType>({
237
273
  [fieldName]: error,
238
274
  };
239
275
  });
276
+ return error;
240
277
  } else {
241
278
  setFieldErrors((prev) => {
242
279
  if (!(fieldName in prev)) return prev;
243
280
  return omit(prev, fieldName);
244
281
  });
282
+ return null;
245
283
  }
246
284
  },
247
285
  registerReceiveFocus: (fieldName, handler) => {
@@ -267,26 +305,58 @@ export function ValidatedForm<DataType>({
267
305
 
268
306
  const Form = fetcher?.Form ?? RemixForm;
269
307
 
308
+ let clickedButtonRef = React.useRef<any>();
309
+ useEffect(() => {
310
+ let form = formRef.current;
311
+ if (!form) return;
312
+
313
+ function handleClick(event: MouseEvent) {
314
+ if (!(event.target instanceof HTMLElement)) return;
315
+ let submitButton = event.target.closest<
316
+ HTMLButtonElement | HTMLInputElement
317
+ >("button,input[type=submit]");
318
+
319
+ if (
320
+ submitButton &&
321
+ submitButton.form === form &&
322
+ submitButton.type === "submit"
323
+ ) {
324
+ clickedButtonRef.current = submitButton;
325
+ }
326
+ }
327
+
328
+ window.addEventListener("click", handleClick);
329
+ return () => {
330
+ window.removeEventListener("click", handleClick);
331
+ };
332
+ }, []);
333
+
270
334
  return (
271
335
  <Form
272
336
  ref={mergeRefs([formRef, formRefProp])}
273
337
  {...rest}
274
338
  action={action}
275
- onSubmit={(event) => {
339
+ onSubmit={async (e) => {
340
+ e.preventDefault();
276
341
  setHasBeenSubmitted(true);
277
- const result = validator.validate(getDataFromForm(event.currentTarget));
342
+ const result = await validator.validate(
343
+ getDataFromForm(e.currentTarget)
344
+ );
278
345
  if (result.error) {
279
- event.preventDefault();
280
- setFieldErrors(result.error);
346
+ setFieldErrors(result.error.fieldErrors);
281
347
  if (!disableFocusOnError) {
282
348
  focusFirstInvalidInput(
283
- result.error,
349
+ result.error.fieldErrors,
284
350
  customFocusHandlers(),
285
351
  formRef.current!
286
352
  );
287
353
  }
288
354
  } else {
289
- onSubmit?.(result.data, event);
355
+ onSubmit && onSubmit(result.data, e);
356
+ if (fetcher)
357
+ fetcher.submit(clickedButtonRef.current || e.currentTarget);
358
+ else submit(clickedButtonRef.current || e.currentTarget);
359
+ clickedButtonRef.current = null;
290
360
  }
291
361
  }}
292
362
  onReset={(event) => {
package/src/hooks.ts CHANGED
@@ -1,12 +1,22 @@
1
1
  import get from "lodash/get";
2
2
  import toPath from "lodash/toPath";
3
- import { useContext, useEffect, useMemo } from "react";
3
+ import { useContext, useEffect, useMemo, useState } from "react";
4
4
  import { FormContext } from "./internal/formContext";
5
5
  import {
6
6
  createGetInputProps,
7
7
  GetInputProps,
8
8
  ValidationBehaviorOptions,
9
9
  } from "./internal/getInputProps";
10
+ import { ValidationState } from "./types";
11
+
12
+ const useInternalFormContext = (hookName: string) => {
13
+ const context = useContext(FormContext);
14
+ if (!context)
15
+ throw new Error(
16
+ `${hookName} must be used within a ValidatedForm component`
17
+ );
18
+ return context;
19
+ };
10
20
 
11
21
  export type FieldProps = {
12
22
  /**
@@ -21,6 +31,14 @@ export type FieldProps = {
21
31
  * Validates the field.
22
32
  */
23
33
  validate: () => void;
34
+ /**
35
+ * The validation state of the field.
36
+ * - idle: the field has not been validated yet.
37
+ * - validating: the field is currently being validated.
38
+ * - valid: the field is valid.
39
+ * - invalid: the field is invalid.
40
+ */
41
+ validationState: ValidationState;
24
42
  /**
25
43
  * The default value of the field, if there is one.
26
44
  */
@@ -66,10 +84,11 @@ export const useField = (
66
84
  touchedFields,
67
85
  setFieldTouched,
68
86
  hasBeenSubmitted,
69
- } = useContext(FormContext);
87
+ } = useInternalFormContext("useField");
70
88
 
71
89
  const isTouched = !!touchedFields[name];
72
90
  const { handleReceiveFocus } = options ?? {};
91
+ const [isValidating, setValidating] = useState(false);
73
92
 
74
93
  useEffect(() => {
75
94
  if (handleReceiveFocus)
@@ -77,17 +96,28 @@ export const useField = (
77
96
  }, [handleReceiveFocus, name, registerReceiveFocus]);
78
97
 
79
98
  const field = useMemo<FieldProps>(() => {
99
+ const error = fieldErrors[name];
100
+ const getValidationState = (): ValidationState => {
101
+ if (isValidating) return "validating";
102
+ if (error) return "invalid";
103
+ if (!isTouched && !hasBeenSubmitted) return "idle";
104
+ return "valid";
105
+ };
80
106
  const helpers = {
81
- error: fieldErrors[name],
107
+ error,
82
108
  clearError: () => {
83
109
  clearError(name);
84
110
  },
85
- validate: () => validateField(name),
111
+ validate: () => {
112
+ setValidating(true);
113
+ validateField(name).then((error) => setValidating(false));
114
+ },
86
115
  defaultValue: defaultValues
87
116
  ? get(defaultValues, toPath(name), undefined)
88
117
  : undefined,
89
118
  touched: isTouched,
90
119
  setTouched: (touched: boolean) => setFieldTouched(name, touched),
120
+ validationState: getValidationState(),
91
121
  };
92
122
  const getInputProps = createGetInputProps({
93
123
  ...helpers,
@@ -106,6 +136,7 @@ export const useField = (
106
136
  isTouched,
107
137
  hasBeenSubmitted,
108
138
  options?.validationBehavior,
139
+ isValidating,
109
140
  clearError,
110
141
  validateField,
111
142
  setFieldTouched,
@@ -116,13 +147,18 @@ export const useField = (
116
147
 
117
148
  /**
118
149
  * Provides access to the entire form context.
119
- * This is not usually necessary, but can be useful for advanced use cases.
120
150
  */
121
- export const useFormContext = () => useContext(FormContext);
151
+ export const useFormContext = () => useInternalFormContext("useFormContext");
122
152
 
123
153
  /**
124
154
  * Returns whether or not the parent form is currently being submitted.
125
155
  * This is different from remix's `useTransition().submission` in that it
126
156
  * is aware of what form it's in and when _that_ form is being submitted.
127
157
  */
128
- export const useIsSubmitting = () => useFormContext().isSubmitting;
158
+ export const useIsSubmitting = () =>
159
+ useInternalFormContext("useIsSubmitting").isSubmitting;
160
+
161
+ /**
162
+ * Returns whether or not the current form is valid.
163
+ */
164
+ export const useIsValid = () => useInternalFormContext("useIsValid").isValid;
@@ -13,7 +13,7 @@ export type FormContextValue = {
13
13
  /**
14
14
  * Validate the specified field.
15
15
  */
16
- validateField: (fieldName: string) => void;
16
+ validateField: (fieldName: string) => Promise<string | null>;
17
17
  /**
18
18
  * The `action` prop of the form.
19
19
  */
@@ -30,7 +30,6 @@ export type FormContextValue = {
30
30
  hasBeenSubmitted: boolean;
31
31
  /**
32
32
  * Whether or not the form is valid.
33
- * This is a shortcut for `Object.keys(fieldErrors).length === 0`.
34
33
  */
35
34
  isValid: boolean;
36
35
  /**
@@ -52,14 +51,4 @@ export type FormContextValue = {
52
51
  setFieldTouched: (fieldName: string, touched: boolean) => void;
53
52
  };
54
53
 
55
- export const FormContext = createContext<FormContextValue>({
56
- fieldErrors: {},
57
- clearError: () => {},
58
- validateField: () => {},
59
- isSubmitting: false,
60
- hasBeenSubmitted: false,
61
- isValid: true,
62
- registerReceiveFocus: () => () => {},
63
- touchedFields: {},
64
- setFieldTouched: () => {},
65
- });
54
+ export const FormContext = createContext<FormContextValue | null>(null);
package/src/server.ts CHANGED
@@ -1,26 +1,33 @@
1
1
  import { json } from "@remix-run/server-runtime";
2
- import { FieldErrors } from "./validation/types";
2
+ import {
3
+ ValidatorError,
4
+ ValidationErrorResponseData,
5
+ } from "./validation/types";
3
6
 
4
7
  /**
5
8
  * Takes the errors from a `Validator` and returns a `Response`.
6
- * The `ValidatedForm` on the frontend will automatically display the errors
7
- * if this is returned from the action.
9
+ * When you return this from your action, `ValidatedForm` on the frontend will automatically
10
+ * display the errors on the correct fields on the correct form.
11
+ *
12
+ * _Recommended_: You can also provide a second argument to `validationError`
13
+ * to specify how to repopulate the form when JS is disabled.
14
+ *
15
+ * @example
16
+ * ```ts
17
+ * const result = validator.validate(await request.formData());
18
+ * if (result.error) return validationError(result.error, result.submittedData);
19
+ * ```
8
20
  */
9
- export const validationError = (
10
- errors: FieldErrors,
11
- submittedData?: unknown
12
- ) => {
13
- if (submittedData) {
14
- return json(
15
- {
16
- fieldErrors: {
17
- ...errors,
18
- _submittedData: submittedData,
19
- },
20
- },
21
- { status: 422 }
22
- );
23
- }
24
-
25
- return json({ fieldErrors: errors }, { status: 422 });
26
- };
21
+ export function validationError(
22
+ error: ValidatorError,
23
+ repopulateFields?: unknown
24
+ ): Response {
25
+ return json<ValidationErrorResponseData>(
26
+ {
27
+ fieldErrors: error.fieldErrors,
28
+ subaction: error.subaction,
29
+ repopulateFields,
30
+ },
31
+ { status: 422 }
32
+ );
33
+ }
package/src/types.ts ADDED
@@ -0,0 +1 @@
1
+ export type ValidationState = "idle" | "validating" | "valid" | "invalid";
@@ -1,4 +1,4 @@
1
- import { GenericObject, Validator } from "..";
1
+ import { CreateValidatorArg, GenericObject, Validator } from "..";
2
2
  import { objectFromPathEntries } from "../internal/flatten";
3
3
 
4
4
  const preprocessFormData = (data: GenericObject | FormData): GenericObject => {
@@ -14,19 +14,30 @@ const preprocessFormData = (data: GenericObject | FormData): GenericObject => {
14
14
  * It provides built-in handling for unflattening nested objects and
15
15
  * extracting the values from FormData.
16
16
  */
17
- export function createValidator<T>(validator: Validator<T>): Validator<T> {
17
+ export function createValidator<T>(
18
+ validator: CreateValidatorArg<T>
19
+ ): Validator<T> {
18
20
  return {
19
- validate: (value: GenericObject | FormData) => {
21
+ validate: async (value) => {
20
22
  const data = preprocessFormData(value);
21
- const result = validator.validate(data);
23
+ const result = await validator.validate(data);
24
+
22
25
  if (result.error) {
23
- // Ideally, we should probably be returning a nested object like
24
- // { fieldErrors: {}, submittedData: {} }
25
- // We should do this in the next major version of the library
26
- // but for now, we can sneak it in with the fieldErrors.
27
- result.error._submittedData = data as any;
26
+ return {
27
+ data: undefined,
28
+ error: {
29
+ fieldErrors: result.error,
30
+ subaction: data.subaction,
31
+ },
32
+ submittedData: data,
33
+ };
28
34
  }
29
- return result;
35
+
36
+ return {
37
+ data: result.data,
38
+ error: undefined,
39
+ submittedData: data,
40
+ };
30
41
  },
31
42
  validateField: (data: GenericObject | FormData, field: string) =>
32
43
  validator.validateField(preprocessFormData(data), field),
@@ -2,16 +2,33 @@ export type FieldErrors = Record<string, string>;
2
2
 
3
3
  export type TouchedFields = Record<string, boolean>;
4
4
 
5
- export type FieldErrorsWithData = FieldErrors & { _submittedData: any };
6
-
7
5
  export type GenericObject = { [key: string]: any };
8
6
 
7
+ export type ValidatorError = {
8
+ subaction?: string;
9
+ fieldErrors: FieldErrors;
10
+ };
11
+
12
+ export type ValidationErrorResponseData = {
13
+ subaction?: string;
14
+ fieldErrors: FieldErrors;
15
+ repopulateFields?: unknown;
16
+ };
17
+
18
+ export type BaseResult = { submittedData: GenericObject };
19
+ export type ErrorResult = BaseResult & {
20
+ error: ValidatorError;
21
+ data: undefined;
22
+ };
23
+ export type SuccessResult<DataType> = BaseResult & {
24
+ data: DataType;
25
+ error: undefined;
26
+ };
27
+
9
28
  /**
10
29
  * The result when validating a form.
11
30
  */
12
- export type ValidationResult<DataType> =
13
- | { data: DataType; error: undefined }
14
- | { error: FieldErrors; data: undefined };
31
+ export type ValidationResult<DataType> = SuccessResult<DataType> | ErrorResult;
15
32
 
16
33
  /**
17
34
  * The result when validating an individual field in a form.
@@ -22,11 +39,25 @@ export type ValidateFieldResult = { error?: string };
22
39
  * A `Validator` can be passed to the `validator` prop of a `ValidatedForm`.
23
40
  */
24
41
  export type Validator<DataType> = {
25
- validate: (unvalidatedData: GenericObject) => ValidationResult<DataType>;
42
+ validate: (
43
+ unvalidatedData: GenericObject
44
+ ) => Promise<ValidationResult<DataType>>;
45
+ validateField: (
46
+ unvalidatedData: GenericObject,
47
+ field: string
48
+ ) => Promise<ValidateFieldResult>;
49
+ };
50
+
51
+ export type Valid<DataType> = { data: DataType; error: undefined };
52
+ export type Invalid = { error: FieldErrors; data: undefined };
53
+ export type CreateValidatorArg<DataType> = {
54
+ validate: (
55
+ unvalidatedData: GenericObject
56
+ ) => Promise<Valid<DataType> | Invalid>;
26
57
  validateField: (
27
58
  unvalidatedData: GenericObject,
28
59
  field: string
29
- ) => ValidateFieldResult;
60
+ ) => Promise<ValidateFieldResult>;
30
61
  };
31
62
 
32
63
  export type ValidatorData<T extends Validator<any>> = T extends Validator<