remix-validated-form 4.0.1-beta.2 → 4.1.0-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.
- package/.turbo/turbo-build.log +2 -2
- package/README.md +4 -4
- package/browser/ValidatedForm.d.ts +2 -2
- package/browser/ValidatedForm.js +137 -149
- package/browser/components.d.ts +5 -8
- package/browser/components.js +5 -5
- package/browser/hooks.d.ts +19 -14
- package/browser/hooks.js +41 -39
- package/browser/index.d.ts +1 -1
- package/browser/index.js +1 -0
- package/browser/internal/constants.d.ts +3 -0
- package/browser/internal/constants.js +3 -0
- package/browser/internal/formContext.d.ts +7 -49
- package/browser/internal/formContext.js +1 -1
- package/browser/internal/getInputProps.js +4 -3
- package/browser/internal/hooks.d.ts +22 -0
- package/browser/internal/hooks.js +110 -0
- package/browser/internal/state.d.ts +269 -0
- package/browser/internal/state.js +82 -0
- package/browser/internal/util.d.ts +1 -0
- package/browser/internal/util.js +2 -0
- package/browser/lowLevelHooks.d.ts +0 -0
- package/browser/lowLevelHooks.js +1 -0
- package/browser/server.d.ts +5 -0
- package/browser/server.js +5 -0
- package/browser/userFacingFormContext.d.ts +56 -0
- package/browser/userFacingFormContext.js +40 -0
- package/browser/validation/createValidator.js +4 -0
- package/browser/validation/types.d.ts +3 -0
- package/build/ValidatedForm.d.ts +2 -2
- package/build/ValidatedForm.js +133 -145
- package/build/hooks.d.ts +19 -14
- package/build/hooks.js +43 -45
- package/build/index.d.ts +1 -1
- package/build/index.js +1 -0
- package/build/internal/constants.d.ts +3 -0
- package/build/internal/constants.js +7 -0
- package/build/internal/formContext.d.ts +7 -49
- package/build/internal/formContext.js +2 -2
- package/build/internal/getInputProps.js +7 -3
- package/build/internal/hooks.d.ts +22 -0
- package/build/internal/hooks.js +130 -0
- package/build/internal/state.d.ts +269 -0
- package/build/internal/state.js +92 -0
- package/build/internal/util.d.ts +1 -0
- package/build/internal/util.js +3 -1
- package/build/server.d.ts +5 -0
- package/build/server.js +7 -1
- package/build/userFacingFormContext.d.ts +56 -0
- package/build/userFacingFormContext.js +44 -0
- package/build/validation/createValidator.js +4 -0
- package/build/validation/types.d.ts +3 -0
- package/package.json +3 -1
- package/src/ValidatedForm.tsx +199 -200
- package/src/hooks.ts +71 -54
- package/src/index.ts +1 -1
- package/src/internal/constants.ts +4 -0
- package/src/internal/formContext.ts +8 -49
- package/src/internal/getInputProps.ts +6 -4
- package/src/internal/hooks.ts +191 -0
- package/src/internal/state.ts +210 -0
- package/src/internal/util.ts +4 -0
- package/src/server.ts +16 -0
- package/src/userFacingFormContext.ts +129 -0
- package/src/validation/createValidator.ts +4 -0
- package/src/validation/types.ts +3 -1
package/src/hooks.ts
CHANGED
@@ -1,20 +1,46 @@
|
|
1
|
-
import
|
2
|
-
import toPath from "lodash/toPath";
|
3
|
-
import { useContext, useEffect, useMemo } from "react";
|
4
|
-
import { FormContext } from "./internal/formContext";
|
1
|
+
import { useEffect, useMemo } from "react";
|
5
2
|
import {
|
6
3
|
createGetInputProps,
|
7
4
|
GetInputProps,
|
8
5
|
ValidationBehaviorOptions,
|
9
6
|
} from "./internal/getInputProps";
|
7
|
+
import {
|
8
|
+
useInternalFormContext,
|
9
|
+
useFieldTouched,
|
10
|
+
useFieldError,
|
11
|
+
useFieldDefaultValue,
|
12
|
+
useContextSelectAtom,
|
13
|
+
useClearError,
|
14
|
+
useSetTouched,
|
15
|
+
} from "./internal/hooks";
|
16
|
+
import {
|
17
|
+
hasBeenSubmittedAtom,
|
18
|
+
isSubmittingAtom,
|
19
|
+
isValidAtom,
|
20
|
+
registerReceiveFocusAtom,
|
21
|
+
validateFieldAtom,
|
22
|
+
} from "./internal/state";
|
23
|
+
|
24
|
+
/**
|
25
|
+
* Returns whether or not the parent form is currently being submitted.
|
26
|
+
* This is different from remix's `useTransition().submission` in that it
|
27
|
+
* is aware of what form it's in and when _that_ form is being submitted.
|
28
|
+
*
|
29
|
+
* @param formId
|
30
|
+
*/
|
31
|
+
export const useIsSubmitting = (formId?: string) => {
|
32
|
+
const formContext = useInternalFormContext(formId, "useIsSubmitting");
|
33
|
+
return useContextSelectAtom(formContext.formId, isSubmittingAtom);
|
34
|
+
};
|
10
35
|
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
36
|
+
/**
|
37
|
+
* Returns whether or not the current form is valid.
|
38
|
+
*
|
39
|
+
* @param formId the id of the form. Only necessary if being used outside a ValidatedForm.
|
40
|
+
*/
|
41
|
+
export const useIsValid = (formId?: string) => {
|
42
|
+
const formContext = useInternalFormContext(formId, "useIsValid");
|
43
|
+
return useContextSelectAtom(formContext.formId, isValidAtom);
|
18
44
|
};
|
19
45
|
|
20
46
|
export type FieldProps = {
|
@@ -64,21 +90,34 @@ export const useField = (
|
|
64
90
|
* Allows you to specify when a field gets validated (when using getInputProps)
|
65
91
|
*/
|
66
92
|
validationBehavior?: Partial<ValidationBehaviorOptions>;
|
93
|
+
/**
|
94
|
+
* The formId of the form you want to use.
|
95
|
+
* This is not necesary if the input is used inside a form.
|
96
|
+
*/
|
97
|
+
formId?: string;
|
67
98
|
}
|
68
99
|
): FieldProps => {
|
69
|
-
const {
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
touchedFields,
|
76
|
-
setFieldTouched,
|
77
|
-
hasBeenSubmitted,
|
78
|
-
} = useInternalFormContext("useField");
|
100
|
+
const { handleReceiveFocus, formId: providedFormId } = options ?? {};
|
101
|
+
const formContext = useInternalFormContext(providedFormId, "useField");
|
102
|
+
|
103
|
+
const defaultValue = useFieldDefaultValue(name, formContext);
|
104
|
+
const touched = useFieldTouched(name, formContext);
|
105
|
+
const error = useFieldError(name, formContext);
|
79
106
|
|
80
|
-
const
|
81
|
-
const
|
107
|
+
const clearError = useClearError(formContext);
|
108
|
+
const setTouched = useSetTouched(formContext);
|
109
|
+
const hasBeenSubmitted = useContextSelectAtom(
|
110
|
+
formContext.formId,
|
111
|
+
hasBeenSubmittedAtom
|
112
|
+
);
|
113
|
+
const validateField = useContextSelectAtom(
|
114
|
+
formContext.formId,
|
115
|
+
validateFieldAtom
|
116
|
+
);
|
117
|
+
const registerReceiveFocus = useContextSelectAtom(
|
118
|
+
formContext.formId,
|
119
|
+
registerReceiveFocusAtom
|
120
|
+
);
|
82
121
|
|
83
122
|
useEffect(() => {
|
84
123
|
if (handleReceiveFocus)
|
@@ -87,18 +126,14 @@ export const useField = (
|
|
87
126
|
|
88
127
|
const field = useMemo<FieldProps>(() => {
|
89
128
|
const helpers = {
|
90
|
-
error
|
91
|
-
clearError: () =>
|
92
|
-
clearError(name);
|
93
|
-
},
|
129
|
+
error,
|
130
|
+
clearError: () => clearError(name),
|
94
131
|
validate: () => {
|
95
132
|
validateField(name);
|
96
133
|
},
|
97
|
-
defaultValue
|
98
|
-
|
99
|
-
|
100
|
-
touched: isTouched,
|
101
|
-
setTouched: (touched: boolean) => setFieldTouched(name, touched),
|
134
|
+
defaultValue,
|
135
|
+
touched,
|
136
|
+
setTouched: (touched: boolean) => setTouched(name, touched),
|
102
137
|
};
|
103
138
|
const getInputProps = createGetInputProps({
|
104
139
|
...helpers,
|
@@ -111,34 +146,16 @@ export const useField = (
|
|
111
146
|
getInputProps,
|
112
147
|
};
|
113
148
|
}, [
|
114
|
-
|
149
|
+
error,
|
150
|
+
defaultValue,
|
151
|
+
touched,
|
115
152
|
name,
|
116
|
-
defaultValues,
|
117
|
-
isTouched,
|
118
153
|
hasBeenSubmitted,
|
119
154
|
options?.validationBehavior,
|
120
155
|
clearError,
|
121
156
|
validateField,
|
122
|
-
|
157
|
+
setTouched,
|
123
158
|
]);
|
124
159
|
|
125
160
|
return field;
|
126
161
|
};
|
127
|
-
|
128
|
-
/**
|
129
|
-
* Provides access to the entire form context.
|
130
|
-
*/
|
131
|
-
export const useFormContext = () => useInternalFormContext("useFormContext");
|
132
|
-
|
133
|
-
/**
|
134
|
-
* Returns whether or not the parent form is currently being submitted.
|
135
|
-
* This is different from remix's `useTransition().submission` in that it
|
136
|
-
* is aware of what form it's in and when _that_ form is being submitted.
|
137
|
-
*/
|
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;
|
package/src/index.ts
CHANGED
@@ -1,54 +1,13 @@
|
|
1
|
+
import { useFetcher } from "@remix-run/react";
|
1
2
|
import { createContext } from "react";
|
2
|
-
import { FieldErrors, TouchedFields } from "../validation/types";
|
3
3
|
|
4
|
-
export type
|
5
|
-
|
6
|
-
* All the errors in all the fields in the form.
|
7
|
-
*/
|
8
|
-
fieldErrors: FieldErrors;
|
9
|
-
/**
|
10
|
-
* Clear the errors of the specified fields.
|
11
|
-
*/
|
12
|
-
clearError: (...names: string[]) => void;
|
13
|
-
/**
|
14
|
-
* Validate the specified field.
|
15
|
-
*/
|
16
|
-
validateField: (fieldName: string) => Promise<string | null>;
|
17
|
-
/**
|
18
|
-
* The `action` prop of the form.
|
19
|
-
*/
|
4
|
+
export type InternalFormContextValue = {
|
5
|
+
formId: string | symbol;
|
20
6
|
action?: string;
|
21
|
-
|
22
|
-
|
23
|
-
|
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;
|
31
|
-
/**
|
32
|
-
* Whether or not the form is valid.
|
33
|
-
*/
|
34
|
-
isValid: boolean;
|
35
|
-
/**
|
36
|
-
* The default values of the form.
|
37
|
-
*/
|
38
|
-
defaultValues?: { [fieldName: string]: any };
|
39
|
-
/**
|
40
|
-
* Register a custom focus handler to be used when
|
41
|
-
* the field needs to receive focus due to a validation error.
|
42
|
-
*/
|
43
|
-
registerReceiveFocus: (fieldName: string, handler: () => void) => () => void;
|
44
|
-
/**
|
45
|
-
* Any fields that have been touched by the user.
|
46
|
-
*/
|
47
|
-
touchedFields: TouchedFields;
|
48
|
-
/**
|
49
|
-
* Change the touched state of the specified field.
|
50
|
-
*/
|
51
|
-
setFieldTouched: (fieldName: string, touched: boolean) => void;
|
7
|
+
subaction?: string;
|
8
|
+
defaultValuesProp?: { [fieldName: string]: any };
|
9
|
+
fetcher?: ReturnType<typeof useFetcher>;
|
52
10
|
};
|
53
11
|
|
54
|
-
export const
|
12
|
+
export const InternalFormContext =
|
13
|
+
createContext<InternalFormContextValue | null>(null);
|
@@ -1,3 +1,5 @@
|
|
1
|
+
import omitBy from "lodash/omitBy";
|
2
|
+
|
1
3
|
export type ValidationBehavior = "onBlur" | "onChange" | "onSubmit";
|
2
4
|
|
3
5
|
export type ValidationBehaviorOptions = {
|
@@ -68,7 +70,7 @@ export const createGetInputProps = ({
|
|
68
70
|
? validationBehaviors.whenTouched
|
69
71
|
: validationBehaviors.initial;
|
70
72
|
|
71
|
-
const inputProps:
|
73
|
+
const inputProps: MinimalInputProps = {
|
72
74
|
...props,
|
73
75
|
onChange: (...args: unknown[]) => {
|
74
76
|
if (behavior === "onChange") validate();
|
@@ -83,19 +85,19 @@ export const createGetInputProps = ({
|
|
83
85
|
name,
|
84
86
|
};
|
85
87
|
|
86
|
-
if (
|
88
|
+
if (props.type === "checkbox") {
|
87
89
|
const value = props.value ?? "on";
|
88
90
|
inputProps.defaultChecked = getCheckboxDefaultChecked(
|
89
91
|
value,
|
90
92
|
defaultValue
|
91
93
|
);
|
92
|
-
} else if (
|
94
|
+
} else if (props.type === "radio") {
|
93
95
|
const value = props.value ?? "on";
|
94
96
|
inputProps.defaultChecked = defaultValue === value;
|
95
97
|
} else {
|
96
98
|
inputProps.defaultValue = defaultValue;
|
97
99
|
}
|
98
100
|
|
99
|
-
return inputProps;
|
101
|
+
return omitBy(inputProps, (value) => value === undefined) as T;
|
100
102
|
};
|
101
103
|
};
|
@@ -0,0 +1,191 @@
|
|
1
|
+
import { useActionData, useMatches, useTransition } from "@remix-run/react";
|
2
|
+
import { Atom } from "jotai";
|
3
|
+
import { useAtomValue, useUpdateAtom } from "jotai/utils";
|
4
|
+
import lodashGet from "lodash/get";
|
5
|
+
import identity from "lodash/identity";
|
6
|
+
import { useCallback, useContext, useMemo } from "react";
|
7
|
+
import { ValidationErrorResponseData } from "..";
|
8
|
+
import { formDefaultValuesKey } from "./constants";
|
9
|
+
import { InternalFormContext, InternalFormContextValue } from "./formContext";
|
10
|
+
import {
|
11
|
+
ATOM_SCOPE,
|
12
|
+
clearErrorAtom,
|
13
|
+
fieldDefaultValueAtom,
|
14
|
+
fieldErrorAtom,
|
15
|
+
fieldTouchedAtom,
|
16
|
+
FormAtom,
|
17
|
+
formRegistry,
|
18
|
+
isHydratedAtom,
|
19
|
+
setTouchedAtom,
|
20
|
+
} from "./state";
|
21
|
+
|
22
|
+
type FormSelectorAtomCreator<T> = (formState: FormAtom) => Atom<T>;
|
23
|
+
const USE_HYDRATED_STATE = Symbol("USE_HYDRATED_STATE");
|
24
|
+
|
25
|
+
export const useInternalFormContext = (
|
26
|
+
formId?: string | symbol,
|
27
|
+
hookName?: string
|
28
|
+
) => {
|
29
|
+
const formContext = useContext(InternalFormContext);
|
30
|
+
|
31
|
+
if (formId) return { formId };
|
32
|
+
if (formContext) return formContext;
|
33
|
+
|
34
|
+
throw new Error(
|
35
|
+
`Unable to determine form for ${hookName}. Please use it inside a form or pass a 'formId'.`
|
36
|
+
);
|
37
|
+
};
|
38
|
+
|
39
|
+
export const useContextSelectAtom = <T>(
|
40
|
+
formId: string | symbol,
|
41
|
+
selectorAtomCreator: FormSelectorAtomCreator<T>
|
42
|
+
) => {
|
43
|
+
const formAtom = formRegistry(formId);
|
44
|
+
const selectorAtom = useMemo(
|
45
|
+
() => selectorAtomCreator(formAtom),
|
46
|
+
[formAtom, selectorAtomCreator]
|
47
|
+
);
|
48
|
+
return useAtomValue(selectorAtom, ATOM_SCOPE);
|
49
|
+
};
|
50
|
+
|
51
|
+
export const useHydratableSelector = <T, U>(
|
52
|
+
{ formId }: InternalFormContextValue,
|
53
|
+
atomCreator: FormSelectorAtomCreator<T>,
|
54
|
+
dataToUse: U | typeof USE_HYDRATED_STATE,
|
55
|
+
selector: (data: U) => T = identity
|
56
|
+
) => {
|
57
|
+
const dataFromState = useContextSelectAtom(formId, atomCreator);
|
58
|
+
return dataToUse === USE_HYDRATED_STATE ? dataFromState : selector(dataToUse);
|
59
|
+
};
|
60
|
+
|
61
|
+
export function useErrorResponseForForm({
|
62
|
+
fetcher,
|
63
|
+
subaction,
|
64
|
+
formId,
|
65
|
+
}: InternalFormContextValue): ValidationErrorResponseData | null {
|
66
|
+
const actionData = useActionData<any>();
|
67
|
+
if (fetcher) {
|
68
|
+
if ((fetcher.data as any)?.fieldErrors) return fetcher.data as any;
|
69
|
+
return null;
|
70
|
+
}
|
71
|
+
|
72
|
+
if (!actionData?.fieldErrors) return null;
|
73
|
+
|
74
|
+
// If there's an explicit id, we should ignore data that has the wrong id
|
75
|
+
if (typeof formId === "string" && actionData.formId)
|
76
|
+
return actionData.formId === formId ? actionData : null;
|
77
|
+
|
78
|
+
if (
|
79
|
+
(!subaction && !actionData.subaction) ||
|
80
|
+
actionData.subaction === subaction
|
81
|
+
)
|
82
|
+
return actionData;
|
83
|
+
|
84
|
+
return null;
|
85
|
+
}
|
86
|
+
|
87
|
+
export const useFieldErrorsForForm = (context: InternalFormContextValue) => {
|
88
|
+
const response = useErrorResponseForForm(context);
|
89
|
+
const hydrated = useContextSelectAtom(context.formId, isHydratedAtom);
|
90
|
+
return hydrated ? USE_HYDRATED_STATE : response?.fieldErrors;
|
91
|
+
};
|
92
|
+
|
93
|
+
export const useDefaultValuesFromLoader = ({
|
94
|
+
formId,
|
95
|
+
}: InternalFormContextValue) => {
|
96
|
+
const matches = useMatches();
|
97
|
+
if (typeof formId === "string") {
|
98
|
+
const dataKey = formDefaultValuesKey(formId);
|
99
|
+
// If multiple loaders declare the same default values,
|
100
|
+
// we should use the data from the deepest route.
|
101
|
+
const match = matches
|
102
|
+
.reverse()
|
103
|
+
.find((match) => match.data && dataKey in match.data);
|
104
|
+
return match?.data[dataKey];
|
105
|
+
}
|
106
|
+
|
107
|
+
return null;
|
108
|
+
};
|
109
|
+
|
110
|
+
export const useDefaultValuesForForm = (context: InternalFormContextValue) => {
|
111
|
+
const { formId, defaultValuesProp } = context;
|
112
|
+
const hydrated = useContextSelectAtom(formId, isHydratedAtom);
|
113
|
+
const errorResponse = useErrorResponseForForm(context);
|
114
|
+
const defaultValuesFromLoader = useDefaultValuesFromLoader(context);
|
115
|
+
|
116
|
+
// Typical flow is:
|
117
|
+
// - Default values only available from props or server
|
118
|
+
// - Props have a higher priority than server
|
119
|
+
// - State gets hydrated with default values
|
120
|
+
// - After submit, we may need to use values from the error
|
121
|
+
|
122
|
+
if (hydrated) return USE_HYDRATED_STATE;
|
123
|
+
if (errorResponse?.repopulateFields) return errorResponse.repopulateFields;
|
124
|
+
if (defaultValuesProp) return defaultValuesProp;
|
125
|
+
return defaultValuesFromLoader;
|
126
|
+
};
|
127
|
+
|
128
|
+
export const useHasActiveFormSubmit = ({
|
129
|
+
fetcher,
|
130
|
+
}: InternalFormContextValue): boolean => {
|
131
|
+
const transition = useTransition();
|
132
|
+
const hasActiveSubmission = fetcher
|
133
|
+
? fetcher.state === "submitting"
|
134
|
+
: !!transition.submission;
|
135
|
+
return hasActiveSubmission;
|
136
|
+
};
|
137
|
+
|
138
|
+
export const useFieldTouched = (
|
139
|
+
name: string,
|
140
|
+
{ formId }: InternalFormContextValue
|
141
|
+
) => {
|
142
|
+
const atomCreator = useMemo(() => fieldTouchedAtom(name), [name]);
|
143
|
+
return useContextSelectAtom(formId, atomCreator);
|
144
|
+
};
|
145
|
+
|
146
|
+
export const useFieldError = (
|
147
|
+
name: string,
|
148
|
+
context: InternalFormContextValue
|
149
|
+
) => {
|
150
|
+
return useHydratableSelector(
|
151
|
+
context,
|
152
|
+
useMemo(() => fieldErrorAtom(name), [name]),
|
153
|
+
useFieldErrorsForForm(context),
|
154
|
+
(fieldErrors) => fieldErrors?.[name]
|
155
|
+
);
|
156
|
+
};
|
157
|
+
|
158
|
+
export const useFieldDefaultValue = (
|
159
|
+
name: string,
|
160
|
+
context: InternalFormContextValue
|
161
|
+
) => {
|
162
|
+
return useHydratableSelector(
|
163
|
+
context,
|
164
|
+
useMemo(() => fieldDefaultValueAtom(name), [name]),
|
165
|
+
useDefaultValuesForForm(context),
|
166
|
+
(val) => lodashGet(val, name)
|
167
|
+
);
|
168
|
+
};
|
169
|
+
|
170
|
+
export const useFormUpdateAtom: typeof useUpdateAtom = (atom) =>
|
171
|
+
useUpdateAtom(atom, ATOM_SCOPE);
|
172
|
+
|
173
|
+
export const useClearError = (context: InternalFormContextValue) => {
|
174
|
+
const clearError = useFormUpdateAtom(clearErrorAtom);
|
175
|
+
return useCallback(
|
176
|
+
(name: string) => {
|
177
|
+
clearError({ name, formAtom: formRegistry(context.formId) });
|
178
|
+
},
|
179
|
+
[clearError, context.formId]
|
180
|
+
);
|
181
|
+
};
|
182
|
+
|
183
|
+
export const useSetTouched = (context: InternalFormContextValue) => {
|
184
|
+
const setTouched = useFormUpdateAtom(setTouchedAtom);
|
185
|
+
return useCallback(
|
186
|
+
(name: string, touched: boolean) => {
|
187
|
+
setTouched({ name, formAtom: formRegistry(context.formId), touched });
|
188
|
+
},
|
189
|
+
[setTouched, context.formId]
|
190
|
+
);
|
191
|
+
};
|
@@ -0,0 +1,210 @@
|
|
1
|
+
import { atom } from "jotai";
|
2
|
+
import { atomWithImmer } from "jotai/immer";
|
3
|
+
import { atomFamily, selectAtom } from "jotai/utils";
|
4
|
+
import lodashGet from "lodash/get";
|
5
|
+
import { FieldErrors, TouchedFields } from "../validation/types";
|
6
|
+
|
7
|
+
export const ATOM_SCOPE = Symbol("remix-validated-form-scope");
|
8
|
+
|
9
|
+
export type FormState = {
|
10
|
+
// Actual state
|
11
|
+
hydrated: boolean;
|
12
|
+
fieldErrors?: FieldErrors;
|
13
|
+
isSubmitting: boolean;
|
14
|
+
hasBeenSubmitted: boolean;
|
15
|
+
touchedFields: TouchedFields;
|
16
|
+
|
17
|
+
// Populated by the form component
|
18
|
+
formId?: string;
|
19
|
+
action?: string;
|
20
|
+
subaction?: string;
|
21
|
+
defaultValues?: { [fieldName: string]: any };
|
22
|
+
validateField: (fieldName: string) => Promise<string | null>;
|
23
|
+
registerReceiveFocus: (fieldName: string, handler: () => void) => () => void;
|
24
|
+
};
|
25
|
+
|
26
|
+
export type FormAtom = ReturnType<typeof formRegistry>;
|
27
|
+
|
28
|
+
export type FieldState = {
|
29
|
+
touched: boolean;
|
30
|
+
defaultValue?: any;
|
31
|
+
error?: string;
|
32
|
+
};
|
33
|
+
|
34
|
+
export const formRegistry = atomFamily((formId: string | symbol) =>
|
35
|
+
atomWithImmer<FormState>({
|
36
|
+
hydrated: false,
|
37
|
+
isSubmitting: false,
|
38
|
+
hasBeenSubmitted: false,
|
39
|
+
touchedFields: {},
|
40
|
+
|
41
|
+
// The symbol version is just to keep things straight with the `atomFamily`
|
42
|
+
formId: typeof formId === "string" ? formId : undefined,
|
43
|
+
|
44
|
+
// Will change upon hydration -- these will never actually be used
|
45
|
+
validateField: () => Promise.resolve(null),
|
46
|
+
registerReceiveFocus: () => () => {},
|
47
|
+
})
|
48
|
+
);
|
49
|
+
|
50
|
+
export const fieldErrorAtom = (name: string) => (formAtom: FormAtom) =>
|
51
|
+
selectAtom(formAtom, (formState) => formState.fieldErrors?.[name]);
|
52
|
+
|
53
|
+
export const fieldTouchedAtom = (name: string) => (formAtom: FormAtom) =>
|
54
|
+
selectAtom(formAtom, (formState) => formState.touchedFields[name]);
|
55
|
+
|
56
|
+
export const fieldDefaultValueAtom = (name: string) => (formAtom: FormAtom) =>
|
57
|
+
selectAtom(
|
58
|
+
formAtom,
|
59
|
+
(formState) =>
|
60
|
+
formState.defaultValues && lodashGet(formState.defaultValues, name)
|
61
|
+
);
|
62
|
+
|
63
|
+
// Selector atoms
|
64
|
+
|
65
|
+
export const formSelectorAtom =
|
66
|
+
<T>(selector: (state: FormState) => T) =>
|
67
|
+
(formAtom: FormAtom) =>
|
68
|
+
selectAtom(formAtom, selector);
|
69
|
+
|
70
|
+
export const fieldErrorsAtom = formSelectorAtom((state) => state.fieldErrors);
|
71
|
+
export const touchedFieldsAtom = formSelectorAtom(
|
72
|
+
(state) => state.touchedFields
|
73
|
+
);
|
74
|
+
export const actionAtom = formSelectorAtom((state) => state.action);
|
75
|
+
export const hasBeenSubmittedAtom = formSelectorAtom(
|
76
|
+
(state) => state.hasBeenSubmitted
|
77
|
+
);
|
78
|
+
export const validateFieldAtom = formSelectorAtom(
|
79
|
+
(state) => state.validateField
|
80
|
+
);
|
81
|
+
export const registerReceiveFocusAtom = formSelectorAtom(
|
82
|
+
(state) => state.registerReceiveFocus
|
83
|
+
);
|
84
|
+
export const isSubmittingAtom = formSelectorAtom((state) => state.isSubmitting);
|
85
|
+
export const defaultValuesAtom = formSelectorAtom(
|
86
|
+
(state) => state.defaultValues
|
87
|
+
);
|
88
|
+
export const isValidAtom = formSelectorAtom(
|
89
|
+
(state) => Object.keys(state.fieldErrors ?? {}).length === 0
|
90
|
+
);
|
91
|
+
export const isHydratedAtom = formSelectorAtom((state) => state.hydrated);
|
92
|
+
|
93
|
+
// Update atoms
|
94
|
+
|
95
|
+
export type FieldAtomArgs = {
|
96
|
+
name: string;
|
97
|
+
formAtom: FormAtom;
|
98
|
+
};
|
99
|
+
|
100
|
+
export const clearErrorAtom = atom(
|
101
|
+
null,
|
102
|
+
(get, set, { name, formAtom }: FieldAtomArgs) =>
|
103
|
+
set(formAtom, (state) => {
|
104
|
+
delete state.fieldErrors?.[name];
|
105
|
+
return state;
|
106
|
+
})
|
107
|
+
);
|
108
|
+
|
109
|
+
export const addErrorAtom = atom(
|
110
|
+
null,
|
111
|
+
(get, set, { name, formAtom, error }: FieldAtomArgs & { error: string }) =>
|
112
|
+
set(formAtom, (state) => {
|
113
|
+
if (!state.fieldErrors) state.fieldErrors = {};
|
114
|
+
state.fieldErrors[name] = error;
|
115
|
+
return state;
|
116
|
+
})
|
117
|
+
);
|
118
|
+
|
119
|
+
export const setFieldErrorsAtom = atom(
|
120
|
+
null,
|
121
|
+
(
|
122
|
+
get,
|
123
|
+
set,
|
124
|
+
{ formAtom, fieldErrors }: { fieldErrors: FieldErrors; formAtom: FormAtom }
|
125
|
+
) =>
|
126
|
+
set(formAtom, (state) => {
|
127
|
+
state.fieldErrors = fieldErrors;
|
128
|
+
return state;
|
129
|
+
})
|
130
|
+
);
|
131
|
+
|
132
|
+
export const setTouchedAtom = atom(
|
133
|
+
null,
|
134
|
+
(
|
135
|
+
get,
|
136
|
+
set,
|
137
|
+
{ name, formAtom, touched }: FieldAtomArgs & { touched: boolean }
|
138
|
+
) =>
|
139
|
+
set(formAtom, (state) => {
|
140
|
+
state.touchedFields[name] = touched;
|
141
|
+
return state;
|
142
|
+
})
|
143
|
+
);
|
144
|
+
|
145
|
+
export const resetAtom = atom(
|
146
|
+
null,
|
147
|
+
(get, set, { formAtom }: { formAtom: FormAtom }) => {
|
148
|
+
set(formAtom, (state) => {
|
149
|
+
state.fieldErrors = {};
|
150
|
+
state.touchedFields = {};
|
151
|
+
state.hasBeenSubmitted = false;
|
152
|
+
return state;
|
153
|
+
});
|
154
|
+
}
|
155
|
+
);
|
156
|
+
|
157
|
+
export const startSubmitAtom = atom(
|
158
|
+
null,
|
159
|
+
(get, set, { formAtom }: { formAtom: FormAtom }) => {
|
160
|
+
set(formAtom, (state) => {
|
161
|
+
state.hasBeenSubmitted = true;
|
162
|
+
state.isSubmitting = true;
|
163
|
+
return state;
|
164
|
+
});
|
165
|
+
}
|
166
|
+
);
|
167
|
+
|
168
|
+
export const endSubmitAtom = atom(
|
169
|
+
null,
|
170
|
+
(get, set, { formAtom }: { formAtom: FormAtom }) => {
|
171
|
+
set(formAtom, (state) => {
|
172
|
+
state.isSubmitting = false;
|
173
|
+
return state;
|
174
|
+
});
|
175
|
+
}
|
176
|
+
);
|
177
|
+
|
178
|
+
type SyncFormContextArgs = {
|
179
|
+
defaultValues?: { [fieldName: string]: any };
|
180
|
+
action?: string;
|
181
|
+
subaction?: string;
|
182
|
+
validateField: FormState["validateField"];
|
183
|
+
registerReceiveFocus: FormState["registerReceiveFocus"];
|
184
|
+
formAtom: FormAtom;
|
185
|
+
};
|
186
|
+
export const syncFormContextAtom = atom(
|
187
|
+
null,
|
188
|
+
(
|
189
|
+
get,
|
190
|
+
set,
|
191
|
+
{
|
192
|
+
defaultValues,
|
193
|
+
action,
|
194
|
+
subaction,
|
195
|
+
formAtom,
|
196
|
+
validateField,
|
197
|
+
registerReceiveFocus,
|
198
|
+
}: SyncFormContextArgs
|
199
|
+
) => {
|
200
|
+
set(formAtom, (state) => {
|
201
|
+
state.defaultValues = defaultValues;
|
202
|
+
state.action = action;
|
203
|
+
state.subaction = subaction;
|
204
|
+
state.registerReceiveFocus = registerReceiveFocus;
|
205
|
+
state.validateField = validateField;
|
206
|
+
state.hydrated = true;
|
207
|
+
return state;
|
208
|
+
});
|
209
|
+
}
|
210
|
+
);
|
package/src/internal/util.ts
CHANGED
@@ -1,4 +1,5 @@
|
|
1
1
|
import type React from "react";
|
2
|
+
import { useEffect, useLayoutEffect } from "react";
|
2
3
|
|
3
4
|
export const omit = (obj: any, ...keys: string[]) => {
|
4
5
|
const result = { ...obj };
|
@@ -21,3 +22,6 @@ export const mergeRefs = <T = any>(
|
|
21
22
|
});
|
22
23
|
};
|
23
24
|
};
|
25
|
+
|
26
|
+
export const useIsomorphicLayoutEffect =
|
27
|
+
typeof window !== "undefined" ? useLayoutEffect : useEffect;
|