@rebasepro/forms 0.0.1-canary.4829d6e
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/LICENSE +21 -0
- package/README.md +105 -0
- package/dist/Field.d.ts +52 -0
- package/dist/Formex.d.ts +7 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.es.js +329 -0
- package/dist/index.es.js.map +1 -0
- package/dist/index.umd.js +369 -0
- package/dist/index.umd.js.map +1 -0
- package/dist/types.d.ts +40 -0
- package/dist/useCreateFormex.d.ts +14 -0
- package/dist/utils.d.ts +16 -0
- package/package.json +79 -0
- package/src/Field.tsx +164 -0
- package/src/Formex.tsx +15 -0
- package/src/index.ts +5 -0
- package/src/types.ts +45 -0
- package/src/useCreateFormex.tsx +297 -0
- package/src/utils.ts +102 -0
package/src/Field.tsx
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { useFormex } from "./Formex";
|
|
3
|
+
import { getIn, isFunction, isObject } from "./utils";
|
|
4
|
+
import { FormexController } from "./types";
|
|
5
|
+
|
|
6
|
+
export interface FieldInputProps<Value> {
|
|
7
|
+
/** Value of the field */
|
|
8
|
+
value: Value;
|
|
9
|
+
/** Name of the field */
|
|
10
|
+
name: string;
|
|
11
|
+
/** Multiple select? */
|
|
12
|
+
multiple?: boolean;
|
|
13
|
+
/** Is the field checked? */
|
|
14
|
+
checked?: boolean;
|
|
15
|
+
/** Change event handler */
|
|
16
|
+
onChange: (event: React.SyntheticEvent) => void,
|
|
17
|
+
/** Blur event handler */
|
|
18
|
+
onBlur: (event: React.FocusEvent) => void,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface FormexFieldProps<Value = any, FormValues extends object = object> {
|
|
22
|
+
field: FieldInputProps<Value>;
|
|
23
|
+
form: FormexController<FormValues>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface FieldConfig<Value, C extends React.ElementType | undefined = undefined> {
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Component to render. Can either be a string e.g. 'select', 'input', or 'textarea', or a component.
|
|
30
|
+
*/
|
|
31
|
+
as?:
|
|
32
|
+
| C
|
|
33
|
+
| string
|
|
34
|
+
| React.ForwardRefExoticComponent<Record<string, unknown>>;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Children render function <Field name>{props => ...}</Field>)
|
|
38
|
+
*/
|
|
39
|
+
children?: ((props: FormexFieldProps<Value>) => React.ReactNode) | React.ReactNode;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Validate a single field value independently
|
|
43
|
+
*/
|
|
44
|
+
// validate?: FieldValidator;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Used for 'select' and related input types.
|
|
48
|
+
*/
|
|
49
|
+
multiple?: boolean;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Field name
|
|
53
|
+
*/
|
|
54
|
+
name: string;
|
|
55
|
+
|
|
56
|
+
/** HTML input type */
|
|
57
|
+
type?: string;
|
|
58
|
+
|
|
59
|
+
/** Field value */
|
|
60
|
+
value?: unknown;
|
|
61
|
+
|
|
62
|
+
/** Inner ref */
|
|
63
|
+
innerRef?: (instance: unknown) => void;
|
|
64
|
+
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type FieldProps<T, C extends React.ElementType | undefined> = {
|
|
68
|
+
as?: C;
|
|
69
|
+
} & (C extends React.ElementType ? (React.ComponentProps<C> & FieldConfig<T, C>) : FieldConfig<T, C>);
|
|
70
|
+
|
|
71
|
+
export function Field<T, C extends React.ElementType | undefined = undefined>({
|
|
72
|
+
validate,
|
|
73
|
+
name,
|
|
74
|
+
children,
|
|
75
|
+
as: is, // `as` is reserved in typescript lol
|
|
76
|
+
// component,
|
|
77
|
+
className,
|
|
78
|
+
...props
|
|
79
|
+
}: FieldProps<T, C>) {
|
|
80
|
+
const formex = useFormex();
|
|
81
|
+
|
|
82
|
+
const field = getFieldProps({ name,
|
|
83
|
+
...props }, formex);
|
|
84
|
+
|
|
85
|
+
if (isFunction(children)) {
|
|
86
|
+
return children({ field,
|
|
87
|
+
form: formex });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// if (component) {
|
|
91
|
+
// if (typeof component === "string") {
|
|
92
|
+
// const { innerRef, ...rest } = props;
|
|
93
|
+
// return React.createElement(
|
|
94
|
+
// component,
|
|
95
|
+
// { ref: innerRef, ...field, ...rest, className },
|
|
96
|
+
// children
|
|
97
|
+
// );
|
|
98
|
+
// }
|
|
99
|
+
// return React.createElement(
|
|
100
|
+
// component,
|
|
101
|
+
// { field, form: formex, ...props, className },
|
|
102
|
+
// children
|
|
103
|
+
// );
|
|
104
|
+
// }
|
|
105
|
+
|
|
106
|
+
// default to input here so we can check for both `as` and `children` above
|
|
107
|
+
const asElement = is || "input";
|
|
108
|
+
|
|
109
|
+
if (typeof asElement === "string") {
|
|
110
|
+
const { innerRef, ...rest } = props;
|
|
111
|
+
return React.createElement(
|
|
112
|
+
asElement,
|
|
113
|
+
{ ref: innerRef,
|
|
114
|
+
...field,
|
|
115
|
+
...rest,
|
|
116
|
+
className },
|
|
117
|
+
children
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return React.createElement(asElement, { ...field,
|
|
122
|
+
...props,
|
|
123
|
+
className }, children);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const getFieldProps = (nameOrOptions: string | FieldConfig<unknown>, formex: FormexController<object>): FieldInputProps<unknown> => {
|
|
127
|
+
const name: string = typeof nameOrOptions === "string"
|
|
128
|
+
? nameOrOptions
|
|
129
|
+
: nameOrOptions.name;
|
|
130
|
+
const valueState = getIn(formex.values as Record<string, unknown>, name);
|
|
131
|
+
|
|
132
|
+
const field: FieldInputProps<unknown> = {
|
|
133
|
+
name: name as string,
|
|
134
|
+
value: valueState,
|
|
135
|
+
onChange: formex.handleChange,
|
|
136
|
+
onBlur: formex.handleBlur
|
|
137
|
+
};
|
|
138
|
+
if (typeof nameOrOptions !== "string") {
|
|
139
|
+
const {
|
|
140
|
+
type,
|
|
141
|
+
value: valueProp, // value is special for checkboxes
|
|
142
|
+
as: is,
|
|
143
|
+
multiple
|
|
144
|
+
} = nameOrOptions as FieldConfig<unknown>;
|
|
145
|
+
|
|
146
|
+
if (type === "checkbox") {
|
|
147
|
+
if (valueProp === undefined) {
|
|
148
|
+
field.checked = !!valueState;
|
|
149
|
+
} else {
|
|
150
|
+
field.checked = !!(
|
|
151
|
+
Array.isArray(valueState) && ~valueState.indexOf(valueProp)
|
|
152
|
+
);
|
|
153
|
+
field.value = valueProp;
|
|
154
|
+
}
|
|
155
|
+
} else if (type === "radio") {
|
|
156
|
+
field.checked = valueState === valueProp;
|
|
157
|
+
field.value = valueProp;
|
|
158
|
+
} else if (is === "select" && multiple) {
|
|
159
|
+
field.value = field.value || [];
|
|
160
|
+
field.multiple = true;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return field;
|
|
164
|
+
};
|
package/src/Formex.tsx
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import React, { useContext } from "react";
|
|
2
|
+
import { FormexController } from "./types";
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
const FormexContext = React.createContext<FormexController<any> | null>(null);
|
|
6
|
+
|
|
7
|
+
export const useFormex = <T = any>() => {
|
|
8
|
+
const ctx = useContext(FormexContext);
|
|
9
|
+
if (!ctx) throw new Error("useFormex must be used within a Formex provider");
|
|
10
|
+
return ctx as FormexController<T>;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const Formex = <T = any>({ value, children }: { value: FormexController<T>, children: React.ReactNode }) => {
|
|
14
|
+
return <FormexContext.Provider value={value}>{children}</FormexContext.Provider>;
|
|
15
|
+
};
|
package/src/index.ts
ADDED
package/src/types.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import React, { FormEvent } from "react";
|
|
2
|
+
|
|
3
|
+
export type FormexController<T = any> = {
|
|
4
|
+
values: T;
|
|
5
|
+
initialValues: T;
|
|
6
|
+
setValues: (values: T) => void;
|
|
7
|
+
setFieldValue: (key: string, value: unknown, shouldValidate?: boolean) => void;
|
|
8
|
+
touched: Record<string, boolean>;
|
|
9
|
+
setFieldTouched: (key: string, touched: boolean, shouldValidate?: boolean) => void;
|
|
10
|
+
setTouched: (touched: Record<string, boolean>) => void;
|
|
11
|
+
dirty: boolean;
|
|
12
|
+
setDirty: (dirty: boolean) => void;
|
|
13
|
+
setSubmitCount: (submitCount: number) => void;
|
|
14
|
+
errors: Record<string, string>;
|
|
15
|
+
setFieldError: (key: string, error?: string) => void;
|
|
16
|
+
handleChange: (event: React.SyntheticEvent) => void,
|
|
17
|
+
handleBlur: (event: React.FocusEvent) => void,
|
|
18
|
+
handleSubmit: (event?: FormEvent<HTMLFormElement>) => void;
|
|
19
|
+
validate: () => void;
|
|
20
|
+
resetForm: (props?: FormexResetProps<T>) => void;
|
|
21
|
+
submitCount: number;
|
|
22
|
+
isSubmitting: boolean;
|
|
23
|
+
setSubmitting: (isSubmitting: boolean) => void;
|
|
24
|
+
isValidating: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* The version of the form. This is incremented every time the form is reset
|
|
27
|
+
* or the form is submitted.
|
|
28
|
+
*/
|
|
29
|
+
version: number;
|
|
30
|
+
|
|
31
|
+
debugId?: string;
|
|
32
|
+
|
|
33
|
+
undo: () => void;
|
|
34
|
+
redo: () => void;
|
|
35
|
+
|
|
36
|
+
canUndo: boolean;
|
|
37
|
+
canRedo: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type FormexResetProps<T = any> = {
|
|
41
|
+
values?: T;
|
|
42
|
+
submitCount?: number;
|
|
43
|
+
errors?: Record<string, string>;
|
|
44
|
+
touched?: Record<string, boolean>;
|
|
45
|
+
};
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { getIn, setIn } from "./utils";
|
|
3
|
+
import { deepEqual as equal } from "fast-equals";
|
|
4
|
+
|
|
5
|
+
import { FormexController, FormexResetProps } from "./types";
|
|
6
|
+
|
|
7
|
+
export function useCreateFormex<T = any>({
|
|
8
|
+
initialValues,
|
|
9
|
+
initialErrors,
|
|
10
|
+
initialDirty,
|
|
11
|
+
initialTouched,
|
|
12
|
+
validation,
|
|
13
|
+
validateOnChange = false,
|
|
14
|
+
validateOnInitialRender = false,
|
|
15
|
+
onSubmit,
|
|
16
|
+
onReset,
|
|
17
|
+
onValuesChangeDeferred,
|
|
18
|
+
debugId
|
|
19
|
+
}: {
|
|
20
|
+
initialValues: T;
|
|
21
|
+
initialErrors?: Record<string, string>;
|
|
22
|
+
initialDirty?: boolean;
|
|
23
|
+
initialTouched?: Record<string, boolean>;
|
|
24
|
+
validateOnChange?: boolean;
|
|
25
|
+
validateOnInitialRender?: boolean;
|
|
26
|
+
validation?: (
|
|
27
|
+
values: T
|
|
28
|
+
) =>
|
|
29
|
+
| Record<string, string>
|
|
30
|
+
| Promise<Record<string, string>>
|
|
31
|
+
| undefined
|
|
32
|
+
| void;
|
|
33
|
+
onValuesChangeDeferred?: (values: T, controller: FormexController<T>) => void;
|
|
34
|
+
onSubmit?: (values: T, controller: FormexController<T>) => void | Promise<void>;
|
|
35
|
+
onReset?: (controller: FormexController<T>) => void | Promise<void>;
|
|
36
|
+
debugId?: string;
|
|
37
|
+
}): FormexController<T> {
|
|
38
|
+
const initialValuesRef = useRef<T>(initialValues);
|
|
39
|
+
const valuesRef = useRef<T>(initialValues);
|
|
40
|
+
const debugIdRef = useRef<string | undefined>(debugId);
|
|
41
|
+
|
|
42
|
+
const [values, setValuesInner] = useState<T>(initialValues);
|
|
43
|
+
const [touchedState, setTouchedState] = useState<Record<string, boolean>>(initialTouched ?? {});
|
|
44
|
+
const [errors, setErrors] = useState<Record<string, string>>(initialErrors ?? {});
|
|
45
|
+
const [dirty, setDirty] = useState(initialDirty ?? false);
|
|
46
|
+
const [submitCount, setSubmitCount] = useState(0);
|
|
47
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
48
|
+
const [isValidating, setIsValidating] = useState(false);
|
|
49
|
+
const [version, setVersion] = useState(0);
|
|
50
|
+
|
|
51
|
+
const onValuesChangeRef = useRef(onValuesChangeDeferred);
|
|
52
|
+
onValuesChangeRef.current = onValuesChangeDeferred;
|
|
53
|
+
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
54
|
+
|
|
55
|
+
const callDebouncedOnValuesChange = useCallback((values: T) => {
|
|
56
|
+
if (onValuesChangeRef.current) {
|
|
57
|
+
if (debounceTimeoutRef.current) {
|
|
58
|
+
clearTimeout(debounceTimeoutRef.current);
|
|
59
|
+
}
|
|
60
|
+
debounceTimeoutRef.current = setTimeout(() => {
|
|
61
|
+
onValuesChangeRef.current?.(values, controllerRef.current);
|
|
62
|
+
}, 300);
|
|
63
|
+
}
|
|
64
|
+
}, []);
|
|
65
|
+
|
|
66
|
+
// Replace state for history with refs
|
|
67
|
+
const historyRef = useRef<T[]>([initialValues]);
|
|
68
|
+
const historyIndexRef = useRef<number>(0);
|
|
69
|
+
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
if (validateOnInitialRender) {
|
|
72
|
+
validate();
|
|
73
|
+
}
|
|
74
|
+
}, []);
|
|
75
|
+
|
|
76
|
+
const setValues = useCallback((newValues: T) => {
|
|
77
|
+
valuesRef.current = newValues;
|
|
78
|
+
setValuesInner(newValues);
|
|
79
|
+
setDirty(!equal(initialValuesRef.current, newValues));
|
|
80
|
+
// Update history using refs
|
|
81
|
+
const newHistory = historyRef.current.slice(0, historyIndexRef.current + 1);
|
|
82
|
+
newHistory.push(newValues);
|
|
83
|
+
historyRef.current = newHistory;
|
|
84
|
+
historyIndexRef.current = newHistory.length - 1;
|
|
85
|
+
callDebouncedOnValuesChange(newValues);
|
|
86
|
+
}, [callDebouncedOnValuesChange]);
|
|
87
|
+
|
|
88
|
+
const validate = useCallback(async () => {
|
|
89
|
+
setIsValidating(true);
|
|
90
|
+
const validationErrors = await validation?.(valuesRef.current);
|
|
91
|
+
setErrors(validationErrors ?? {});
|
|
92
|
+
setIsValidating(false);
|
|
93
|
+
return validationErrors;
|
|
94
|
+
}, [validation]);
|
|
95
|
+
|
|
96
|
+
const setFieldValue = useCallback(
|
|
97
|
+
(key: string, value: unknown, shouldValidate?: boolean) => {
|
|
98
|
+
const newValues = setIn(valuesRef.current as Record<string, unknown>, key, value) as T;
|
|
99
|
+
valuesRef.current = newValues;
|
|
100
|
+
setValuesInner(newValues);
|
|
101
|
+
if (!equal(getIn(initialValuesRef.current as Record<string, unknown>, key), value)) {
|
|
102
|
+
setDirty(true);
|
|
103
|
+
}
|
|
104
|
+
if (shouldValidate) {
|
|
105
|
+
validate();
|
|
106
|
+
}
|
|
107
|
+
// Update history using refs
|
|
108
|
+
const newHistory = historyRef.current.slice(0, historyIndexRef.current + 1);
|
|
109
|
+
newHistory.push(newValues);
|
|
110
|
+
historyRef.current = newHistory;
|
|
111
|
+
historyIndexRef.current = newHistory.length - 1;
|
|
112
|
+
callDebouncedOnValuesChange(newValues);
|
|
113
|
+
},
|
|
114
|
+
[validate, callDebouncedOnValuesChange]
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
const setFieldError = useCallback((key: string, error: string | undefined) => {
|
|
118
|
+
setErrors((prevErrors: Record<string, string>) => {
|
|
119
|
+
const newErrors = { ...prevErrors };
|
|
120
|
+
if (error) {
|
|
121
|
+
newErrors[key] = error;
|
|
122
|
+
} else {
|
|
123
|
+
delete newErrors[key];
|
|
124
|
+
}
|
|
125
|
+
return newErrors;
|
|
126
|
+
});
|
|
127
|
+
}, []);
|
|
128
|
+
|
|
129
|
+
const setFieldTouched = useCallback(
|
|
130
|
+
(key: string, touched: boolean, shouldValidate?: boolean) => {
|
|
131
|
+
setTouchedState((prev: Record<string, boolean>) => ({
|
|
132
|
+
...prev,
|
|
133
|
+
[key]: touched
|
|
134
|
+
}));
|
|
135
|
+
if (shouldValidate) {
|
|
136
|
+
validate();
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
[validate]
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const handleChange = useCallback(
|
|
143
|
+
(event: React.SyntheticEvent) => {
|
|
144
|
+
const target = event.target as HTMLInputElement;
|
|
145
|
+
let value;
|
|
146
|
+
if (target.type === "checkbox") {
|
|
147
|
+
value = target.checked;
|
|
148
|
+
} else if (target.type === "number") {
|
|
149
|
+
value = target.valueAsNumber;
|
|
150
|
+
} else {
|
|
151
|
+
value = target.value;
|
|
152
|
+
}
|
|
153
|
+
const name = target.name;
|
|
154
|
+
setFieldValue(name, value, validateOnChange);
|
|
155
|
+
setFieldTouched(name, true);
|
|
156
|
+
},
|
|
157
|
+
[setFieldValue, setFieldTouched, validateOnChange]
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
const handleBlur = useCallback((event: React.FocusEvent) => {
|
|
161
|
+
const target = event.target as HTMLInputElement;
|
|
162
|
+
const name = target.name;
|
|
163
|
+
setFieldTouched(name, true);
|
|
164
|
+
}, [setFieldTouched]);
|
|
165
|
+
|
|
166
|
+
const submit = useCallback(
|
|
167
|
+
async (e?: React.FormEvent<HTMLFormElement>) => {
|
|
168
|
+
e?.preventDefault();
|
|
169
|
+
e?.stopPropagation();
|
|
170
|
+
setIsSubmitting(true);
|
|
171
|
+
setSubmitCount((prev: number) => prev + 1);
|
|
172
|
+
const validationErrors = await validation?.(valuesRef.current);
|
|
173
|
+
if (validationErrors && Object.keys(validationErrors).length > 0) {
|
|
174
|
+
setErrors(validationErrors);
|
|
175
|
+
} else {
|
|
176
|
+
setErrors({});
|
|
177
|
+
await onSubmit?.(valuesRef.current, controllerRef.current);
|
|
178
|
+
}
|
|
179
|
+
setIsSubmitting(false);
|
|
180
|
+
setVersion((prev: number) => prev + 1);
|
|
181
|
+
},
|
|
182
|
+
[onSubmit, validation]
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
const resetForm = useCallback((props?: FormexResetProps<T>) => {
|
|
186
|
+
const {
|
|
187
|
+
submitCount: submitCountProp,
|
|
188
|
+
values: valuesProp,
|
|
189
|
+
errors: errorsProp,
|
|
190
|
+
touched: touchedProp
|
|
191
|
+
} = props ?? {};
|
|
192
|
+
valuesRef.current = valuesProp ?? initialValuesRef.current;
|
|
193
|
+
initialValuesRef.current = valuesProp ?? initialValuesRef.current;
|
|
194
|
+
setValuesInner(valuesProp ?? initialValuesRef.current);
|
|
195
|
+
setErrors(errorsProp ?? {});
|
|
196
|
+
setTouchedState(touchedProp ?? initialTouched ?? {});
|
|
197
|
+
setDirty(false);
|
|
198
|
+
setSubmitCount(submitCountProp ?? 0);
|
|
199
|
+
setVersion((prev: number) => prev + 1);
|
|
200
|
+
onReset?.(controllerRef.current);
|
|
201
|
+
// Reset history with refs
|
|
202
|
+
historyRef.current = [valuesProp ?? initialValuesRef.current];
|
|
203
|
+
historyIndexRef.current = 0;
|
|
204
|
+
}, [onReset, initialTouched]);
|
|
205
|
+
|
|
206
|
+
useEffect(() => {
|
|
207
|
+
if (!equal(initialValuesRef.current, initialValues)) {
|
|
208
|
+
resetForm({ values: initialValues });
|
|
209
|
+
}
|
|
210
|
+
}, [initialValues, resetForm]);
|
|
211
|
+
|
|
212
|
+
const undo = useCallback(() => {
|
|
213
|
+
if (historyIndexRef.current > 0) {
|
|
214
|
+
const newIndex = historyIndexRef.current - 1;
|
|
215
|
+
const newValues = historyRef.current[newIndex];
|
|
216
|
+
setValuesInner(newValues);
|
|
217
|
+
valuesRef.current = newValues;
|
|
218
|
+
historyIndexRef.current = newIndex;
|
|
219
|
+
setDirty(!equal(initialValuesRef.current, newValues));
|
|
220
|
+
callDebouncedOnValuesChange(newValues);
|
|
221
|
+
}
|
|
222
|
+
}, [callDebouncedOnValuesChange]);
|
|
223
|
+
|
|
224
|
+
const redo = useCallback(() => {
|
|
225
|
+
if (historyIndexRef.current < historyRef.current.length - 1) {
|
|
226
|
+
const newIndex = historyIndexRef.current + 1;
|
|
227
|
+
const newValues = historyRef.current[newIndex];
|
|
228
|
+
setValuesInner(newValues);
|
|
229
|
+
valuesRef.current = newValues;
|
|
230
|
+
historyIndexRef.current = newIndex;
|
|
231
|
+
setDirty(!equal(initialValuesRef.current, newValues));
|
|
232
|
+
callDebouncedOnValuesChange(newValues);
|
|
233
|
+
}
|
|
234
|
+
}, [callDebouncedOnValuesChange]);
|
|
235
|
+
|
|
236
|
+
const controllerRef = useRef<FormexController<T>>({} as FormexController<T>);
|
|
237
|
+
|
|
238
|
+
const controller = useMemo<FormexController<T>>(
|
|
239
|
+
() => ({
|
|
240
|
+
values,
|
|
241
|
+
initialValues: initialValuesRef.current,
|
|
242
|
+
handleChange,
|
|
243
|
+
isSubmitting,
|
|
244
|
+
setSubmitting: setIsSubmitting,
|
|
245
|
+
setValues,
|
|
246
|
+
setFieldValue,
|
|
247
|
+
errors,
|
|
248
|
+
setFieldError,
|
|
249
|
+
touched: touchedState,
|
|
250
|
+
setFieldTouched,
|
|
251
|
+
setTouched: setTouchedState,
|
|
252
|
+
dirty,
|
|
253
|
+
setDirty,
|
|
254
|
+
handleSubmit: submit,
|
|
255
|
+
submitCount,
|
|
256
|
+
setSubmitCount,
|
|
257
|
+
handleBlur,
|
|
258
|
+
validate,
|
|
259
|
+
isValidating,
|
|
260
|
+
resetForm,
|
|
261
|
+
version,
|
|
262
|
+
debugId: debugIdRef.current,
|
|
263
|
+
undo,
|
|
264
|
+
redo,
|
|
265
|
+
canUndo: historyIndexRef.current > 0,
|
|
266
|
+
canRedo: historyIndexRef.current < historyRef.current.length - 1
|
|
267
|
+
}),
|
|
268
|
+
[
|
|
269
|
+
values,
|
|
270
|
+
errors,
|
|
271
|
+
touchedState,
|
|
272
|
+
dirty,
|
|
273
|
+
isSubmitting,
|
|
274
|
+
submitCount,
|
|
275
|
+
isValidating,
|
|
276
|
+
version,
|
|
277
|
+
handleChange,
|
|
278
|
+
handleBlur,
|
|
279
|
+
setValues,
|
|
280
|
+
setFieldValue,
|
|
281
|
+
setFieldTouched,
|
|
282
|
+
setTouchedState,
|
|
283
|
+
setFieldError,
|
|
284
|
+
validate,
|
|
285
|
+
submit,
|
|
286
|
+
resetForm,
|
|
287
|
+
undo,
|
|
288
|
+
redo
|
|
289
|
+
]
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
useEffect(() => {
|
|
293
|
+
controllerRef.current = controller;
|
|
294
|
+
}, [controller]);
|
|
295
|
+
|
|
296
|
+
return controller;
|
|
297
|
+
}
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/** @private is the value an empty array? */
|
|
2
|
+
export const isEmptyArray = (value?: unknown) =>
|
|
3
|
+
Array.isArray(value) && value.length === 0;
|
|
4
|
+
|
|
5
|
+
/** @private is the given object a Function? */
|
|
6
|
+
|
|
7
|
+
export const isFunction = (obj: unknown): obj is Function =>
|
|
8
|
+
typeof obj === "function";
|
|
9
|
+
|
|
10
|
+
/** @private is the given object an Object? */
|
|
11
|
+
export const isObject = (obj: unknown): obj is Record<string, unknown> =>
|
|
12
|
+
obj !== null && typeof obj === "object";
|
|
13
|
+
|
|
14
|
+
/** @private is the given object an integer? */
|
|
15
|
+
export const isInteger = (obj: unknown): boolean =>
|
|
16
|
+
String(Math.floor(Number(obj))) === obj;
|
|
17
|
+
|
|
18
|
+
/** @private is the given object a NaN? */
|
|
19
|
+
|
|
20
|
+
export const isNaN = (obj: unknown): boolean => obj !== obj;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Deeply get a value from an object via its path.
|
|
24
|
+
*/
|
|
25
|
+
export function getIn(
|
|
26
|
+
obj: unknown,
|
|
27
|
+
key: string | string[],
|
|
28
|
+
def?: unknown,
|
|
29
|
+
p = 0
|
|
30
|
+
): unknown {
|
|
31
|
+
const path = toPath(key);
|
|
32
|
+
let current: unknown = obj;
|
|
33
|
+
while (current && p < path.length) {
|
|
34
|
+
current = (current as Record<string, unknown>)[path[p++]];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// check if path is not in the end
|
|
38
|
+
if (p !== path.length && !current) {
|
|
39
|
+
return def;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return current === undefined ? def : current;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function setIn(obj: unknown, path: string, value: unknown): unknown {
|
|
46
|
+
const res = clone(obj) as Record<string, unknown>; // this keeps inheritance when obj is a class
|
|
47
|
+
let resVal: Record<string, unknown> = res;
|
|
48
|
+
let i = 0;
|
|
49
|
+
const pathArray = toPath(path);
|
|
50
|
+
|
|
51
|
+
for (; i < pathArray.length - 1; i++) {
|
|
52
|
+
const currentPath: string = pathArray[i];
|
|
53
|
+
const currentObj = getIn(obj, pathArray.slice(0, i + 1));
|
|
54
|
+
|
|
55
|
+
if (currentObj && (isObject(currentObj) || Array.isArray(currentObj))) {
|
|
56
|
+
resVal = resVal[currentPath] = clone(currentObj) as Record<string, unknown>;
|
|
57
|
+
} else {
|
|
58
|
+
const nextPath: string = pathArray[i + 1];
|
|
59
|
+
resVal = resVal[currentPath] =
|
|
60
|
+
(isInteger(nextPath) && Number(nextPath) >= 0 ? [] : {}) as Record<string, unknown>;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Return original object if new value is the same as current
|
|
65
|
+
if ((i === 0 ? (obj as Record<string, unknown>) : resVal)[pathArray[i]] === value) {
|
|
66
|
+
return obj;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (value === undefined) {
|
|
70
|
+
delete resVal[pathArray[i]];
|
|
71
|
+
} else {
|
|
72
|
+
resVal[pathArray[i]] = value;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// If the path array has a single element, the loop did not run.
|
|
76
|
+
// Deleting on `resVal` had no effect in this scenario, so we delete on the result instead.
|
|
77
|
+
if (i === 0 && value === undefined) {
|
|
78
|
+
delete res[pathArray[i]];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return res;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function clone(value: unknown): unknown {
|
|
85
|
+
if (Array.isArray(value)) {
|
|
86
|
+
return [...value];
|
|
87
|
+
} else if (typeof value === "object" && value !== null) {
|
|
88
|
+
// Preserve class instances (EntityReference, GeoPoint, etc.) - don't spread them
|
|
89
|
+
if (Object.getPrototypeOf(value) !== Object.prototype) {
|
|
90
|
+
return value;
|
|
91
|
+
}
|
|
92
|
+
return { ...(value as Record<string, unknown>) };
|
|
93
|
+
} else {
|
|
94
|
+
return value; // This is for primitive types which do not need cloning.
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function toPath(value: string | string[]) {
|
|
99
|
+
if (Array.isArray(value)) return value; // Already in path array form.
|
|
100
|
+
// Replace brackets with dots, remove leading/trailing dots, then split by dot.
|
|
101
|
+
return value.replace(/\[(\d+)]/g, ".$1").replace(/^\./, "").replace(/\.$/, "").split(".");
|
|
102
|
+
}
|