remix-validated-form 3.4.2 → 4.0.1-beta.2

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,11 @@ import {
3
3
  useActionData,
4
4
  useFetcher,
5
5
  useFormAction,
6
+ useSubmit,
6
7
  useTransition,
7
8
  } from "@remix-run/react";
9
+ import { Fetcher } from "@remix-run/react/transition";
10
+ import uniq from "lodash/uniq";
8
11
  import React, {
9
12
  ComponentProps,
10
13
  useEffect,
@@ -20,8 +23,8 @@ import { omit, mergeRefs } from "./internal/util";
20
23
  import {
21
24
  FieldErrors,
22
25
  Validator,
23
- FieldErrorsWithData,
24
26
  TouchedFields,
27
+ ValidationErrorResponseData,
25
28
  } from "./validation/types";
26
29
 
27
30
  export type FormProps<DataType> = {
@@ -33,7 +36,10 @@ export type FormProps<DataType> = {
33
36
  * A submit callback that gets called when the form is submitted
34
37
  * after all validations have been run.
35
38
  */
36
- onSubmit?: (data: DataType, event: React.FormEvent<HTMLFormElement>) => void;
39
+ onSubmit?: (
40
+ data: DataType,
41
+ event: React.FormEvent<HTMLFormElement>
42
+ ) => Promise<void>;
37
43
  /**
38
44
  * Allows you to provide a `fetcher` from remix's `useFetcher` hook.
39
45
  * The form will use the fetcher for loading states, action data, etc
@@ -68,25 +74,27 @@ export type FormProps<DataType> = {
68
74
  disableFocusOnError?: boolean;
69
75
  } & Omit<ComponentProps<typeof RemixForm>, "onSubmit">;
70
76
 
71
- function useFieldErrorsFromBackend(
77
+ function useErrorResponseForThisForm(
72
78
  fetcher?: ReturnType<typeof useFetcher>,
73
79
  subaction?: string
74
- ): FieldErrorsWithData | null {
80
+ ): ValidationErrorResponseData | null {
75
81
  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;
82
+ if (fetcher) {
83
+ if ((fetcher.data as any)?.fieldErrors) return fetcher.data as any;
84
+ return null;
84
85
  }
86
+
87
+ if (!actionData?.fieldErrors) return null;
88
+ if (
89
+ (!subaction && !actionData.subaction) ||
90
+ actionData.subaction === subaction
91
+ )
92
+ return actionData;
85
93
  return null;
86
94
  }
87
95
 
88
96
  function useFieldErrors(
89
- fieldErrorsFromBackend?: any
97
+ fieldErrorsFromBackend?: FieldErrors
90
98
  ): [FieldErrors, React.Dispatch<React.SetStateAction<FieldErrors>>] {
91
99
  const [fieldErrors, setFieldErrors] = useState<FieldErrors>(
92
100
  fieldErrorsFromBackend ?? {}
@@ -99,22 +107,19 @@ function useFieldErrors(
99
107
  }
100
108
 
101
109
  const useIsSubmitting = (
102
- action?: string,
103
- subaction?: string,
104
- fetcher?: ReturnType<typeof useFetcher>
105
- ) => {
106
- const actionForCurrentPage = useFormAction();
107
- const pendingFormSubmit = useTransition().submission;
110
+ fetcher?: Fetcher
111
+ ): [boolean, () => void, () => void] => {
112
+ const [isSubmitStarted, setSubmitStarted] = useState(false);
113
+ const transition = useTransition();
114
+ const hasActiveSubmission = fetcher
115
+ ? fetcher.state === "submitting"
116
+ : !!transition.submission;
117
+ const isSubmitting = hasActiveSubmission && isSubmitStarted;
108
118
 
109
- if (fetcher) return fetcher.state === "submitting";
110
- if (!pendingFormSubmit) return false;
119
+ const startSubmit = () => setSubmitStarted(true);
120
+ const endSubmit = () => setSubmitStarted(false);
111
121
 
112
- const { formData, action: pendingAction } = pendingFormSubmit;
113
- const pendingSubAction = formData.get("subaction");
114
- const expectedAction = action ?? actionForCurrentPage;
115
- if (subaction)
116
- return expectedAction === pendingAction && subaction === pendingSubAction;
117
- return expectedAction === pendingAction && !pendingSubAction;
122
+ return [isSubmitting, startSubmit, endSubmit];
118
123
  };
119
124
 
120
125
  const getDataFromForm = (el: HTMLFormElement) => new FormData(el);
@@ -136,11 +141,14 @@ const getDataFromForm = (el: HTMLFormElement) => new FormData(el);
136
141
  * and only if JS is disabled.
137
142
  */
138
143
  function useDefaultValues<DataType>(
139
- fieldErrors?: FieldErrorsWithData | null,
144
+ repopulateFieldsFromBackend?: any,
140
145
  defaultValues?: Partial<DataType>
141
146
  ) {
142
- const defaultsFromValidationError = fieldErrors?._submittedData;
143
- return defaultsFromValidationError ?? defaultValues;
147
+ return repopulateFieldsFromBackend ?? defaultValues;
148
+ }
149
+
150
+ function nonNull<T>(value: T | null | undefined): value is T {
151
+ return value !== null;
144
152
  }
145
153
 
146
154
  const focusFirstInvalidInput = (
@@ -148,28 +156,48 @@ const focusFirstInvalidInput = (
148
156
  customFocusHandlers: MultiValueMap<string, () => void>,
149
157
  formElement: HTMLFormElement
150
158
  ) => {
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;
159
+ const namesInOrder = [...formElement.elements]
160
+ .map((el) => {
161
+ const input = el instanceof RadioNodeList ? el[0] : el;
162
+ if (input instanceof HTMLInputElement) return input.name;
163
+ return null;
164
+ })
165
+ .filter(nonNull)
166
+ .filter((name) => name in fieldErrors);
167
+ const uniqueNamesInOrder = uniq(namesInOrder);
157
168
 
158
- if (customFocusHandlers.has(input.name)) {
159
- customFocusHandlers.getAll(input.name).forEach((handler) => {
169
+ for (const fieldName of uniqueNamesInOrder) {
170
+ if (customFocusHandlers.has(fieldName)) {
171
+ customFocusHandlers.getAll(fieldName).forEach((handler) => {
160
172
  handler();
161
173
  });
162
174
  break;
163
175
  }
164
176
 
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;
177
+ const elem = formElement.elements.namedItem(fieldName);
178
+ if (!elem) continue;
179
+
180
+ if (elem instanceof RadioNodeList) {
181
+ const selectedRadio =
182
+ [...elem]
183
+ .filter(
184
+ (item): item is HTMLInputElement => item instanceof HTMLInputElement
185
+ )
186
+ .find((item) => item.value === elem.value) ?? elem[0];
187
+ if (selectedRadio && selectedRadio instanceof HTMLInputElement) {
188
+ selectedRadio.focus();
189
+ break;
190
+ }
169
191
  }
170
192
 
171
- input.focus();
172
- break;
193
+ if (elem instanceof HTMLInputElement) {
194
+ if (elem.type === "hidden") {
195
+ continue;
196
+ }
197
+
198
+ elem.focus();
199
+ break;
200
+ }
173
201
  }
174
202
  };
175
203
 
@@ -188,17 +216,27 @@ export function ValidatedForm<DataType>({
188
216
  subaction,
189
217
  resetAfterSubmit,
190
218
  disableFocusOnError,
219
+ method,
220
+ replace,
191
221
  ...rest
192
222
  }: FormProps<DataType>) {
193
- const fieldErrorsFromBackend = useFieldErrorsFromBackend(fetcher, subaction);
194
- const [fieldErrors, setFieldErrors] = useFieldErrors(fieldErrorsFromBackend);
195
- const isSubmitting = useIsSubmitting(action, subaction, fetcher);
196
- const defaultsToUse = useDefaultValues(fieldErrorsFromBackend, defaultValues);
223
+ const backendError = useErrorResponseForThisForm(fetcher, subaction);
224
+ const [fieldErrors, setFieldErrors] = useFieldErrors(
225
+ backendError?.fieldErrors
226
+ );
227
+ const [isSubmitting, startSubmit, endSubmit] = useIsSubmitting(fetcher);
228
+
229
+ const defaultsToUse = useDefaultValues(
230
+ backendError?.repopulateFields,
231
+ defaultValues
232
+ );
197
233
  const [touchedFields, setTouchedFields] = useState<TouchedFields>({});
198
234
  const [hasBeenSubmitted, setHasBeenSubmitted] = useState(false);
235
+ const submit = useSubmit();
199
236
  const formRef = useRef<HTMLFormElement>(null);
200
237
  useSubmitComplete(isSubmitting, () => {
201
- if (!fieldErrorsFromBackend && resetAfterSubmit) {
238
+ endSubmit();
239
+ if (!backendError && resetAfterSubmit) {
202
240
  formRef.current?.reset();
203
241
  }
204
242
  });
@@ -209,7 +247,7 @@ export function ValidatedForm<DataType>({
209
247
  fieldErrors,
210
248
  action,
211
249
  defaultValues: defaultsToUse,
212
- isSubmitting: isSubmitting ?? false,
250
+ isSubmitting,
213
251
  isValid: Object.keys(fieldErrors).length === 0,
214
252
  touchedFields,
215
253
  setFieldTouched: (fieldName: string, touched: boolean) =>
@@ -220,9 +258,9 @@ export function ValidatedForm<DataType>({
220
258
  clearError: (fieldName) => {
221
259
  setFieldErrors((prev) => omit(prev, fieldName));
222
260
  },
223
- validateField: (fieldName) => {
261
+ validateField: async (fieldName) => {
224
262
  invariant(formRef.current, "Cannot find reference to form");
225
- const { error } = validator.validateField(
263
+ const { error } = await validator.validateField(
226
264
  getDataFromForm(formRef.current),
227
265
  fieldName as any
228
266
  );
@@ -237,11 +275,13 @@ export function ValidatedForm<DataType>({
237
275
  [fieldName]: error,
238
276
  };
239
277
  });
278
+ return error;
240
279
  } else {
241
280
  setFieldErrors((prev) => {
242
281
  if (!(fieldName in prev)) return prev;
243
282
  return omit(prev, fieldName);
244
283
  });
284
+ return null;
245
285
  }
246
286
  },
247
287
  registerReceiveFocus: (fieldName, handler) => {
@@ -267,26 +307,66 @@ export function ValidatedForm<DataType>({
267
307
 
268
308
  const Form = fetcher?.Form ?? RemixForm;
269
309
 
310
+ let clickedButtonRef = React.useRef<any>();
311
+ useEffect(() => {
312
+ let form = formRef.current;
313
+ if (!form) return;
314
+
315
+ function handleClick(event: MouseEvent) {
316
+ if (!(event.target instanceof HTMLElement)) return;
317
+ let submitButton = event.target.closest<
318
+ HTMLButtonElement | HTMLInputElement
319
+ >("button,input[type=submit]");
320
+
321
+ if (
322
+ submitButton &&
323
+ submitButton.form === form &&
324
+ submitButton.type === "submit"
325
+ ) {
326
+ clickedButtonRef.current = submitButton;
327
+ }
328
+ }
329
+
330
+ window.addEventListener("click", handleClick);
331
+ return () => {
332
+ window.removeEventListener("click", handleClick);
333
+ };
334
+ }, []);
335
+
270
336
  return (
271
337
  <Form
272
338
  ref={mergeRefs([formRef, formRefProp])}
273
339
  {...rest}
274
340
  action={action}
275
- onSubmit={(event) => {
341
+ method={method}
342
+ replace={replace}
343
+ onSubmit={async (e) => {
344
+ e.preventDefault();
276
345
  setHasBeenSubmitted(true);
277
- const result = validator.validate(getDataFromForm(event.currentTarget));
346
+ startSubmit();
347
+ const result = await validator.validate(
348
+ getDataFromForm(e.currentTarget)
349
+ );
278
350
  if (result.error) {
279
- event.preventDefault();
280
- setFieldErrors(result.error);
351
+ endSubmit();
352
+ setFieldErrors(result.error.fieldErrors);
281
353
  if (!disableFocusOnError) {
282
354
  focusFirstInvalidInput(
283
- result.error,
355
+ result.error.fieldErrors,
284
356
  customFocusHandlers(),
285
357
  formRef.current!
286
358
  );
287
359
  }
288
360
  } else {
289
- onSubmit?.(result.data, event);
361
+ onSubmit && onSubmit(result.data, e);
362
+ if (fetcher)
363
+ fetcher.submit(clickedButtonRef.current || e.currentTarget);
364
+ else
365
+ submit(clickedButtonRef.current || e.currentTarget, {
366
+ method,
367
+ replace,
368
+ });
369
+ clickedButtonRef.current = null;
290
370
  }
291
371
  }}
292
372
  onReset={(event) => {
package/src/hooks.ts CHANGED
@@ -8,6 +8,15 @@ import {
8
8
  ValidationBehaviorOptions,
9
9
  } from "./internal/getInputProps";
10
10
 
11
+ const useInternalFormContext = (hookName: string) => {
12
+ const context = useContext(FormContext);
13
+ if (!context)
14
+ throw new Error(
15
+ `${hookName} must be used within a ValidatedForm component`
16
+ );
17
+ return context;
18
+ };
19
+
11
20
  export type FieldProps = {
12
21
  /**
13
22
  * The validation error message if there is one.
@@ -66,7 +75,7 @@ export const useField = (
66
75
  touchedFields,
67
76
  setFieldTouched,
68
77
  hasBeenSubmitted,
69
- } = useContext(FormContext);
78
+ } = useInternalFormContext("useField");
70
79
 
71
80
  const isTouched = !!touchedFields[name];
72
81
  const { handleReceiveFocus } = options ?? {};
@@ -82,7 +91,9 @@ export const useField = (
82
91
  clearError: () => {
83
92
  clearError(name);
84
93
  },
85
- validate: () => validateField(name),
94
+ validate: () => {
95
+ validateField(name);
96
+ },
86
97
  defaultValue: defaultValues
87
98
  ? get(defaultValues, toPath(name), undefined)
88
99
  : undefined,
@@ -116,13 +127,18 @@ export const useField = (
116
127
 
117
128
  /**
118
129
  * Provides access to the entire form context.
119
- * This is not usually necessary, but can be useful for advanced use cases.
120
130
  */
121
- export const useFormContext = () => useContext(FormContext);
131
+ export const useFormContext = () => useInternalFormContext("useFormContext");
122
132
 
123
133
  /**
124
134
  * Returns whether or not the parent form is currently being submitted.
125
135
  * This is different from remix's `useTransition().submission` in that it
126
136
  * is aware of what form it's in and when _that_ form is being submitted.
127
137
  */
128
- export const useIsSubmitting = () => useFormContext().isSubmitting;
138
+ export const useIsSubmitting = () =>
139
+ useInternalFormContext("useIsSubmitting").isSubmitting;
140
+
141
+ /**
142
+ * Returns whether or not the current form is valid.
143
+ */
144
+ 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
+ * 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
+ }
@@ -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<