remix-validated-form 3.1.1 → 3.3.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.
Files changed (38) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/README.md +10 -10
  3. package/browser/ValidatedForm.js +31 -5
  4. package/browser/hooks.d.ts +17 -0
  5. package/browser/hooks.js +37 -11
  6. package/browser/internal/MultiValueMap.d.ts +9 -0
  7. package/browser/internal/MultiValueMap.js +39 -1
  8. package/browser/internal/SingleTypeMultiValueMap.d.ts +1 -0
  9. package/browser/internal/SingleTypeMultiValueMap.js +1 -0
  10. package/browser/internal/flatten.js +6 -1
  11. package/browser/internal/formContext.d.ts +15 -1
  12. package/browser/internal/formContext.js +3 -0
  13. package/browser/internal/getInputProps.d.ts +21 -0
  14. package/browser/internal/getInputProps.js +39 -0
  15. package/browser/internal/test.d.ts +0 -0
  16. package/browser/internal/test.js +15 -0
  17. package/browser/validation/types.d.ts +1 -0
  18. package/build/ValidatedForm.js +32 -6
  19. package/build/hooks.d.ts +17 -0
  20. package/build/hooks.js +37 -11
  21. package/build/internal/MultiValueMap.d.ts +9 -0
  22. package/build/internal/MultiValueMap.js +44 -0
  23. package/build/internal/flatten.js +6 -1
  24. package/build/internal/formContext.d.ts +15 -1
  25. package/build/internal/formContext.js +3 -0
  26. package/build/internal/getInputProps.d.ts +21 -0
  27. package/build/internal/getInputProps.js +43 -0
  28. package/build/internal/test.d.ts +1 -0
  29. package/build/internal/test.js +12 -0
  30. package/build/validation/types.d.ts +1 -0
  31. package/package.json +1 -1
  32. package/src/ValidatedForm.tsx +31 -8
  33. package/src/hooks.ts +51 -5
  34. package/src/internal/{SingleTypeMultiValueMap.ts → MultiValueMap.ts} +2 -1
  35. package/src/internal/flatten.ts +9 -2
  36. package/src/internal/formContext.ts +18 -1
  37. package/src/internal/getInputProps.ts +73 -0
  38. package/src/validation/types.ts +2 -0
@@ -1,9 +1,9 @@
1
1
  $ npm run build:browser && npm run build:main
2
2
 
3
- > remix-validated-form@3.1.0 build:browser
3
+ > remix-validated-form@3.2.2 build:browser
4
4
  > tsc --module ESNext --outDir ./browser
5
5
 
6
6
 
7
- > remix-validated-form@3.1.0 build:main
7
+ > remix-validated-form@3.2.2 build:main
8
8
  > tsc --module CommonJS --outDir ./build
9
9
 
package/README.md CHANGED
@@ -64,18 +64,14 @@ type MyInputProps = {
64
64
  };
65
65
 
66
66
  export const MyInput = ({ name, label }: InputProps) => {
67
- const { validate, clearError, defaultValue, error } = useField(name);
67
+ const { error, getInputProps } = useField(name);
68
68
  return (
69
69
  <div>
70
70
  <label htmlFor={name}>{label}</label>
71
- <input
72
- id={name}
73
- name={name}
74
- onBlur={validate}
75
- onChange={clearError}
76
- defaultValue={defaultValue}
77
- />
78
- {error && <span className="my-error-class">{error}</span>}
71
+ <input {...getInputProps({ id: name })} />
72
+ {error && (
73
+ <span className="my-error-class">{error}</span>
74
+ )}
79
75
  </div>
80
76
  );
81
77
  };
@@ -257,4 +253,8 @@ We recommend this approach since the validation will still work even if JS is di
257
253
  ## How do we trigger toast messages on success?
258
254
 
259
255
  Problem: how do we trigger a toast message on success if the action redirects away from the form route? The Remix solution is to flash a message in the session and pick this up in a loader function, probably in root.tsx
260
- See the [Remix](https://remix.run/docs/en/v1/api/remix#sessionflashkey-value) documentation for more information.
256
+ See the [Remix](https://remix.run/docs/en/v1/api/remix#sessionflashkey-value) documentation for more information.
257
+
258
+ ## Why is my cancel button triggering form submission?
259
+ Problem: the cancel button has an onClick handler to navigate away from the form route but instead it is submitting the form.
260
+ A button defaults to `type="submit"` in a form which will submit the form by default. If you want to prevent this you can add `type="reset"` or `type="button"` to the cancel button.
@@ -3,7 +3,7 @@ import { Form as RemixForm, useActionData, useFormAction, useTransition, } from
3
3
  import { useEffect, useMemo, useRef, useState, } from "react";
4
4
  import invariant from "tiny-invariant";
5
5
  import { FormContext } from "./internal/formContext";
6
- import { useMultiValueMap, } from "./internal/SingleTypeMultiValueMap";
6
+ import { useMultiValueMap } from "./internal/MultiValueMap";
7
7
  import { useSubmitComplete } from "./internal/submissionCallbacks";
8
8
  import { omit, mergeRefs } from "./internal/util";
9
9
  function useFieldErrorsFromBackend(fetcher, subaction) {
@@ -96,6 +96,8 @@ export function ValidatedForm({ validator, onSubmit, children, fetcher, action,
96
96
  const [fieldErrors, setFieldErrors] = useFieldErrors(fieldErrorsFromBackend);
97
97
  const isSubmitting = useIsSubmitting(action, subaction, fetcher);
98
98
  const defaultsToUse = useDefaultValues(fieldErrorsFromBackend, defaultValues);
99
+ const [touchedFields, setTouchedFields] = useState({});
100
+ const [hasBeenSubmitted, setHasBeenSubmitted] = useState(false);
99
101
  const formRef = useRef(null);
100
102
  useSubmitComplete(isSubmitting, () => {
101
103
  var _a;
@@ -110,17 +112,35 @@ export function ValidatedForm({ validator, onSubmit, children, fetcher, action,
110
112
  defaultValues: defaultsToUse,
111
113
  isSubmitting: isSubmitting !== null && isSubmitting !== void 0 ? isSubmitting : false,
112
114
  isValid: Object.keys(fieldErrors).length === 0,
115
+ touchedFields,
116
+ setFieldTouched: (fieldName, touched) => setTouchedFields((prev) => ({
117
+ ...prev,
118
+ [fieldName]: touched,
119
+ })),
113
120
  clearError: (fieldName) => {
114
121
  setFieldErrors((prev) => omit(prev, fieldName));
115
122
  },
116
123
  validateField: (fieldName) => {
117
124
  invariant(formRef.current, "Cannot find reference to form");
118
125
  const { error } = validator.validateField(getDataFromForm(formRef.current), fieldName);
126
+ // By checking and returning `prev` here, we can avoid a re-render
127
+ // if the validation state is the same.
119
128
  if (error) {
120
- setFieldErrors((prev) => ({
121
- ...prev,
122
- [fieldName]: error,
123
- }));
129
+ setFieldErrors((prev) => {
130
+ if (prev[fieldName] === error)
131
+ return prev;
132
+ return {
133
+ ...prev,
134
+ [fieldName]: error,
135
+ };
136
+ });
137
+ }
138
+ else {
139
+ setFieldErrors((prev) => {
140
+ if (!(fieldName in prev))
141
+ return prev;
142
+ return omit(prev, fieldName);
143
+ });
124
144
  }
125
145
  },
126
146
  registerReceiveFocus: (fieldName, handler) => {
@@ -129,17 +149,21 @@ export function ValidatedForm({ validator, onSubmit, children, fetcher, action,
129
149
  customFocusHandlers().remove(fieldName, handler);
130
150
  };
131
151
  },
152
+ hasBeenSubmitted,
132
153
  }), [
133
154
  fieldErrors,
134
155
  action,
135
156
  defaultsToUse,
136
157
  isSubmitting,
158
+ touchedFields,
159
+ hasBeenSubmitted,
137
160
  setFieldErrors,
138
161
  validator,
139
162
  customFocusHandlers,
140
163
  ]);
141
164
  const Form = (_a = fetcher === null || fetcher === void 0 ? void 0 : fetcher.Form) !== null && _a !== void 0 ? _a : RemixForm;
142
165
  return (_jsx(Form, { ref: mergeRefs([formRef, formRefProp]), ...rest, action: action, onSubmit: (event) => {
166
+ setHasBeenSubmitted(true);
143
167
  const result = validator.validate(getDataFromForm(event.currentTarget));
144
168
  if (result.error) {
145
169
  event.preventDefault();
@@ -156,5 +180,7 @@ export function ValidatedForm({ validator, onSubmit, children, fetcher, action,
156
180
  if (event.defaultPrevented)
157
181
  return;
158
182
  setFieldErrors({});
183
+ setTouchedFields({});
184
+ setHasBeenSubmitted(false);
159
185
  }, children: _jsxs(FormContext.Provider, { value: contextValue, children: [subaction && (_jsx("input", { type: "hidden", value: subaction, name: "subaction" }, void 0)), children] }, void 0) }, void 0));
160
186
  }
@@ -1,3 +1,4 @@
1
+ import { GetInputProps, ValidationBehaviorOptions } from "./internal/getInputProps";
1
2
  export declare type FieldProps = {
2
3
  /**
3
4
  * The validation error message if there is one.
@@ -15,6 +16,18 @@ export declare type FieldProps = {
15
16
  * The default value of the field, if there is one.
16
17
  */
17
18
  defaultValue?: any;
19
+ /**
20
+ * Whether or not the field has been touched.
21
+ */
22
+ touched: boolean;
23
+ /**
24
+ * Helper to set the touched state of the field.
25
+ */
26
+ setTouched: (touched: boolean) => void;
27
+ /**
28
+ * Helper to get all the props necessary for a regular input.
29
+ */
30
+ getInputProps: GetInputProps;
18
31
  };
19
32
  /**
20
33
  * Provides the data and helpers necessary to set up a field.
@@ -26,6 +39,10 @@ export declare const useField: (name: string, options?: {
26
39
  * This is useful for custom components that use a hidden input.
27
40
  */
28
41
  handleReceiveFocus?: (() => void) | undefined;
42
+ /**
43
+ * Allows you to specify when a field gets validated (when using getInputProps)
44
+ */
45
+ validationBehavior?: Partial<ValidationBehaviorOptions> | undefined;
29
46
  } | undefined) => FieldProps;
30
47
  /**
31
48
  * Provides access to the entire form context.
package/browser/hooks.js CHANGED
@@ -2,26 +2,52 @@ import get from "lodash/get";
2
2
  import toPath from "lodash/toPath";
3
3
  import { useContext, useEffect, useMemo } from "react";
4
4
  import { FormContext } from "./internal/formContext";
5
+ import { createGetInputProps, } from "./internal/getInputProps";
5
6
  /**
6
7
  * Provides the data and helpers necessary to set up a field.
7
8
  */
8
9
  export const useField = (name, options) => {
9
- const { fieldErrors, clearError, validateField, defaultValues, registerReceiveFocus, } = useContext(FormContext);
10
+ const { fieldErrors, clearError, validateField, defaultValues, registerReceiveFocus, touchedFields, setFieldTouched, hasBeenSubmitted, } = useContext(FormContext);
11
+ const isTouched = !!touchedFields[name];
10
12
  const { handleReceiveFocus } = options !== null && options !== void 0 ? options : {};
11
13
  useEffect(() => {
12
14
  if (handleReceiveFocus)
13
15
  return registerReceiveFocus(name, handleReceiveFocus);
14
16
  }, [handleReceiveFocus, name, registerReceiveFocus]);
15
- const field = useMemo(() => ({
16
- error: fieldErrors[name],
17
- clearError: () => {
18
- clearError(name);
19
- },
20
- validate: () => validateField(name),
21
- defaultValue: defaultValues
22
- ? get(defaultValues, toPath(name), undefined)
23
- : undefined,
24
- }), [clearError, defaultValues, fieldErrors, name, validateField]);
17
+ const field = useMemo(() => {
18
+ const helpers = {
19
+ error: fieldErrors[name],
20
+ clearError: () => {
21
+ clearError(name);
22
+ },
23
+ validate: () => validateField(name),
24
+ defaultValue: defaultValues
25
+ ? get(defaultValues, toPath(name), undefined)
26
+ : undefined,
27
+ touched: isTouched,
28
+ setTouched: (touched) => setFieldTouched(name, touched),
29
+ };
30
+ const getInputProps = createGetInputProps({
31
+ ...helpers,
32
+ name,
33
+ hasBeenSubmitted,
34
+ validationBehavior: options === null || options === void 0 ? void 0 : options.validationBehavior,
35
+ });
36
+ return {
37
+ ...helpers,
38
+ getInputProps,
39
+ };
40
+ }, [
41
+ fieldErrors,
42
+ name,
43
+ defaultValues,
44
+ isTouched,
45
+ hasBeenSubmitted,
46
+ options === null || options === void 0 ? void 0 : options.validationBehavior,
47
+ clearError,
48
+ validateField,
49
+ setFieldTouched,
50
+ ]);
25
51
  return field;
26
52
  };
27
53
  /**
@@ -0,0 +1,9 @@
1
+ export declare class MultiValueMap<Key, Value> {
2
+ private dict;
3
+ add: (key: Key, value: Value) => void;
4
+ remove: (key: Key, value: Value) => void;
5
+ getAll: (key: Key) => Value[];
6
+ entries: () => IterableIterator<[Key, Value[]]>;
7
+ has: (key: Key) => boolean;
8
+ }
9
+ export declare const useMultiValueMap: <Key, Value>() => () => MultiValueMap<Key, Value>;
@@ -1 +1,39 @@
1
- "use strict";
1
+ import { useRef } from "react";
2
+ export class MultiValueMap {
3
+ constructor() {
4
+ this.dict = new Map();
5
+ this.add = (key, value) => {
6
+ if (this.dict.has(key)) {
7
+ this.dict.get(key).push(value);
8
+ }
9
+ else {
10
+ this.dict.set(key, [value]);
11
+ }
12
+ };
13
+ this.remove = (key, value) => {
14
+ if (!this.dict.has(key))
15
+ return;
16
+ const array = this.dict.get(key);
17
+ const index = array.indexOf(value);
18
+ if (index !== -1)
19
+ array.splice(index, 1);
20
+ if (array.length === 0)
21
+ this.dict.delete(key);
22
+ };
23
+ this.getAll = (key) => {
24
+ var _a;
25
+ return (_a = this.dict.get(key)) !== null && _a !== void 0 ? _a : [];
26
+ };
27
+ this.entries = () => this.dict.entries();
28
+ this.has = (key) => this.dict.has(key);
29
+ }
30
+ }
31
+ export const useMultiValueMap = () => {
32
+ const ref = useRef(null);
33
+ return () => {
34
+ if (ref.current)
35
+ return ref.current;
36
+ ref.current = new MultiValueMap();
37
+ return ref.current;
38
+ };
39
+ };
@@ -3,6 +3,7 @@ export declare class MultiValueMap<Key, Value> {
3
3
  add: (key: Key, value: Value) => void;
4
4
  remove: (key: Key, value: Value) => void;
5
5
  getAll: (key: Key) => Value[];
6
+ entries: () => IterableIterator<[Key, Value[]]>;
6
7
  has: (key: Key) => boolean;
7
8
  }
8
9
  export declare const useMultiValueMap: <Key, Value>() => () => MultiValueMap<Key, Value>;
@@ -26,6 +26,7 @@ export class MultiValueMap {
26
26
  var _a;
27
27
  return (_a = this.dict.get(key)) !== null && _a !== void 0 ? _a : [];
28
28
  };
29
+ this.entries = () => this.dict.entries();
29
30
  this.has = (key) => this.dict.has(key);
30
31
  }
31
32
  }
@@ -6,7 +6,12 @@ import keys from "lodash/keys";
6
6
  import mapKeys from "lodash/mapKeys";
7
7
  import set from "lodash/set";
8
8
  import transform from "lodash/transform";
9
- export const objectFromPathEntries = (entries) => entries.reduce((acc, [key, value]) => set(acc, key, value), {});
9
+ import { MultiValueMap } from "./MultiValueMap";
10
+ export const objectFromPathEntries = (entries) => {
11
+ const map = new MultiValueMap();
12
+ entries.forEach(([key, value]) => map.add(key, value));
13
+ return [...map.entries()].reduce((acc, [key, value]) => set(acc, key, value.length === 1 ? value[0] : value), {});
14
+ };
10
15
  /** Flatten an object so there are no nested objects or arrays */
11
16
  export function flatten(obj, preserveEmpty = false) {
12
17
  return transform(obj, function (result, value, key) {
@@ -1,5 +1,5 @@
1
1
  /// <reference types="react" />
2
- import { FieldErrors } from "../validation/types";
2
+ import { FieldErrors, TouchedFields } from "../validation/types";
3
3
  export declare type FormContextValue = {
4
4
  /**
5
5
  * All the errors in all the fields in the form.
@@ -21,6 +21,12 @@ export declare type FormContextValue = {
21
21
  * Whether or not the form is submitting.
22
22
  */
23
23
  isSubmitting: boolean;
24
+ /**
25
+ * Whether or not a submission has been attempted.
26
+ * This is true once the form has been submitted, even if there were validation errors.
27
+ * Resets to false when the form is reset.
28
+ */
29
+ hasBeenSubmitted: boolean;
24
30
  /**
25
31
  * Whether or not the form is valid.
26
32
  * This is a shortcut for `Object.keys(fieldErrors).length === 0`.
@@ -37,5 +43,13 @@ export declare type FormContextValue = {
37
43
  * the field needs to receive focus due to a validation error.
38
44
  */
39
45
  registerReceiveFocus: (fieldName: string, handler: () => void) => () => void;
46
+ /**
47
+ * Any fields that have been touched by the user.
48
+ */
49
+ touchedFields: TouchedFields;
50
+ /**
51
+ * Change the touched state of the specified field.
52
+ */
53
+ setFieldTouched: (fieldName: string, touched: boolean) => void;
40
54
  };
41
55
  export declare const FormContext: import("react").Context<FormContextValue>;
@@ -4,6 +4,9 @@ export const FormContext = createContext({
4
4
  clearError: () => { },
5
5
  validateField: () => { },
6
6
  isSubmitting: false,
7
+ hasBeenSubmitted: false,
7
8
  isValid: true,
8
9
  registerReceiveFocus: () => () => { },
10
+ touchedFields: {},
11
+ setFieldTouched: () => { },
9
12
  });
@@ -0,0 +1,21 @@
1
+ export declare type ValidationBehavior = "onBlur" | "onChange" | "onSubmit";
2
+ export declare type ValidationBehaviorOptions = {
3
+ initial: ValidationBehavior;
4
+ whenTouched: ValidationBehavior;
5
+ whenSubmitted: ValidationBehavior;
6
+ };
7
+ export declare type CreateGetInputPropsOptions = {
8
+ clearError: () => void;
9
+ validate: () => void;
10
+ defaultValue?: any;
11
+ touched: boolean;
12
+ setTouched: (touched: boolean) => void;
13
+ hasBeenSubmitted: boolean;
14
+ validationBehavior?: Partial<ValidationBehaviorOptions>;
15
+ name: string;
16
+ };
17
+ declare type HandledProps = "name" | "defaultValue";
18
+ declare type Callbacks = "onChange" | "onBlur";
19
+ export declare type GetInputProps = <T extends Record<string, any>>(props?: Omit<T, HandledProps | Callbacks> & Partial<Pick<T, Callbacks>>) => T;
20
+ export declare const createGetInputProps: ({ clearError, validate, defaultValue, touched, setTouched, hasBeenSubmitted, validationBehavior, name, }: CreateGetInputPropsOptions) => GetInputProps;
21
+ export {};
@@ -0,0 +1,39 @@
1
+ const defaultValidationBehavior = {
2
+ initial: "onBlur",
3
+ whenTouched: "onChange",
4
+ whenSubmitted: "onChange",
5
+ };
6
+ export const createGetInputProps = ({ clearError, validate, defaultValue, touched, setTouched, hasBeenSubmitted, validationBehavior, name, }) => {
7
+ const validationBehaviors = {
8
+ ...defaultValidationBehavior,
9
+ ...validationBehavior,
10
+ };
11
+ return (props = {}) => {
12
+ const behavior = hasBeenSubmitted
13
+ ? validationBehaviors.whenSubmitted
14
+ : touched
15
+ ? validationBehaviors.whenTouched
16
+ : validationBehaviors.initial;
17
+ const result = {
18
+ ...props,
19
+ onChange: (...args) => {
20
+ var _a;
21
+ if (behavior === "onChange")
22
+ validate();
23
+ else
24
+ clearError();
25
+ return (_a = props === null || props === void 0 ? void 0 : props.onChange) === null || _a === void 0 ? void 0 : _a.call(props, ...args);
26
+ },
27
+ onBlur: (...args) => {
28
+ var _a;
29
+ if (behavior === "onBlur")
30
+ validate();
31
+ setTouched(true);
32
+ return (_a = props === null || props === void 0 ? void 0 : props.onBlur) === null || _a === void 0 ? void 0 : _a.call(props, ...args);
33
+ },
34
+ defaultValue,
35
+ name,
36
+ };
37
+ return result;
38
+ };
39
+ };
File without changes
@@ -0,0 +1,15 @@
1
+ "use strict";
2
+ z.preprocess((val) => {
3
+ // Somewhat awkward -- this gets processed per item in the form,
4
+ // but as a whole array in the backend
5
+ if (Array.isArray(val)) {
6
+ return val;
7
+ }
8
+ else {
9
+ return [val];
10
+ }
11
+ }, z.array(z.preprocess((val) => {
12
+ return typeof val !== "string" || val === ""
13
+ ? undefined
14
+ : Number.parseInt(val);
15
+ }, z.number())));
@@ -1,4 +1,5 @@
1
1
  export declare type FieldErrors = Record<string, string>;
2
+ export declare type TouchedFields = Record<string, boolean>;
2
3
  export declare type FieldErrorsWithData = FieldErrors & {
3
4
  _submittedData: any;
4
5
  };
@@ -9,7 +9,7 @@ const react_1 = require("@remix-run/react");
9
9
  const react_2 = require("react");
10
10
  const tiny_invariant_1 = __importDefault(require("tiny-invariant"));
11
11
  const formContext_1 = require("./internal/formContext");
12
- const SingleTypeMultiValueMap_1 = require("./internal/SingleTypeMultiValueMap");
12
+ const MultiValueMap_1 = require("./internal/MultiValueMap");
13
13
  const submissionCallbacks_1 = require("./internal/submissionCallbacks");
14
14
  const util_1 = require("./internal/util");
15
15
  function useFieldErrorsFromBackend(fetcher, subaction) {
@@ -102,6 +102,8 @@ function ValidatedForm({ validator, onSubmit, children, fetcher, action, default
102
102
  const [fieldErrors, setFieldErrors] = useFieldErrors(fieldErrorsFromBackend);
103
103
  const isSubmitting = useIsSubmitting(action, subaction, fetcher);
104
104
  const defaultsToUse = useDefaultValues(fieldErrorsFromBackend, defaultValues);
105
+ const [touchedFields, setTouchedFields] = (0, react_2.useState)({});
106
+ const [hasBeenSubmitted, setHasBeenSubmitted] = (0, react_2.useState)(false);
105
107
  const formRef = (0, react_2.useRef)(null);
106
108
  (0, submissionCallbacks_1.useSubmitComplete)(isSubmitting, () => {
107
109
  var _a;
@@ -109,24 +111,42 @@ function ValidatedForm({ validator, onSubmit, children, fetcher, action, default
109
111
  (_a = formRef.current) === null || _a === void 0 ? void 0 : _a.reset();
110
112
  }
111
113
  });
112
- const customFocusHandlers = (0, SingleTypeMultiValueMap_1.useMultiValueMap)();
114
+ const customFocusHandlers = (0, MultiValueMap_1.useMultiValueMap)();
113
115
  const contextValue = (0, react_2.useMemo)(() => ({
114
116
  fieldErrors,
115
117
  action,
116
118
  defaultValues: defaultsToUse,
117
119
  isSubmitting: isSubmitting !== null && isSubmitting !== void 0 ? isSubmitting : false,
118
120
  isValid: Object.keys(fieldErrors).length === 0,
121
+ touchedFields,
122
+ setFieldTouched: (fieldName, touched) => setTouchedFields((prev) => ({
123
+ ...prev,
124
+ [fieldName]: touched,
125
+ })),
119
126
  clearError: (fieldName) => {
120
127
  setFieldErrors((prev) => (0, util_1.omit)(prev, fieldName));
121
128
  },
122
129
  validateField: (fieldName) => {
123
130
  (0, tiny_invariant_1.default)(formRef.current, "Cannot find reference to form");
124
131
  const { error } = validator.validateField(getDataFromForm(formRef.current), fieldName);
132
+ // By checking and returning `prev` here, we can avoid a re-render
133
+ // if the validation state is the same.
125
134
  if (error) {
126
- setFieldErrors((prev) => ({
127
- ...prev,
128
- [fieldName]: error,
129
- }));
135
+ setFieldErrors((prev) => {
136
+ if (prev[fieldName] === error)
137
+ return prev;
138
+ return {
139
+ ...prev,
140
+ [fieldName]: error,
141
+ };
142
+ });
143
+ }
144
+ else {
145
+ setFieldErrors((prev) => {
146
+ if (!(fieldName in prev))
147
+ return prev;
148
+ return (0, util_1.omit)(prev, fieldName);
149
+ });
130
150
  }
131
151
  },
132
152
  registerReceiveFocus: (fieldName, handler) => {
@@ -135,17 +155,21 @@ function ValidatedForm({ validator, onSubmit, children, fetcher, action, default
135
155
  customFocusHandlers().remove(fieldName, handler);
136
156
  };
137
157
  },
158
+ hasBeenSubmitted,
138
159
  }), [
139
160
  fieldErrors,
140
161
  action,
141
162
  defaultsToUse,
142
163
  isSubmitting,
164
+ touchedFields,
165
+ hasBeenSubmitted,
143
166
  setFieldErrors,
144
167
  validator,
145
168
  customFocusHandlers,
146
169
  ]);
147
170
  const Form = (_a = fetcher === null || fetcher === void 0 ? void 0 : fetcher.Form) !== null && _a !== void 0 ? _a : react_1.Form;
148
171
  return ((0, jsx_runtime_1.jsx)(Form, { ref: (0, util_1.mergeRefs)([formRef, formRefProp]), ...rest, action: action, onSubmit: (event) => {
172
+ setHasBeenSubmitted(true);
149
173
  const result = validator.validate(getDataFromForm(event.currentTarget));
150
174
  if (result.error) {
151
175
  event.preventDefault();
@@ -162,6 +186,8 @@ function ValidatedForm({ validator, onSubmit, children, fetcher, action, default
162
186
  if (event.defaultPrevented)
163
187
  return;
164
188
  setFieldErrors({});
189
+ setTouchedFields({});
190
+ setHasBeenSubmitted(false);
165
191
  }, children: (0, jsx_runtime_1.jsxs)(formContext_1.FormContext.Provider, { value: contextValue, children: [subaction && ((0, jsx_runtime_1.jsx)("input", { type: "hidden", value: subaction, name: "subaction" }, void 0)), children] }, void 0) }, void 0));
166
192
  }
167
193
  exports.ValidatedForm = ValidatedForm;
package/build/hooks.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { GetInputProps, ValidationBehaviorOptions } from "./internal/getInputProps";
1
2
  export declare type FieldProps = {
2
3
  /**
3
4
  * The validation error message if there is one.
@@ -15,6 +16,18 @@ export declare type FieldProps = {
15
16
  * The default value of the field, if there is one.
16
17
  */
17
18
  defaultValue?: any;
19
+ /**
20
+ * Whether or not the field has been touched.
21
+ */
22
+ touched: boolean;
23
+ /**
24
+ * Helper to set the touched state of the field.
25
+ */
26
+ setTouched: (touched: boolean) => void;
27
+ /**
28
+ * Helper to get all the props necessary for a regular input.
29
+ */
30
+ getInputProps: GetInputProps;
18
31
  };
19
32
  /**
20
33
  * Provides the data and helpers necessary to set up a field.
@@ -26,6 +39,10 @@ export declare const useField: (name: string, options?: {
26
39
  * This is useful for custom components that use a hidden input.
27
40
  */
28
41
  handleReceiveFocus?: (() => void) | undefined;
42
+ /**
43
+ * Allows you to specify when a field gets validated (when using getInputProps)
44
+ */
45
+ validationBehavior?: Partial<ValidationBehaviorOptions> | undefined;
29
46
  } | undefined) => FieldProps;
30
47
  /**
31
48
  * Provides access to the entire form context.
package/build/hooks.js CHANGED
@@ -8,26 +8,52 @@ const get_1 = __importDefault(require("lodash/get"));
8
8
  const toPath_1 = __importDefault(require("lodash/toPath"));
9
9
  const react_1 = require("react");
10
10
  const formContext_1 = require("./internal/formContext");
11
+ const getInputProps_1 = require("./internal/getInputProps");
11
12
  /**
12
13
  * Provides the data and helpers necessary to set up a field.
13
14
  */
14
15
  const useField = (name, options) => {
15
- const { fieldErrors, clearError, validateField, defaultValues, registerReceiveFocus, } = (0, react_1.useContext)(formContext_1.FormContext);
16
+ const { fieldErrors, clearError, validateField, defaultValues, registerReceiveFocus, touchedFields, setFieldTouched, hasBeenSubmitted, } = (0, react_1.useContext)(formContext_1.FormContext);
17
+ const isTouched = !!touchedFields[name];
16
18
  const { handleReceiveFocus } = options !== null && options !== void 0 ? options : {};
17
19
  (0, react_1.useEffect)(() => {
18
20
  if (handleReceiveFocus)
19
21
  return registerReceiveFocus(name, handleReceiveFocus);
20
22
  }, [handleReceiveFocus, name, registerReceiveFocus]);
21
- const field = (0, react_1.useMemo)(() => ({
22
- error: fieldErrors[name],
23
- clearError: () => {
24
- clearError(name);
25
- },
26
- validate: () => validateField(name),
27
- defaultValue: defaultValues
28
- ? (0, get_1.default)(defaultValues, (0, toPath_1.default)(name), undefined)
29
- : undefined,
30
- }), [clearError, defaultValues, fieldErrors, name, validateField]);
23
+ const field = (0, react_1.useMemo)(() => {
24
+ const helpers = {
25
+ error: fieldErrors[name],
26
+ clearError: () => {
27
+ clearError(name);
28
+ },
29
+ validate: () => validateField(name),
30
+ defaultValue: defaultValues
31
+ ? (0, get_1.default)(defaultValues, (0, toPath_1.default)(name), undefined)
32
+ : undefined,
33
+ touched: isTouched,
34
+ setTouched: (touched) => setFieldTouched(name, touched),
35
+ };
36
+ const getInputProps = (0, getInputProps_1.createGetInputProps)({
37
+ ...helpers,
38
+ name,
39
+ hasBeenSubmitted,
40
+ validationBehavior: options === null || options === void 0 ? void 0 : options.validationBehavior,
41
+ });
42
+ return {
43
+ ...helpers,
44
+ getInputProps,
45
+ };
46
+ }, [
47
+ fieldErrors,
48
+ name,
49
+ defaultValues,
50
+ isTouched,
51
+ hasBeenSubmitted,
52
+ options === null || options === void 0 ? void 0 : options.validationBehavior,
53
+ clearError,
54
+ validateField,
55
+ setFieldTouched,
56
+ ]);
31
57
  return field;
32
58
  };
33
59
  exports.useField = useField;
@@ -0,0 +1,9 @@
1
+ export declare class MultiValueMap<Key, Value> {
2
+ private dict;
3
+ add: (key: Key, value: Value) => void;
4
+ remove: (key: Key, value: Value) => void;
5
+ getAll: (key: Key) => Value[];
6
+ entries: () => IterableIterator<[Key, Value[]]>;
7
+ has: (key: Key) => boolean;
8
+ }
9
+ export declare const useMultiValueMap: <Key, Value>() => () => MultiValueMap<Key, Value>;
@@ -0,0 +1,44 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.useMultiValueMap = exports.MultiValueMap = void 0;
4
+ const react_1 = require("react");
5
+ class MultiValueMap {
6
+ constructor() {
7
+ this.dict = new Map();
8
+ this.add = (key, value) => {
9
+ if (this.dict.has(key)) {
10
+ this.dict.get(key).push(value);
11
+ }
12
+ else {
13
+ this.dict.set(key, [value]);
14
+ }
15
+ };
16
+ this.remove = (key, value) => {
17
+ if (!this.dict.has(key))
18
+ return;
19
+ const array = this.dict.get(key);
20
+ const index = array.indexOf(value);
21
+ if (index !== -1)
22
+ array.splice(index, 1);
23
+ if (array.length === 0)
24
+ this.dict.delete(key);
25
+ };
26
+ this.getAll = (key) => {
27
+ var _a;
28
+ return (_a = this.dict.get(key)) !== null && _a !== void 0 ? _a : [];
29
+ };
30
+ this.entries = () => this.dict.entries();
31
+ this.has = (key) => this.dict.has(key);
32
+ }
33
+ }
34
+ exports.MultiValueMap = MultiValueMap;
35
+ const useMultiValueMap = () => {
36
+ const ref = (0, react_1.useRef)(null);
37
+ return () => {
38
+ if (ref.current)
39
+ return ref.current;
40
+ ref.current = new MultiValueMap();
41
+ return ref.current;
42
+ };
43
+ };
44
+ exports.useMultiValueMap = useMultiValueMap;
@@ -12,7 +12,12 @@ const keys_1 = __importDefault(require("lodash/keys"));
12
12
  const mapKeys_1 = __importDefault(require("lodash/mapKeys"));
13
13
  const set_1 = __importDefault(require("lodash/set"));
14
14
  const transform_1 = __importDefault(require("lodash/transform"));
15
- const objectFromPathEntries = (entries) => entries.reduce((acc, [key, value]) => (0, set_1.default)(acc, key, value), {});
15
+ const MultiValueMap_1 = require("./MultiValueMap");
16
+ const objectFromPathEntries = (entries) => {
17
+ const map = new MultiValueMap_1.MultiValueMap();
18
+ entries.forEach(([key, value]) => map.add(key, value));
19
+ return [...map.entries()].reduce((acc, [key, value]) => (0, set_1.default)(acc, key, value.length === 1 ? value[0] : value), {});
20
+ };
16
21
  exports.objectFromPathEntries = objectFromPathEntries;
17
22
  /** Flatten an object so there are no nested objects or arrays */
18
23
  function flatten(obj, preserveEmpty = false) {
@@ -1,5 +1,5 @@
1
1
  /// <reference types="react" />
2
- import { FieldErrors } from "../validation/types";
2
+ import { FieldErrors, TouchedFields } from "../validation/types";
3
3
  export declare type FormContextValue = {
4
4
  /**
5
5
  * All the errors in all the fields in the form.
@@ -21,6 +21,12 @@ export declare type FormContextValue = {
21
21
  * Whether or not the form is submitting.
22
22
  */
23
23
  isSubmitting: boolean;
24
+ /**
25
+ * Whether or not a submission has been attempted.
26
+ * This is true once the form has been submitted, even if there were validation errors.
27
+ * Resets to false when the form is reset.
28
+ */
29
+ hasBeenSubmitted: boolean;
24
30
  /**
25
31
  * Whether or not the form is valid.
26
32
  * This is a shortcut for `Object.keys(fieldErrors).length === 0`.
@@ -37,5 +43,13 @@ export declare type FormContextValue = {
37
43
  * the field needs to receive focus due to a validation error.
38
44
  */
39
45
  registerReceiveFocus: (fieldName: string, handler: () => void) => () => void;
46
+ /**
47
+ * Any fields that have been touched by the user.
48
+ */
49
+ touchedFields: TouchedFields;
50
+ /**
51
+ * Change the touched state of the specified field.
52
+ */
53
+ setFieldTouched: (fieldName: string, touched: boolean) => void;
40
54
  };
41
55
  export declare const FormContext: import("react").Context<FormContextValue>;
@@ -7,6 +7,9 @@ exports.FormContext = (0, react_1.createContext)({
7
7
  clearError: () => { },
8
8
  validateField: () => { },
9
9
  isSubmitting: false,
10
+ hasBeenSubmitted: false,
10
11
  isValid: true,
11
12
  registerReceiveFocus: () => () => { },
13
+ touchedFields: {},
14
+ setFieldTouched: () => { },
12
15
  });
@@ -0,0 +1,21 @@
1
+ export declare type ValidationBehavior = "onBlur" | "onChange" | "onSubmit";
2
+ export declare type ValidationBehaviorOptions = {
3
+ initial: ValidationBehavior;
4
+ whenTouched: ValidationBehavior;
5
+ whenSubmitted: ValidationBehavior;
6
+ };
7
+ export declare type CreateGetInputPropsOptions = {
8
+ clearError: () => void;
9
+ validate: () => void;
10
+ defaultValue?: any;
11
+ touched: boolean;
12
+ setTouched: (touched: boolean) => void;
13
+ hasBeenSubmitted: boolean;
14
+ validationBehavior?: Partial<ValidationBehaviorOptions>;
15
+ name: string;
16
+ };
17
+ declare type HandledProps = "name" | "defaultValue";
18
+ declare type Callbacks = "onChange" | "onBlur";
19
+ export declare type GetInputProps = <T extends Record<string, any>>(props?: Omit<T, HandledProps | Callbacks> & Partial<Pick<T, Callbacks>>) => T;
20
+ export declare const createGetInputProps: ({ clearError, validate, defaultValue, touched, setTouched, hasBeenSubmitted, validationBehavior, name, }: CreateGetInputPropsOptions) => GetInputProps;
21
+ export {};
@@ -0,0 +1,43 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createGetInputProps = void 0;
4
+ const defaultValidationBehavior = {
5
+ initial: "onBlur",
6
+ whenTouched: "onChange",
7
+ whenSubmitted: "onChange",
8
+ };
9
+ const createGetInputProps = ({ clearError, validate, defaultValue, touched, setTouched, hasBeenSubmitted, validationBehavior, name, }) => {
10
+ const validationBehaviors = {
11
+ ...defaultValidationBehavior,
12
+ ...validationBehavior,
13
+ };
14
+ return (props = {}) => {
15
+ const behavior = hasBeenSubmitted
16
+ ? validationBehaviors.whenSubmitted
17
+ : touched
18
+ ? validationBehaviors.whenTouched
19
+ : validationBehaviors.initial;
20
+ const result = {
21
+ ...props,
22
+ onChange: (...args) => {
23
+ var _a;
24
+ if (behavior === "onChange")
25
+ validate();
26
+ else
27
+ clearError();
28
+ return (_a = props === null || props === void 0 ? void 0 : props.onChange) === null || _a === void 0 ? void 0 : _a.call(props, ...args);
29
+ },
30
+ onBlur: (...args) => {
31
+ var _a;
32
+ if (behavior === "onBlur")
33
+ validate();
34
+ setTouched(true);
35
+ return (_a = props === null || props === void 0 ? void 0 : props.onBlur) === null || _a === void 0 ? void 0 : _a.call(props, ...args);
36
+ },
37
+ defaultValue,
38
+ name,
39
+ };
40
+ return result;
41
+ };
42
+ };
43
+ exports.createGetInputProps = createGetInputProps;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,12 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const jsx_runtime_1 = require("react/jsx-runtime");
4
+ const getInputProps_1 = require("./getInputProps");
5
+ const CompRequired = (props) => null;
6
+ const getProps = (0, getInputProps_1.createGetInputProps)({});
7
+ (0, jsx_runtime_1.jsx)(CompRequired, { ...getProps({
8
+ temp: 21,
9
+ bob: "ross",
10
+ onBlur: () => { },
11
+ // onChange: () => {},
12
+ }) }, void 0);
@@ -1,4 +1,5 @@
1
1
  export declare type FieldErrors = Record<string, string>;
2
+ export declare type TouchedFields = Record<string, boolean>;
2
3
  export declare type FieldErrorsWithData = FieldErrors & {
3
4
  _submittedData: any;
4
5
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "remix-validated-form",
3
- "version": "3.1.1",
3
+ "version": "3.3.0",
4
4
  "description": "Form component and utils for easy form validation in remix",
5
5
  "browser": "./browser/index.js",
6
6
  "main": "./build/index.js",
@@ -14,16 +14,14 @@ import React, {
14
14
  } from "react";
15
15
  import invariant from "tiny-invariant";
16
16
  import { FormContext, FormContextValue } from "./internal/formContext";
17
- import {
18
- MultiValueMap,
19
- useMultiValueMap,
20
- } from "./internal/SingleTypeMultiValueMap";
17
+ import { MultiValueMap, useMultiValueMap } from "./internal/MultiValueMap";
21
18
  import { useSubmitComplete } from "./internal/submissionCallbacks";
22
19
  import { omit, mergeRefs } from "./internal/util";
23
20
  import {
24
21
  FieldErrors,
25
22
  Validator,
26
23
  FieldErrorsWithData,
24
+ TouchedFields,
27
25
  } from "./validation/types";
28
26
 
29
27
  export type FormProps<DataType> = {
@@ -196,6 +194,8 @@ export function ValidatedForm<DataType>({
196
194
  const [fieldErrors, setFieldErrors] = useFieldErrors(fieldErrorsFromBackend);
197
195
  const isSubmitting = useIsSubmitting(action, subaction, fetcher);
198
196
  const defaultsToUse = useDefaultValues(fieldErrorsFromBackend, defaultValues);
197
+ const [touchedFields, setTouchedFields] = useState<TouchedFields>({});
198
+ const [hasBeenSubmitted, setHasBeenSubmitted] = useState(false);
199
199
  const formRef = useRef<HTMLFormElement>(null);
200
200
  useSubmitComplete(isSubmitting, () => {
201
201
  if (!fieldErrorsFromBackend && resetAfterSubmit) {
@@ -211,6 +211,12 @@ export function ValidatedForm<DataType>({
211
211
  defaultValues: defaultsToUse,
212
212
  isSubmitting: isSubmitting ?? false,
213
213
  isValid: Object.keys(fieldErrors).length === 0,
214
+ touchedFields,
215
+ setFieldTouched: (fieldName: string, touched: boolean) =>
216
+ setTouchedFields((prev) => ({
217
+ ...prev,
218
+ [fieldName]: touched,
219
+ })),
214
220
  clearError: (fieldName) => {
215
221
  setFieldErrors((prev) => omit(prev, fieldName));
216
222
  },
@@ -220,11 +226,22 @@ export function ValidatedForm<DataType>({
220
226
  getDataFromForm(formRef.current),
221
227
  fieldName as any
222
228
  );
229
+
230
+ // By checking and returning `prev` here, we can avoid a re-render
231
+ // if the validation state is the same.
223
232
  if (error) {
224
- setFieldErrors((prev) => ({
225
- ...prev,
226
- [fieldName]: error,
227
- }));
233
+ setFieldErrors((prev) => {
234
+ if (prev[fieldName] === error) return prev;
235
+ return {
236
+ ...prev,
237
+ [fieldName]: error,
238
+ };
239
+ });
240
+ } else {
241
+ setFieldErrors((prev) => {
242
+ if (!(fieldName in prev)) return prev;
243
+ return omit(prev, fieldName);
244
+ });
228
245
  }
229
246
  },
230
247
  registerReceiveFocus: (fieldName, handler) => {
@@ -233,12 +250,15 @@ export function ValidatedForm<DataType>({
233
250
  customFocusHandlers().remove(fieldName, handler);
234
251
  };
235
252
  },
253
+ hasBeenSubmitted,
236
254
  }),
237
255
  [
238
256
  fieldErrors,
239
257
  action,
240
258
  defaultsToUse,
241
259
  isSubmitting,
260
+ touchedFields,
261
+ hasBeenSubmitted,
242
262
  setFieldErrors,
243
263
  validator,
244
264
  customFocusHandlers,
@@ -253,6 +273,7 @@ export function ValidatedForm<DataType>({
253
273
  {...rest}
254
274
  action={action}
255
275
  onSubmit={(event) => {
276
+ setHasBeenSubmitted(true);
256
277
  const result = validator.validate(getDataFromForm(event.currentTarget));
257
278
  if (result.error) {
258
279
  event.preventDefault();
@@ -272,6 +293,8 @@ export function ValidatedForm<DataType>({
272
293
  onReset?.(event);
273
294
  if (event.defaultPrevented) return;
274
295
  setFieldErrors({});
296
+ setTouchedFields({});
297
+ setHasBeenSubmitted(false);
275
298
  }}
276
299
  >
277
300
  <FormContext.Provider value={contextValue}>
package/src/hooks.ts CHANGED
@@ -2,6 +2,11 @@ import get from "lodash/get";
2
2
  import toPath from "lodash/toPath";
3
3
  import { useContext, useEffect, useMemo } from "react";
4
4
  import { FormContext } from "./internal/formContext";
5
+ import {
6
+ createGetInputProps,
7
+ GetInputProps,
8
+ ValidationBehaviorOptions,
9
+ } from "./internal/getInputProps";
5
10
 
6
11
  export type FieldProps = {
7
12
  /**
@@ -20,6 +25,18 @@ export type FieldProps = {
20
25
  * The default value of the field, if there is one.
21
26
  */
22
27
  defaultValue?: any;
28
+ /**
29
+ * Whether or not the field has been touched.
30
+ */
31
+ touched: boolean;
32
+ /**
33
+ * Helper to set the touched state of the field.
34
+ */
35
+ setTouched: (touched: boolean) => void;
36
+ /**
37
+ * Helper to get all the props necessary for a regular input.
38
+ */
39
+ getInputProps: GetInputProps;
23
40
  };
24
41
 
25
42
  /**
@@ -34,6 +51,10 @@ export const useField = (
34
51
  * This is useful for custom components that use a hidden input.
35
52
  */
36
53
  handleReceiveFocus?: () => void;
54
+ /**
55
+ * Allows you to specify when a field gets validated (when using getInputProps)
56
+ */
57
+ validationBehavior?: Partial<ValidationBehaviorOptions>;
37
58
  }
38
59
  ): FieldProps => {
39
60
  const {
@@ -42,8 +63,12 @@ export const useField = (
42
63
  validateField,
43
64
  defaultValues,
44
65
  registerReceiveFocus,
66
+ touchedFields,
67
+ setFieldTouched,
68
+ hasBeenSubmitted,
45
69
  } = useContext(FormContext);
46
70
 
71
+ const isTouched = !!touchedFields[name];
47
72
  const { handleReceiveFocus } = options ?? {};
48
73
 
49
74
  useEffect(() => {
@@ -51,8 +76,8 @@ export const useField = (
51
76
  return registerReceiveFocus(name, handleReceiveFocus);
52
77
  }, [handleReceiveFocus, name, registerReceiveFocus]);
53
78
 
54
- const field = useMemo<FieldProps>(
55
- () => ({
79
+ const field = useMemo<FieldProps>(() => {
80
+ const helpers = {
56
81
  error: fieldErrors[name],
57
82
  clearError: () => {
58
83
  clearError(name);
@@ -61,9 +86,30 @@ export const useField = (
61
86
  defaultValue: defaultValues
62
87
  ? get(defaultValues, toPath(name), undefined)
63
88
  : undefined,
64
- }),
65
- [clearError, defaultValues, fieldErrors, name, validateField]
66
- );
89
+ touched: isTouched,
90
+ setTouched: (touched: boolean) => setFieldTouched(name, touched),
91
+ };
92
+ const getInputProps = createGetInputProps({
93
+ ...helpers,
94
+ name,
95
+ hasBeenSubmitted,
96
+ validationBehavior: options?.validationBehavior,
97
+ });
98
+ return {
99
+ ...helpers,
100
+ getInputProps,
101
+ };
102
+ }, [
103
+ fieldErrors,
104
+ name,
105
+ defaultValues,
106
+ isTouched,
107
+ hasBeenSubmitted,
108
+ options?.validationBehavior,
109
+ clearError,
110
+ validateField,
111
+ setFieldTouched,
112
+ ]);
67
113
 
68
114
  return field;
69
115
  };
@@ -4,7 +4,6 @@ export class MultiValueMap<Key, Value> {
4
4
  private dict: Map<Key, Value[]> = new Map();
5
5
 
6
6
  add = (key: Key, value: Value) => {
7
- this.dict.set(key, [...(this.dict.get(key) ?? []), value]);
8
7
  if (this.dict.has(key)) {
9
8
  this.dict.get(key)!.push(value);
10
9
  } else {
@@ -24,6 +23,8 @@ export class MultiValueMap<Key, Value> {
24
23
  return this.dict.get(key) ?? [];
25
24
  };
26
25
 
26
+ entries = (): IterableIterator<[Key, Value[]]> => this.dict.entries();
27
+
27
28
  has = (key: Key): boolean => this.dict.has(key);
28
29
  }
29
30
 
@@ -7,9 +7,16 @@ import mapKeys from "lodash/mapKeys";
7
7
  import set from "lodash/set";
8
8
  import transform from "lodash/transform";
9
9
  import { GenericObject } from "..";
10
+ import { MultiValueMap } from "./MultiValueMap";
10
11
 
11
- export const objectFromPathEntries = (entries: [string, any][]) =>
12
- entries.reduce((acc, [key, value]) => set(acc, key, value), {});
12
+ export const objectFromPathEntries = (entries: [string, any][]) => {
13
+ const map = new MultiValueMap<string, any>();
14
+ entries.forEach(([key, value]) => map.add(key, value));
15
+ return [...map.entries()].reduce(
16
+ (acc, [key, value]) => set(acc, key, value.length === 1 ? value[0] : value),
17
+ {}
18
+ );
19
+ };
13
20
 
14
21
  /** Flatten an object so there are no nested objects or arrays */
15
22
  export function flatten(obj: GenericObject, preserveEmpty = false) {
@@ -1,5 +1,5 @@
1
1
  import { createContext } from "react";
2
- import { FieldErrors } from "../validation/types";
2
+ import { FieldErrors, TouchedFields } from "../validation/types";
3
3
 
4
4
  export type FormContextValue = {
5
5
  /**
@@ -22,6 +22,12 @@ export type FormContextValue = {
22
22
  * Whether or not the form is submitting.
23
23
  */
24
24
  isSubmitting: boolean;
25
+ /**
26
+ * Whether or not a submission has been attempted.
27
+ * This is true once the form has been submitted, even if there were validation errors.
28
+ * Resets to false when the form is reset.
29
+ */
30
+ hasBeenSubmitted: boolean;
25
31
  /**
26
32
  * Whether or not the form is valid.
27
33
  * This is a shortcut for `Object.keys(fieldErrors).length === 0`.
@@ -36,6 +42,14 @@ export type FormContextValue = {
36
42
  * the field needs to receive focus due to a validation error.
37
43
  */
38
44
  registerReceiveFocus: (fieldName: string, handler: () => void) => () => void;
45
+ /**
46
+ * Any fields that have been touched by the user.
47
+ */
48
+ touchedFields: TouchedFields;
49
+ /**
50
+ * Change the touched state of the specified field.
51
+ */
52
+ setFieldTouched: (fieldName: string, touched: boolean) => void;
39
53
  };
40
54
 
41
55
  export const FormContext = createContext<FormContextValue>({
@@ -43,6 +57,9 @@ export const FormContext = createContext<FormContextValue>({
43
57
  clearError: () => {},
44
58
  validateField: () => {},
45
59
  isSubmitting: false,
60
+ hasBeenSubmitted: false,
46
61
  isValid: true,
47
62
  registerReceiveFocus: () => () => {},
63
+ touchedFields: {},
64
+ setFieldTouched: () => {},
48
65
  });
@@ -0,0 +1,73 @@
1
+ export type ValidationBehavior = "onBlur" | "onChange" | "onSubmit";
2
+
3
+ export type ValidationBehaviorOptions = {
4
+ initial: ValidationBehavior;
5
+ whenTouched: ValidationBehavior;
6
+ whenSubmitted: ValidationBehavior;
7
+ };
8
+
9
+ export type CreateGetInputPropsOptions = {
10
+ clearError: () => void;
11
+ validate: () => void;
12
+ defaultValue?: any;
13
+ touched: boolean;
14
+ setTouched: (touched: boolean) => void;
15
+ hasBeenSubmitted: boolean;
16
+ validationBehavior?: Partial<ValidationBehaviorOptions>;
17
+ name: string;
18
+ };
19
+
20
+ type HandledProps = "name" | "defaultValue";
21
+ type Callbacks = "onChange" | "onBlur";
22
+
23
+ export type GetInputProps = <T extends Record<string, any>>(
24
+ props?: Omit<T, HandledProps | Callbacks> & Partial<Pick<T, Callbacks>>
25
+ ) => T;
26
+
27
+ const defaultValidationBehavior: ValidationBehaviorOptions = {
28
+ initial: "onBlur",
29
+ whenTouched: "onChange",
30
+ whenSubmitted: "onChange",
31
+ };
32
+
33
+ export const createGetInputProps = ({
34
+ clearError,
35
+ validate,
36
+ defaultValue,
37
+ touched,
38
+ setTouched,
39
+ hasBeenSubmitted,
40
+ validationBehavior,
41
+ name,
42
+ }: CreateGetInputPropsOptions): GetInputProps => {
43
+ const validationBehaviors = {
44
+ ...defaultValidationBehavior,
45
+ ...validationBehavior,
46
+ };
47
+
48
+ return <T extends Record<string, any>>(props = {} as any) => {
49
+ const behavior = hasBeenSubmitted
50
+ ? validationBehaviors.whenSubmitted
51
+ : touched
52
+ ? validationBehaviors.whenTouched
53
+ : validationBehaviors.initial;
54
+
55
+ const result: T = {
56
+ ...props,
57
+ onChange: (...args: unknown[]) => {
58
+ if (behavior === "onChange") validate();
59
+ else clearError();
60
+ return props?.onChange?.(...args);
61
+ },
62
+ onBlur: (...args: unknown[]) => {
63
+ if (behavior === "onBlur") validate();
64
+ setTouched(true);
65
+ return props?.onBlur?.(...args);
66
+ },
67
+ defaultValue,
68
+ name,
69
+ };
70
+
71
+ return result;
72
+ };
73
+ };
@@ -1,5 +1,7 @@
1
1
  export type FieldErrors = Record<string, string>;
2
2
 
3
+ export type TouchedFields = Record<string, boolean>;
4
+
3
5
  export type FieldErrorsWithData = FieldErrors & { _submittedData: any };
4
6
 
5
7
  export type GenericObject = { [key: string]: any };