@rebasepro/forms 0.17.3 → 0.18.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2023 Rebase
3
+ Copyright (c) 2026 Rebase
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -8,6 +8,10 @@ Lightweight React form state management with undo/redo support.
8
8
  pnpm add @rebasepro/forms
9
9
  ```
10
10
 
11
+ ESM-only: `"type": "module"` with no CommonJS build, so it is loaded with
12
+ `import`. `require()` of it resolves only on Node 22.12+, which supports
13
+ `require(esm)`.
14
+
11
15
  **Peer dependencies:** `react >= 19.0.0`, `react-dom >= 19.0.0`
12
16
 
13
17
  ## What This Package Does
package/package.json CHANGED
@@ -1,11 +1,7 @@
1
1
  {
2
2
  "name": "@rebasepro/forms",
3
- "type": "module",
4
- "version": "0.17.3",
5
- "license": "MIT",
6
- "publishConfig": {
7
- "access": "public"
8
- },
3
+ "version": "0.18.1",
4
+ "description": "Schema-driven form components for Rebase collections.",
9
5
  "keywords": [
10
6
  "rebase",
11
7
  "forms",
@@ -13,21 +9,38 @@
13
9
  "react",
14
10
  "admin"
15
11
  ],
12
+ "homepage": "https://rebase.pro",
13
+ "bugs": {
14
+ "url": "https://github.com/rebasepro/rebase/issues"
15
+ },
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "https://github.com/rebasepro/rebase.git",
19
+ "directory": "packages/forms"
20
+ },
21
+ "license": "MIT",
22
+ "engines": {
23
+ "node": ">=22.22.0"
24
+ },
25
+ "type": "module",
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
16
29
  "exports": {
17
30
  ".": {
18
31
  "types": "./dist/index.d.ts",
19
32
  "development": "./dist/index.es.js",
20
- "import": "./dist/index.es.js"
33
+ "import": "./dist/index.es.js",
34
+ "default": "./dist/index.es.js"
21
35
  },
22
36
  "./package.json": "./package.json"
23
37
  },
24
38
  "main": "./dist/index.es.js",
25
39
  "module": "./dist/index.es.js",
26
40
  "types": "dist/index.d.ts",
27
- "source": "src/index.ts",
28
41
  "peerDependencies": {
29
- "react": ">=19.0.0",
30
- "react-dom": ">=19.0.0"
42
+ "react": "^19.2.7",
43
+ "react-dom": "^19.2.7"
31
44
  },
32
45
  "dependencies": {
33
46
  "fast-equals": "6.0.2"
@@ -47,7 +60,6 @@
47
60
  },
48
61
  "files": [
49
62
  "dist",
50
- "src",
51
63
  "bin"
52
64
  ],
53
65
  "jest": {
@@ -65,15 +77,11 @@
65
77
  ]
66
78
  },
67
79
  "gitHead": "d935eefa5aa8d1009a2398cfac2c1e4ee9aeb6b6",
68
- "repository": {
69
- "type": "git",
70
- "url": "https://github.com/rebasepro/rebase.git",
71
- "directory": "packages/forms"
72
- },
73
80
  "scripts": {
74
81
  "dev": "vite",
75
82
  "build": "vite build && tsc --emitDeclarationOnly -p tsconfig.prod.json && node ../../tooling/scripts/add-dts-extensions.mjs dist && node ../../tooling/scripts/assert-build-output.mjs",
76
83
  "clean": "rm -rf dist && find ./src -name '*.js' -type f | xargs rm -f",
77
- "test": "jest --passWithNoTests"
84
+ "test": "jest --passWithNoTests",
85
+ "test:watch": "jest --watch"
78
86
  }
79
87
  }
package/src/Field.tsx DELETED
@@ -1,164 +0,0 @@
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 DELETED
@@ -1,15 +0,0 @@
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 DELETED
@@ -1,5 +0,0 @@
1
- export * from "./Field";
2
- export * from "./Formex";
3
- export * from "./types";
4
- export * from "./utils";
5
- export * from "./useCreateFormex";
package/src/types.ts DELETED
@@ -1,58 +0,0 @@
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, and whenever the `initialValues` it was created
28
- * with are replaced — a container reading `values` off the controller needs
29
- * to hear about a re-seed the same way it hears about a reset.
30
- */
31
- version: number;
32
-
33
- debugId?: string;
34
-
35
- undo: () => void;
36
- redo: () => void;
37
-
38
- canUndo: boolean;
39
- canRedo: boolean;
40
- }
41
-
42
- export type FormexResetProps<T = any> = {
43
- values?: T;
44
- submitCount?: number;
45
- errors?: Record<string, string>;
46
- touched?: Record<string, boolean>;
47
- /**
48
- * Leave what the reset replaced one step behind in the undo history, so
49
- * {@link FormexController.undo} brings it back.
50
- *
51
- * For a reset the *user* asked for — a "Discard changes" or "Clear form"
52
- * button — where the thing being thrown away is everything they typed, and
53
- * a misclick is otherwise unrecoverable. The default clears the history,
54
- * which is right for a reset the form performs on its own (a save, a new
55
- * record): there is nothing there worth stepping back into.
56
- */
57
- undoable?: boolean;
58
- };
@@ -1,404 +0,0 @@
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
- /**
8
- * One step of the form's undo history.
9
- *
10
- * `touched` and `boundary` are set only on the pair of entries an undoable
11
- * reset writes — the state it replaced and the state it produced. A reset
12
- * clears the touched map and bumps `version`, so a step across that pair has to
13
- * put both back. Ordinary edits leave both unset on purpose: undoing a
14
- * keystroke should not re-seed every field in the form.
15
- */
16
- type FormexHistoryEntry<T> = {
17
- values: T;
18
- touched?: Record<string, boolean>;
19
- boundary?: boolean;
20
- };
21
-
22
- export function useCreateFormex<T = any>({
23
- initialValues,
24
- initialModifiedValues,
25
- initialErrors,
26
- initialDirty,
27
- initialTouched,
28
- validation,
29
- validateOnChange = false,
30
- validateOnInitialRender = false,
31
- onSubmit,
32
- onReset,
33
- onValuesChangeDeferred,
34
- debugId
35
- }: {
36
- /**
37
- * The **baseline**: what the values are stored as. Everything the form
38
- * calls "dirty" is a difference from this, so it has to be the stored
39
- * record and nothing else. To open a form already carrying an edit, pass
40
- * that edit as {@link initialModifiedValues} — folding it into
41
- * `initialValues` instead makes the baseline agree with the edit, and then
42
- * nothing can tell that the edit is unsaved.
43
- */
44
- initialValues: T;
45
- /**
46
- * What the form should *show* on its first render, when that is not the
47
- * baseline — an edit handed over from somewhere else, a draft restored
48
- * from a cache. Dirty is computed from the difference.
49
- */
50
- initialModifiedValues?: T;
51
- initialErrors?: Record<string, string>;
52
- /**
53
- * Force the starting dirty state. Only for callers that know the form
54
- * opens modified but cannot supply the modified values; prefer
55
- * {@link initialModifiedValues}, which lets it be derived.
56
- */
57
- initialDirty?: boolean;
58
- initialTouched?: Record<string, boolean>;
59
- validateOnChange?: boolean;
60
- validateOnInitialRender?: boolean;
61
- validation?: (
62
- values: T
63
- ) =>
64
- | Record<string, string>
65
- | Promise<Record<string, string>>
66
- | undefined
67
- | void;
68
- onValuesChangeDeferred?: (values: T, controller: FormexController<T>) => void;
69
- onSubmit?: (values: T, controller: FormexController<T>) => void | Promise<void>;
70
- onReset?: (controller: FormexController<T>) => void | Promise<void>;
71
- debugId?: string;
72
- }): FormexController<T> {
73
- // The baseline and the current values start apart when the form opens
74
- // already carrying an edit. Keeping them separate is what lets the dirty
75
- // flag be *derived* rather than asserted, and what lets the baseline be
76
- // replaced later without touching what the user is looking at.
77
- const startValues = initialModifiedValues ?? initialValues;
78
-
79
- const initialValuesRef = useRef<T>(initialValues);
80
- const valuesRef = useRef<T>(startValues);
81
- const debugIdRef = useRef<string | undefined>(debugId);
82
-
83
- const [values, setValuesInner] = useState<T>(startValues);
84
- const [touchedState, setTouchedState] = useState<Record<string, boolean>>(initialTouched ?? {});
85
- // Read by `resetForm`, which needs the map as it stands at the moment it is
86
- // about to clear it. Assigned during render rather than from an effect, so
87
- // an event handler can never read one render's worth of stale state.
88
- const touchedRef = useRef<Record<string, boolean>>(touchedState);
89
- touchedRef.current = touchedState;
90
- const [errors, setErrors] = useState<Record<string, string>>(initialErrors ?? {});
91
- const [dirty, setDirty] = useState(initialDirty ?? !equal(initialValues, startValues));
92
- const [submitCount, setSubmitCount] = useState(0);
93
- const [isSubmitting, setIsSubmitting] = useState(false);
94
- const [isValidating, setIsValidating] = useState(false);
95
- const [version, setVersion] = useState(0);
96
-
97
- const onValuesChangeRef = useRef(onValuesChangeDeferred);
98
- onValuesChangeRef.current = onValuesChangeDeferred;
99
- const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
100
-
101
- const callDebouncedOnValuesChange = useCallback((values: T) => {
102
- if (onValuesChangeRef.current) {
103
- if (debounceTimeoutRef.current) {
104
- clearTimeout(debounceTimeoutRef.current);
105
- }
106
- debounceTimeoutRef.current = setTimeout(() => {
107
- onValuesChangeRef.current?.(values, controllerRef.current);
108
- }, 300);
109
- }
110
- }, []);
111
-
112
- // Replace state for history with refs
113
- const historyRef = useRef<FormexHistoryEntry<T>[]>([{ values: startValues }]);
114
- const historyIndexRef = useRef<number>(0);
115
-
116
- /**
117
- * Record a new state, dropping anything that had been undone past it.
118
- */
119
- const pushHistory = useCallback((entry: FormexHistoryEntry<T>) => {
120
- const newHistory = historyRef.current.slice(0, historyIndexRef.current + 1);
121
- newHistory.push(entry);
122
- historyRef.current = newHistory;
123
- historyIndexRef.current = newHistory.length - 1;
124
- }, []);
125
-
126
- useEffect(() => {
127
- if (validateOnInitialRender) {
128
- validate();
129
- }
130
- }, []);
131
-
132
- const setValues = useCallback((newValues: T) => {
133
- valuesRef.current = newValues;
134
- setValuesInner(newValues);
135
- setDirty(!equal(initialValuesRef.current, newValues));
136
- pushHistory({ values: newValues });
137
- callDebouncedOnValuesChange(newValues);
138
- }, [callDebouncedOnValuesChange, pushHistory]);
139
-
140
- const validate = useCallback(async () => {
141
- setIsValidating(true);
142
- const validationErrors = await validation?.(valuesRef.current);
143
- setErrors(validationErrors ?? {});
144
- setIsValidating(false);
145
- return validationErrors;
146
- }, [validation]);
147
-
148
- const setFieldValue = useCallback(
149
- (key: string, value: unknown, shouldValidate?: boolean) => {
150
- const newValues = setIn(valuesRef.current as Record<string, unknown>, key, value) as T;
151
- valuesRef.current = newValues;
152
- setValuesInner(newValues);
153
- if (!equal(getIn(initialValuesRef.current as Record<string, unknown>, key), value)) {
154
- setDirty(true);
155
- }
156
- if (shouldValidate) {
157
- validate();
158
- }
159
- pushHistory({ values: newValues });
160
- callDebouncedOnValuesChange(newValues);
161
- },
162
- [validate, callDebouncedOnValuesChange, pushHistory]
163
- );
164
-
165
- const setFieldError = useCallback((key: string, error: string | undefined) => {
166
- setErrors((prevErrors: Record<string, string>) => {
167
- const newErrors = { ...prevErrors };
168
- if (error) {
169
- newErrors[key] = error;
170
- } else {
171
- delete newErrors[key];
172
- }
173
- return newErrors;
174
- });
175
- }, []);
176
-
177
- const setFieldTouched = useCallback(
178
- (key: string, touched: boolean, shouldValidate?: boolean) => {
179
- setTouchedState((prev: Record<string, boolean>) => ({
180
- ...prev,
181
- [key]: touched
182
- }));
183
- if (shouldValidate) {
184
- validate();
185
- }
186
- },
187
- [validate]
188
- );
189
-
190
- const handleChange = useCallback(
191
- (event: React.SyntheticEvent) => {
192
- const target = event.target as HTMLInputElement;
193
- let value;
194
- if (target.type === "checkbox") {
195
- value = target.checked;
196
- } else if (target.type === "number") {
197
- value = target.valueAsNumber;
198
- } else {
199
- value = target.value;
200
- }
201
- const name = target.name;
202
- setFieldValue(name, value, validateOnChange);
203
- setFieldTouched(name, true);
204
- },
205
- [setFieldValue, setFieldTouched, validateOnChange]
206
- );
207
-
208
- const handleBlur = useCallback((event: React.FocusEvent) => {
209
- const target = event.target as HTMLInputElement;
210
- const name = target.name;
211
- setFieldTouched(name, true);
212
- }, [setFieldTouched]);
213
-
214
- const submit = useCallback(
215
- async (e?: React.FormEvent<HTMLFormElement>) => {
216
- e?.preventDefault();
217
- e?.stopPropagation();
218
- setIsSubmitting(true);
219
- setSubmitCount((prev: number) => prev + 1);
220
- const validationErrors = await validation?.(valuesRef.current);
221
- if (validationErrors && Object.keys(validationErrors).length > 0) {
222
- setErrors(validationErrors);
223
- } else {
224
- setErrors({});
225
- await onSubmit?.(valuesRef.current, controllerRef.current);
226
- }
227
- setIsSubmitting(false);
228
- setVersion((prev: number) => prev + 1);
229
- },
230
- [onSubmit, validation]
231
- );
232
-
233
- const resetForm = useCallback((props?: FormexResetProps<T>) => {
234
- const {
235
- submitCount: submitCountProp,
236
- values: valuesProp,
237
- errors: errorsProp,
238
- touched: touchedProp,
239
- undoable
240
- } = props ?? {};
241
- const priorValues = valuesRef.current;
242
- const priorTouched = touchedRef.current;
243
- const nextValues = valuesProp ?? initialValuesRef.current;
244
- const nextTouched = touchedProp ?? initialTouched ?? {};
245
- valuesRef.current = nextValues;
246
- initialValuesRef.current = nextValues;
247
- setValuesInner(nextValues);
248
- setErrors(errorsProp ?? {});
249
- setTouchedState(nextTouched);
250
- setDirty(false);
251
- setSubmitCount(submitCountProp ?? 0);
252
- setVersion((prev: number) => prev + 1);
253
- onReset?.(controllerRef.current);
254
- if (undoable) {
255
- // Keep what the user typed one step behind them. The entry stepped
256
- // back into carries the touched map as well as the values: without
257
- // it the values return but every field reads untouched, and a draft
258
- // backup — which is extracted *through* the touched map — would come
259
- // back empty.
260
- const kept = historyRef.current.slice(0, historyIndexRef.current + 1);
261
- kept[kept.length - 1] = { values: priorValues, touched: priorTouched };
262
- kept.push({ values: nextValues, touched: nextTouched, boundary: true });
263
- historyRef.current = kept;
264
- historyIndexRef.current = kept.length - 1;
265
- } else {
266
- historyRef.current = [{ values: nextValues }];
267
- historyIndexRef.current = 0;
268
- }
269
- }, [onReset, initialTouched]);
270
-
271
- /**
272
- * The `initialValues` prop moved: the record this form edits finished
273
- * loading, or was replaced. That is a **re-baseline**, not a reset.
274
- *
275
- * It used to call `resetForm({ values: initialValues })`, which is a reset
276
- * in both of the ways that matter, and both were wrong here:
277
- *
278
- * - it fired `onReset`, which callers reasonably read as "the user
279
- * discarded their changes". The admin clears the cache that seeds an
280
- * in-flight edit handed over from the side panel there, so a record's own
281
- * data arriving deleted the edit the form had just been opened with, and
282
- * the form then re-seeded itself from the server — an expanded record
283
- * silently lost whatever had been typed into it.
284
- * - it overwrote `values`, so anything typed while the record was still
285
- * loading was thrown away without a word.
286
- *
287
- * So: move the baseline, leave the edit alone, and re-judge one against
288
- * the other. Only an untouched form follows the baseline to its new value.
289
- */
290
- useEffect(() => {
291
- if (equal(initialValuesRef.current, initialValues)) return;
292
-
293
- const modified = !equal(initialValuesRef.current, valuesRef.current);
294
- initialValuesRef.current = initialValues;
295
-
296
- if (modified) {
297
- setDirty(!equal(initialValues, valuesRef.current));
298
- } else {
299
- valuesRef.current = initialValues;
300
- setValuesInner(initialValues);
301
- historyRef.current = [{ values: initialValues }];
302
- historyIndexRef.current = 0;
303
- setDirty(false);
304
- }
305
- // Containers that read `values` off the controller key on `version`;
306
- // a re-seed changes what they are holding just as a reset does.
307
- setVersion((prev: number) => prev + 1);
308
- }, [initialValues]);
309
-
310
- const stepHistory = useCallback((newIndex: number) => {
311
- const from = historyRef.current[historyIndexRef.current];
312
- const entry = historyRef.current[newIndex];
313
- const newValues = entry.values;
314
- setValuesInner(newValues);
315
- valuesRef.current = newValues;
316
- historyIndexRef.current = newIndex;
317
- setDirty(!equal(initialValuesRef.current, newValues));
318
- if (entry.touched) {
319
- setTouchedState(entry.touched);
320
- }
321
- // Stepping across a reset. The reset told everything reading `values`
322
- // off the controller to re-read itself, so the way back has to say so
323
- // too — otherwise a cleared markdown editor stays cleared while the
324
- // value behind it is already back. Ordinary steps skip this.
325
- if (from?.boundary || entry.boundary) {
326
- setVersion((prev: number) => prev + 1);
327
- }
328
- callDebouncedOnValuesChange(newValues);
329
- }, [callDebouncedOnValuesChange]);
330
-
331
- const undo = useCallback(() => {
332
- if (historyIndexRef.current > 0) {
333
- stepHistory(historyIndexRef.current - 1);
334
- }
335
- }, [stepHistory]);
336
-
337
- const redo = useCallback(() => {
338
- if (historyIndexRef.current < historyRef.current.length - 1) {
339
- stepHistory(historyIndexRef.current + 1);
340
- }
341
- }, [stepHistory]);
342
-
343
- const controllerRef = useRef<FormexController<T>>({} as FormexController<T>);
344
-
345
- const controller = useMemo<FormexController<T>>(
346
- () => ({
347
- values,
348
- initialValues: initialValuesRef.current,
349
- handleChange,
350
- isSubmitting,
351
- setSubmitting: setIsSubmitting,
352
- setValues,
353
- setFieldValue,
354
- errors,
355
- setFieldError,
356
- touched: touchedState,
357
- setFieldTouched,
358
- setTouched: setTouchedState,
359
- dirty,
360
- setDirty,
361
- handleSubmit: submit,
362
- submitCount,
363
- setSubmitCount,
364
- handleBlur,
365
- validate,
366
- isValidating,
367
- resetForm,
368
- version,
369
- debugId: debugIdRef.current,
370
- undo,
371
- redo,
372
- canUndo: historyIndexRef.current > 0,
373
- canRedo: historyIndexRef.current < historyRef.current.length - 1
374
- }),
375
- [
376
- values,
377
- errors,
378
- touchedState,
379
- dirty,
380
- isSubmitting,
381
- submitCount,
382
- isValidating,
383
- version,
384
- handleChange,
385
- handleBlur,
386
- setValues,
387
- setFieldValue,
388
- setFieldTouched,
389
- setTouchedState,
390
- setFieldError,
391
- validate,
392
- submit,
393
- resetForm,
394
- undo,
395
- redo
396
- ]
397
- );
398
-
399
- useEffect(() => {
400
- controllerRef.current = controller;
401
- }, [controller]);
402
-
403
- return controller;
404
- }
package/src/utils.ts DELETED
@@ -1,133 +0,0 @@
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
- // The read counterpart. `getIn(values, "constructor.prototype")` handing
32
- // back `Object.prototype` is how a polluted value gets read back out, and
33
- // how a form comes to render one.
34
- if (pathTraversesPrototype(key)) return def;
35
-
36
- const path = toPath(key);
37
- let current: unknown = obj;
38
- while (current && p < path.length) {
39
- current = (current as Record<string, unknown>)[path[p++]];
40
- }
41
-
42
- // check if path is not in the end
43
- if (p !== path.length && !current) {
44
- return def;
45
- }
46
-
47
- return current === undefined ? def : current;
48
- }
49
-
50
- export function setIn(obj: unknown, path: string, value: unknown): unknown {
51
- // Refused rather than sanitised: there is no legitimate reading of a form
52
- // field whose path names the prototype chain, and silently rewriting the
53
- // path would write the value somewhere the caller did not ask for.
54
- // Returning the original object is what every other no-op in this function
55
- // does.
56
- if (pathTraversesPrototype(path)) return obj;
57
-
58
- const res = clone(obj) as Record<string, unknown>; // this keeps inheritance when obj is a class
59
- let resVal: Record<string, unknown> = res;
60
- let i = 0;
61
- const pathArray = toPath(path);
62
-
63
- for (; i < pathArray.length - 1; i++) {
64
- const currentPath: string = pathArray[i];
65
- const currentObj = getIn(obj, pathArray.slice(0, i + 1));
66
-
67
- if (currentObj && (isObject(currentObj) || Array.isArray(currentObj))) {
68
- resVal = resVal[currentPath] = clone(currentObj) as Record<string, unknown>;
69
- } else {
70
- const nextPath: string = pathArray[i + 1];
71
- resVal = resVal[currentPath] =
72
- (isInteger(nextPath) && Number(nextPath) >= 0 ? [] : {}) as Record<string, unknown>;
73
- }
74
- }
75
-
76
- // Return original object if new value is the same as current
77
- if ((i === 0 ? (obj as Record<string, unknown>) : resVal)[pathArray[i]] === value) {
78
- return obj;
79
- }
80
-
81
- if (value === undefined) {
82
- delete resVal[pathArray[i]];
83
- } else {
84
- resVal[pathArray[i]] = value;
85
- }
86
-
87
- // If the path array has a single element, the loop did not run.
88
- // Deleting on `resVal` had no effect in this scenario, so we delete on the result instead.
89
- if (i === 0 && value === undefined) {
90
- delete res[pathArray[i]];
91
- }
92
-
93
- return res;
94
- }
95
-
96
- export function clone(value: unknown): unknown {
97
- if (Array.isArray(value)) {
98
- return [...value];
99
- } else if (typeof value === "object" && value !== null) {
100
- // Preserve class instances (EntityReference, GeoPoint, etc.) - don't spread them
101
- if (Object.getPrototypeOf(value) !== Object.prototype) {
102
- return value;
103
- }
104
- return { ...(value as Record<string, unknown>) };
105
- } else {
106
- return value; // This is for primitive types which do not need cloning.
107
- }
108
- }
109
-
110
- /**
111
- * Segments that reach the prototype chain rather than a property of the object.
112
- *
113
- * `res["__proto__"] = x` is a setter for the object's prototype, not an own
114
- * property, so a path of `__proto__.polluted` wrote straight onto
115
- * `Object.prototype` and gave every object in the process a `polluted`
116
- * property. `constructor.prototype.x` arrived by a second route, and
117
- * `__proto__.0` did it to arrays.
118
- *
119
- * These are paths, and a path here is a property key — which for a map property
120
- * or a column mapped out of an imported CSV is data, not code.
121
- */
122
- const UNSAFE_PATH_SEGMENTS = new Set(["__proto__", "constructor", "prototype"]);
123
-
124
- /** Whether any segment of this path would traverse the prototype chain. */
125
- export function pathTraversesPrototype(path: string | string[]): boolean {
126
- return toPath(path).some(segment => UNSAFE_PATH_SEGMENTS.has(segment));
127
- }
128
-
129
- function toPath(value: string | string[]) {
130
- if (Array.isArray(value)) return value; // Already in path array form.
131
- // Replace brackets with dots, remove leading/trailing dots, then split by dot.
132
- return value.replace(/\[(\d+)]/g, ".$1").replace(/^\./, "").replace(/\.$/, "").split(".");
133
- }