remix-validated-form 4.5.2 → 4.6.0-beta.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (38) hide show
  1. package/.turbo/turbo-build.log +8 -9
  2. package/browser/ValidatedForm.js +0 -3
  3. package/browser/index.d.ts +1 -0
  4. package/browser/index.js +1 -0
  5. package/browser/internal/hooks.d.ts +1 -0
  6. package/browser/internal/hooks.js +3 -4
  7. package/browser/internal/state/controlledFields.d.ts +1 -0
  8. package/browser/internal/state/controlledFields.js +17 -29
  9. package/browser/internal/state/createFormStore.d.ts +31 -1
  10. package/browser/internal/state/createFormStore.js +177 -14
  11. package/browser/server.d.ts +2 -2
  12. package/browser/server.js +1 -1
  13. package/dist/remix-validated-form.cjs.js +12 -3
  14. package/dist/remix-validated-form.cjs.js.map +1 -1
  15. package/dist/remix-validated-form.es.js +361 -131
  16. package/dist/remix-validated-form.es.js.map +1 -1
  17. package/dist/remix-validated-form.umd.js +12 -3
  18. package/dist/remix-validated-form.umd.js.map +1 -1
  19. package/dist/types/index.d.ts +1 -0
  20. package/dist/types/internal/hooks.d.ts +1 -0
  21. package/dist/types/internal/state/arrayUtil.d.ts +12 -0
  22. package/dist/types/internal/state/controlledFields.d.ts +1 -0
  23. package/dist/types/internal/state/createFormStore.d.ts +31 -1
  24. package/dist/types/internal/state/fieldArray.d.ts +28 -0
  25. package/dist/types/server.d.ts +2 -2
  26. package/package.json +1 -3
  27. package/src/ValidatedForm.tsx +0 -3
  28. package/src/index.ts +6 -0
  29. package/src/internal/hooks.ts +9 -4
  30. package/src/internal/logic/nestedObjectToPathObject.ts +63 -0
  31. package/src/internal/state/arrayUtil.ts +399 -0
  32. package/src/internal/state/controlledFields.ts +39 -43
  33. package/src/internal/state/createFormStore.ts +288 -20
  34. package/src/internal/state/fieldArray.tsx +155 -0
  35. package/src/server.ts +1 -1
  36. package/vite.config.ts +1 -1
  37. package/dist/types/internal/state/controlledFieldStore.d.ts +0 -26
  38. package/src/internal/state/controlledFieldStore.ts +0 -112
@@ -4,3 +4,4 @@ export * from "./ValidatedForm";
4
4
  export * from "./validation/types";
5
5
  export * from "./validation/createValidator";
6
6
  export * from "./userFacingFormContext";
7
+ export { FieldArray, useFieldArray, type FieldArrayProps, type FieldArrayHelpers, } from "./internal/state/fieldArray";
@@ -13,6 +13,7 @@ export declare const useHasActiveFormSubmit: ({ fetcher, }: InternalFormContextV
13
13
  export declare const useFieldTouched: (field: string, { formId }: InternalFormContextValue) => readonly [boolean, (touched: boolean) => void];
14
14
  export declare const useFieldError: (name: string, context: InternalFormContextValue) => string | undefined;
15
15
  export declare const useClearError: (context: InternalFormContextValue) => (field: string) => void;
16
+ export declare const useCurrentDefaultValueForField: (formId: InternalFormId, field: string) => any;
16
17
  export declare const useFieldDefaultValue: (name: string, context: InternalFormContextValue) => any;
17
18
  export declare const useInternalIsSubmitting: (formId: InternalFormId) => boolean;
18
19
  export declare const useInternalIsValid: (formId: InternalFormId) => boolean;
@@ -0,0 +1,12 @@
1
+ export declare const getArray: (values: any, field: string) => unknown[];
2
+ export declare const swap: (array: unknown[], indexA: number, indexB: number) => void;
3
+ export declare const move: (array: unknown[], from: number, to: number) => void;
4
+ export declare const insert: (array: unknown[], index: number, value: unknown) => void;
5
+ export declare const remove: (array: unknown[], index: number) => void;
6
+ export declare const replace: (array: unknown[], index: number, value: unknown) => void;
7
+ /**
8
+ * The purpose of this helper is to make it easier to update `fieldErrors` and `touchedFields`.
9
+ * We key those objects by full paths to the fields.
10
+ * When we're doing array mutations, that makes it difficult to update those objects.
11
+ */
12
+ export declare const mutateAsArray: (field: string, obj: Record<string, any>, mutate: (arr: any[]) => void) => void;
@@ -1,6 +1,7 @@
1
1
  import { InternalFormContextValue } from "../formContext";
2
2
  import { InternalFormId } from "./types";
3
3
  export declare const useControlledFieldValue: (context: InternalFormContextValue, field: string) => any;
4
+ export declare const useRegisterControlledField: (context: InternalFormContextValue, field: string) => void;
4
5
  export declare const useControllableValue: (context: InternalFormContextValue, field: string) => readonly [any, (value: unknown) => void];
5
6
  export declare const useUpdateControllableValue: (formId: InternalFormId) => (field: string, value: unknown) => void;
6
7
  export declare const useAwaitValue: (formId: InternalFormId) => (field: string) => Promise<void>;
@@ -27,6 +27,7 @@ export declare type FormState = {
27
27
  touchedFields: TouchedFields;
28
28
  formProps?: SyncedFormProps;
29
29
  formElement: HTMLFormElement | null;
30
+ currentDefaultValues: Record<string, any>;
30
31
  isValid: () => boolean;
31
32
  startSubmit: () => void;
32
33
  endSubmit: () => void;
@@ -36,13 +37,42 @@ export declare type FormState = {
36
37
  clearFieldError: (field: string) => void;
37
38
  reset: () => void;
38
39
  syncFormProps: (props: SyncedFormProps) => void;
39
- setHydrated: () => void;
40
40
  setFormElement: (formElement: HTMLFormElement | null) => void;
41
41
  validateField: (fieldName: string) => Promise<string | null>;
42
42
  validate: () => Promise<ValidationResult<unknown>>;
43
43
  resetFormElement: () => void;
44
44
  submit: () => void;
45
45
  getValues: () => FormData;
46
+ controlledFields: {
47
+ values: {
48
+ [fieldName: string]: any;
49
+ };
50
+ refCounts: {
51
+ [fieldName: string]: number;
52
+ };
53
+ valueUpdatePromises: {
54
+ [fieldName: string]: Promise<void>;
55
+ };
56
+ valueUpdateResolvers: {
57
+ [fieldName: string]: () => void;
58
+ };
59
+ register: (fieldName: string) => void;
60
+ unregister: (fieldName: string) => void;
61
+ setValue: (fieldName: string, value: unknown) => void;
62
+ kickoffValueUpdate: (fieldName: string) => void;
63
+ getValue: (fieldName: string) => unknown;
64
+ awaitValueUpdate: (fieldName: string) => Promise<void>;
65
+ array: {
66
+ push: (fieldName: string, value: unknown) => void;
67
+ swap: (fieldName: string, indexA: number, indexB: number) => void;
68
+ move: (fieldName: string, fromIndex: number, toIndex: number) => void;
69
+ insert: (fieldName: string, index: number, value: unknown) => void;
70
+ unshift: (fieldName: string, value: unknown) => void;
71
+ remove: (fieldName: string, index: number) => void;
72
+ pop: (fieldName: string) => void;
73
+ replace: (fieldName: string, index: number, value: unknown) => void;
74
+ };
75
+ };
46
76
  };
47
77
  export declare const useRootFormStore: import("zustand").UseBoundStore<Omit<import("zustand").StoreApi<FormStoreState>, "setState"> & {
48
78
  setState(nextStateOrUpdater: FormStoreState | Partial<FormStoreState> | ((state: WritableDraft<FormStoreState>) => void), shouldReplace?: boolean | undefined): void;
@@ -0,0 +1,28 @@
1
+ import React from "react";
2
+ export declare type FieldArrayValidationBehavior = "onChange" | "onSubmit";
3
+ export declare type FieldArrayValidationBehaviorOptions = {
4
+ initial: FieldArrayValidationBehavior;
5
+ whenSubmitted: FieldArrayValidationBehavior;
6
+ };
7
+ export declare type FieldArrayHelpers<Item = any> = {
8
+ push: (item: Item) => void;
9
+ swap: (indexA: number, indexB: number) => void;
10
+ move: (from: number, to: number) => void;
11
+ insert: (index: number, value: Item) => void;
12
+ unshift: (value: Item) => void;
13
+ remove: (index: number) => void;
14
+ pop: () => void;
15
+ replace: (index: number, value: Item) => void;
16
+ };
17
+ export declare type UseFieldArrayOptions = {
18
+ formId?: string;
19
+ validationBehavior?: Partial<FieldArrayValidationBehaviorOptions>;
20
+ };
21
+ export declare function useFieldArray<Item = any>(name: string, { formId, validationBehavior }?: UseFieldArrayOptions): [itemDefaults: Item[], helpers: FieldArrayHelpers<any>, error: string | undefined];
22
+ export declare type FieldArrayProps = {
23
+ name: string;
24
+ children: (itemDefaults: any[], helpers: FieldArrayHelpers, error: string | undefined) => React.ReactNode;
25
+ formId?: string;
26
+ validationBehavior?: FieldArrayValidationBehaviorOptions;
27
+ };
28
+ export declare const FieldArray: ({ name, children, formId, validationBehavior, }: FieldArrayProps) => React.ReactNode;
@@ -1,5 +1,5 @@
1
1
  import { FORM_DEFAULTS_FIELD } from "./internal/constants";
2
- import { ValidatorError } from "./validation/types";
2
+ import { ValidatorError, ValidationErrorResponseData } from "./validation/types";
3
3
  /**
4
4
  * Takes the errors from a `Validator` and returns a `Response`.
5
5
  * When you return this from your action, `ValidatedForm` on the frontend will automatically
@@ -14,7 +14,7 @@ import { ValidatorError } from "./validation/types";
14
14
  * if (result.error) return validationError(result.error, result.submittedData);
15
15
  * ```
16
16
  */
17
- export declare function validationError(error: ValidatorError, repopulateFields?: unknown, init?: ResponseInit): Response;
17
+ export declare function validationError(error: ValidatorError, repopulateFields?: unknown, init?: ResponseInit): import("@remix-run/server-runtime").TypedResponse<ValidationErrorResponseData>;
18
18
  export declare type FormDefaults = {
19
19
  [formDefaultsKey: `${typeof FORM_DEFAULTS_FIELD}_${string}`]: any;
20
20
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "remix-validated-form",
3
- "version": "4.5.2",
3
+ "version": "4.6.0-beta.0",
4
4
  "description": "Form component and utils for easy form validation in remix",
5
5
  "browser": "./dist/remix-validated-form.cjs.js",
6
6
  "main": "./dist/remix-validated-form.umd.js",
@@ -34,12 +34,10 @@
34
34
  ],
35
35
  "peerDependencies": {
36
36
  "@remix-run/react": "1.x",
37
- "@remix-run/server-runtime": "1.x",
38
37
  "react": "^17.0.2 || ^18.0.0"
39
38
  },
40
39
  "devDependencies": {
41
40
  "@remix-run/react": "^1.6.5",
42
- "@remix-run/server-runtime": "^1.6.5",
43
41
  "@types/lodash": "^4.14.178",
44
42
  "@types/react": "^18.0.9",
45
43
  "fetch-blob": "^3.1.3",
@@ -23,7 +23,6 @@ import {
23
23
  useSetFieldErrors,
24
24
  } from "./internal/hooks";
25
25
  import { MultiValueMap, useMultiValueMap } from "./internal/MultiValueMap";
26
- import { useControlledFieldStore } from "./internal/state/controlledFieldStore";
27
26
  import {
28
27
  SyncedFormProps,
29
28
  useRootFormStore,
@@ -237,7 +236,6 @@ export function ValidatedForm<DataType>({
237
236
  const setFieldErrors = useSetFieldErrors(formId);
238
237
  const setFieldError = useFormStore(formId, (state) => state.setFieldError);
239
238
  const reset = useFormStore(formId, (state) => state.reset);
240
- const resetControlledFields = useControlledFieldStore((state) => state.reset);
241
239
  const startSubmit = useFormStore(formId, (state) => state.startSubmit);
242
240
  const endSubmit = useFormStore(formId, (state) => state.endSubmit);
243
241
  const syncFormProps = useFormStore(formId, (state) => state.syncFormProps);
@@ -354,7 +352,6 @@ export function ValidatedForm<DataType>({
354
352
  onReset?.(event);
355
353
  if (event.defaultPrevented) return;
356
354
  reset();
357
- resetControlledFields(formId);
358
355
  }}
359
356
  >
360
357
  <InternalFormContext.Provider value={contextValue}>
package/src/index.ts CHANGED
@@ -4,3 +4,9 @@ export * from "./ValidatedForm";
4
4
  export * from "./validation/types";
5
5
  export * from "./validation/createValidator";
6
6
  export * from "./userFacingFormContext";
7
+ export {
8
+ FieldArray,
9
+ useFieldArray,
10
+ type FieldArrayProps,
11
+ type FieldArrayHelpers,
12
+ } from "./internal/state/fieldArray";
@@ -141,15 +141,20 @@ export const useClearError = (context: InternalFormContextValue) => {
141
141
  return useFormStore(formId, (state) => state.clearFieldError);
142
142
  };
143
143
 
144
+ export const useCurrentDefaultValueForField = (
145
+ formId: InternalFormId,
146
+ field: string
147
+ ) =>
148
+ useFormStore(formId, (state) => lodashGet(state.currentDefaultValues, field));
149
+
144
150
  export const useFieldDefaultValue = (
145
151
  name: string,
146
152
  context: InternalFormContextValue
147
153
  ) => {
148
154
  const defaultValues = useDefaultValuesForForm(context);
149
- const state = useSyncedDefaultValues(context.formId);
150
- return defaultValues
151
- .map((val) => lodashGet(val, name))
152
- .hydrateTo(lodashGet(state, name));
155
+ const state = useCurrentDefaultValueForField(context.formId, name);
156
+
157
+ return defaultValues.map((val) => lodashGet(val, name)).hydrateTo(state);
153
158
  };
154
159
 
155
160
  export const useInternalIsSubmitting = (formId: InternalFormId) =>
@@ -0,0 +1,63 @@
1
+ export const nestedObjectToPathObject = (
2
+ val: any,
3
+ acc: Record<string, any>,
4
+ path: string
5
+ ): any => {
6
+ if (Array.isArray(val)) {
7
+ val.forEach((v, index) =>
8
+ nestedObjectToPathObject(v, acc, `${path}[${index}]`)
9
+ );
10
+ return acc;
11
+ }
12
+
13
+ if (typeof val === "object") {
14
+ Object.entries(val).forEach(([key, value]) => {
15
+ const nextPath = path ? `${path}.${key}` : key;
16
+ nestedObjectToPathObject(value, acc, nextPath);
17
+ });
18
+ return acc;
19
+ }
20
+
21
+ if (val !== undefined) {
22
+ acc[path] = val;
23
+ }
24
+
25
+ return acc;
26
+ };
27
+
28
+ if (import.meta.vitest) {
29
+ const { describe, expect, it } = import.meta.vitest;
30
+
31
+ describe("nestedObjectToPathObject", () => {
32
+ it("should return an object with the correct path", () => {
33
+ const result = nestedObjectToPathObject(
34
+ {
35
+ a: 1,
36
+ b: 2,
37
+ c: { foo: "bar", baz: [true, false] },
38
+ d: [
39
+ { foo: "bar", baz: [true, false] },
40
+ { e: true, f: "hi" },
41
+ ],
42
+ g: undefined,
43
+ },
44
+ {},
45
+ ""
46
+ );
47
+
48
+ expect(result).toEqual({
49
+ a: 1,
50
+ b: 2,
51
+ "c.foo": "bar",
52
+ "c.baz[0]": true,
53
+ "c.baz[1]": false,
54
+ "d[0].foo": "bar",
55
+ "d[0].baz[0]": true,
56
+ "d[0].baz[1]": false,
57
+ "d[1].e": true,
58
+ "d[1].f": "hi",
59
+ });
60
+ expect(Object.keys(result)).toHaveLength(10);
61
+ });
62
+ });
63
+ }
@@ -0,0 +1,399 @@
1
+ import lodashGet from "lodash/get";
2
+ import lodashSet from "lodash/set";
3
+ import invariant from "tiny-invariant";
4
+
5
+ ////
6
+ // All of these array helpers are written in a way that mutates the original array.
7
+ // This is because we're working with immer.
8
+ ////
9
+
10
+ export const getArray = (values: any, field: string): unknown[] => {
11
+ const value = lodashGet(values, field);
12
+ if (value === undefined || value === null) {
13
+ const newValue: unknown[] = [];
14
+ lodashSet(values, field, newValue);
15
+ return newValue;
16
+ }
17
+ invariant(
18
+ Array.isArray(value),
19
+ `FieldArray: defaultValue value for ${field} must be an array, null, or undefined`
20
+ );
21
+ return value;
22
+ };
23
+
24
+ export const swap = (array: unknown[], indexA: number, indexB: number) => {
25
+ const itemA = array[indexA];
26
+ const itemB = array[indexB];
27
+
28
+ const hasItemA = indexA in array;
29
+ const hasItemB = indexB in array;
30
+
31
+ // If we're dealing with a sparse array (i.e. one of the indeces doesn't exist),
32
+ // we should keep it sparse
33
+ if (hasItemA) {
34
+ array[indexB] = itemA;
35
+ } else {
36
+ delete array[indexB];
37
+ }
38
+
39
+ if (hasItemB) {
40
+ array[indexA] = itemB;
41
+ } else {
42
+ delete array[indexA];
43
+ }
44
+ };
45
+
46
+ // A splice that can handle sparse arrays
47
+ function sparseSplice(
48
+ array: unknown[],
49
+ start: number,
50
+ deleteCount: number,
51
+ item?: unknown
52
+ ) {
53
+ // Inserting an item into an array won't behave as we need it to if the array isn't
54
+ // at least as long as the start index. We can force the array to be long enough like this.
55
+ if (array.length < start && item) {
56
+ array.length = start;
57
+ }
58
+
59
+ // If we just pass item in, it'll be undefined and splice will delete the item.
60
+ if (arguments.length === 4) return array.splice(start, deleteCount, item);
61
+ return array.splice(start, deleteCount);
62
+ }
63
+
64
+ export const move = (array: unknown[], from: number, to: number) => {
65
+ const [item] = sparseSplice(array, from, 1);
66
+ sparseSplice(array, to, 0, item);
67
+ };
68
+
69
+ export const insert = (array: unknown[], index: number, value: unknown) => {
70
+ sparseSplice(array, index, 0, value);
71
+ };
72
+
73
+ export const remove = (array: unknown[], index: number) => {
74
+ sparseSplice(array, index, 1);
75
+ };
76
+
77
+ export const replace = (array: unknown[], index: number, value: unknown) => {
78
+ sparseSplice(array, index, 1, value);
79
+ };
80
+
81
+ /**
82
+ * The purpose of this helper is to make it easier to update `fieldErrors` and `touchedFields`.
83
+ * We key those objects by full paths to the fields.
84
+ * When we're doing array mutations, that makes it difficult to update those objects.
85
+ */
86
+ export const mutateAsArray = (
87
+ field: string,
88
+ obj: Record<string, any>,
89
+ mutate: (arr: any[]) => void
90
+ ) => {
91
+ const beforeKeys = new Set<string>();
92
+ const arr: any[] = [];
93
+
94
+ for (const [key, value] of Object.entries(obj)) {
95
+ if (key.startsWith(field) && key !== field) {
96
+ beforeKeys.add(key);
97
+ }
98
+ lodashSet(arr, key.substring(field.length), value);
99
+ }
100
+
101
+ mutate(arr);
102
+ for (const key of beforeKeys) {
103
+ delete obj[key];
104
+ }
105
+
106
+ const newKeys = getDeepArrayPaths(arr);
107
+ for (const key of newKeys) {
108
+ const val = lodashGet(arr, key);
109
+ obj[`${field}${key}`] = val;
110
+ }
111
+ };
112
+
113
+ const getDeepArrayPaths = (obj: any, basePath: string = ""): string[] => {
114
+ // This only needs to handle arrays and plain objects
115
+ // and we can assume the first call is always an array.
116
+
117
+ if (Array.isArray(obj)) {
118
+ return obj.flatMap((item, index) =>
119
+ getDeepArrayPaths(item, `${basePath}[${index}]`)
120
+ );
121
+ }
122
+
123
+ if (typeof obj === "object") {
124
+ return Object.keys(obj).flatMap((key) =>
125
+ getDeepArrayPaths(obj[key], `${basePath}.${key}`)
126
+ );
127
+ }
128
+
129
+ return [basePath];
130
+ };
131
+
132
+ if (import.meta.vitest) {
133
+ const { describe, expect, it } = import.meta.vitest;
134
+
135
+ // Count the actual number of items in the array
136
+ // instead of just getting the length.
137
+ // This is useful for validating that sparse arrays are handled correctly.
138
+ const countArrayItems = (arr: any[]) => {
139
+ let count = 0;
140
+ arr.forEach(() => count++);
141
+ return count;
142
+ };
143
+
144
+ describe("getArray", () => {
145
+ it("shoud get a deeply nested array that can be mutated to update the nested value", () => {
146
+ const values = {
147
+ d: [
148
+ { foo: "bar", baz: [true, false] },
149
+ { e: true, f: "hi" },
150
+ ],
151
+ };
152
+ const result = getArray(values, "d[0].baz");
153
+ const finalValues = {
154
+ d: [
155
+ { foo: "bar", baz: [true, false, true] },
156
+ { e: true, f: "hi" },
157
+ ],
158
+ };
159
+
160
+ expect(result).toEqual([true, false]);
161
+ result.push(true);
162
+ expect(values).toEqual(finalValues);
163
+ });
164
+
165
+ it("should return an empty array that can be mutated if result is null or undefined", () => {
166
+ const values = {};
167
+ const result = getArray(values, "a.foo[0].bar");
168
+ const finalValues = {
169
+ a: { foo: [{ bar: ["Bob ross"] }] },
170
+ };
171
+
172
+ expect(result).toEqual([]);
173
+ result.push("Bob ross");
174
+ expect(values).toEqual(finalValues);
175
+ });
176
+
177
+ it("should throw if the value is defined and not an array", () => {
178
+ const values = { foo: "foo" };
179
+ expect(() => getArray(values, "foo")).toThrow();
180
+ });
181
+ });
182
+
183
+ describe("swap", () => {
184
+ it("should swap two items", () => {
185
+ const array = [1, 2, 3];
186
+ swap(array, 0, 1);
187
+ expect(array).toEqual([2, 1, 3]);
188
+ });
189
+
190
+ it("should work for sparse arrays", () => {
191
+ // A bit of a sanity check for native array behavior
192
+ const arr = [] as any[];
193
+ arr[0] = true;
194
+ swap(arr, 0, 2);
195
+
196
+ expect(countArrayItems(arr)).toEqual(1);
197
+ expect(0 in arr).toBe(false);
198
+ expect(2 in arr).toBe(true);
199
+ expect(arr[2]).toEqual(true);
200
+ });
201
+ });
202
+
203
+ describe("move", () => {
204
+ it("should move an item to a new index", () => {
205
+ const array = [1, 2, 3];
206
+ move(array, 0, 1);
207
+ expect(array).toEqual([2, 1, 3]);
208
+ });
209
+
210
+ it("should work with sparse arrays", () => {
211
+ const array = [1];
212
+ move(array, 0, 2);
213
+
214
+ expect(countArrayItems(array)).toEqual(1);
215
+ expect(array).toEqual([undefined, undefined, 1]);
216
+ });
217
+ });
218
+
219
+ describe("insert", () => {
220
+ it("should insert an item at a new index", () => {
221
+ const array = [1, 2, 3];
222
+ insert(array, 1, 4);
223
+ expect(array).toEqual([1, 4, 2, 3]);
224
+ });
225
+
226
+ it("should be able to insert falsey values", () => {
227
+ const array = [1, 2, 3];
228
+ insert(array, 1, null);
229
+ expect(array).toEqual([1, null, 2, 3]);
230
+ });
231
+
232
+ it("should handle sparse arrays", () => {
233
+ const array: any[] = [];
234
+ array[2] = true;
235
+ insert(array, 0, true);
236
+
237
+ expect(countArrayItems(array)).toEqual(2);
238
+ expect(array).toEqual([true, undefined, undefined, true]);
239
+ });
240
+ });
241
+
242
+ describe("remove", () => {
243
+ it("should remove an item at a given index", () => {
244
+ const array = [1, 2, 3];
245
+ remove(array, 1);
246
+ expect(array).toEqual([1, 3]);
247
+ });
248
+
249
+ it("should handle sparse arrays", () => {
250
+ const array: any[] = [];
251
+ array[2] = true;
252
+ remove(array, 0);
253
+
254
+ expect(countArrayItems(array)).toEqual(1);
255
+ expect(array).toEqual([undefined, true]);
256
+ });
257
+ });
258
+
259
+ describe("replace", () => {
260
+ it("should replace an item at a given index", () => {
261
+ const array = [1, 2, 3];
262
+ replace(array, 1, 4);
263
+ expect(array).toEqual([1, 4, 3]);
264
+ });
265
+
266
+ it("should handle sparse arrays", () => {
267
+ const array: any[] = [];
268
+ array[2] = true;
269
+ replace(array, 0, true);
270
+ expect(countArrayItems(array)).toEqual(2);
271
+ expect(array).toEqual([true, undefined, true]);
272
+ });
273
+ });
274
+
275
+ describe("mutateAsArray", () => {
276
+ it("should handle swap", () => {
277
+ const values = {
278
+ myField: "something",
279
+ "myField[0]": "foo",
280
+ "myField[2]": "bar",
281
+ otherField: "baz",
282
+ "otherField[0]": "something else",
283
+ };
284
+ mutateAsArray("myField", values, (arr) => {
285
+ swap(arr, 0, 2);
286
+ });
287
+ expect(values).toEqual({
288
+ myField: "something",
289
+ "myField[0]": "bar",
290
+ "myField[2]": "foo",
291
+ otherField: "baz",
292
+ "otherField[0]": "something else",
293
+ });
294
+ });
295
+
296
+ it("should swap sparse arrays", () => {
297
+ const values = {
298
+ myField: "something",
299
+ "myField[0]": "foo",
300
+ otherField: "baz",
301
+ "otherField[0]": "something else",
302
+ };
303
+ mutateAsArray("myField", values, (arr) => {
304
+ swap(arr, 0, 2);
305
+ });
306
+ expect(values).toEqual({
307
+ myField: "something",
308
+ "myField[2]": "foo",
309
+ otherField: "baz",
310
+ "otherField[0]": "something else",
311
+ });
312
+ });
313
+
314
+ it("should handle arrays with nested values", () => {
315
+ const values = {
316
+ myField: "something",
317
+ "myField[0].title": "foo",
318
+ "myField[0].note": "bar",
319
+ "myField[2].title": "other",
320
+ "myField[2].note": "other",
321
+ otherField: "baz",
322
+ "otherField[0]": "something else",
323
+ };
324
+ mutateAsArray("myField", values, (arr) => {
325
+ swap(arr, 0, 2);
326
+ });
327
+ expect(values).toEqual({
328
+ myField: "something",
329
+ "myField[0].title": "other",
330
+ "myField[0].note": "other",
331
+ "myField[2].title": "foo",
332
+ "myField[2].note": "bar",
333
+ otherField: "baz",
334
+ "otherField[0]": "something else",
335
+ });
336
+ });
337
+
338
+ it("should handle move", () => {
339
+ const values = {
340
+ myField: "something",
341
+ "myField[0]": "foo",
342
+ "myField[1]": "bar",
343
+ "myField[2]": "baz",
344
+ "otherField[0]": "something else",
345
+ };
346
+ mutateAsArray("myField", values, (arr) => {
347
+ move(arr, 0, 2);
348
+ });
349
+ expect(values).toEqual({
350
+ myField: "something",
351
+ "myField[0]": "bar",
352
+ "myField[1]": "baz",
353
+ "myField[2]": "foo",
354
+ "otherField[0]": "something else",
355
+ });
356
+ });
357
+
358
+ it("should handle remove", () => {
359
+ const values = {
360
+ myField: "something",
361
+ "myField[0]": "foo",
362
+ "myField[1]": "bar",
363
+ "myField[2]": "baz",
364
+ "otherField[0]": "something else",
365
+ };
366
+ mutateAsArray("myField", values, (arr) => {
367
+ remove(arr, 1);
368
+ });
369
+ expect(values).toEqual({
370
+ myField: "something",
371
+ "myField[0]": "foo",
372
+ "myField[1]": "baz",
373
+ "otherField[0]": "something else",
374
+ });
375
+ expect("myField[2]" in values).toBe(false);
376
+ });
377
+ });
378
+
379
+ describe("getDeepArrayPaths", () => {
380
+ it("should return all paths recursively", () => {
381
+ const obj = [
382
+ true,
383
+ true,
384
+ [true, true],
385
+ { foo: true, bar: { baz: true, test: [true] } },
386
+ ];
387
+
388
+ expect(getDeepArrayPaths(obj, "myField")).toEqual([
389
+ "myField[0]",
390
+ "myField[1]",
391
+ "myField[2][0]",
392
+ "myField[2][1]",
393
+ "myField[3].foo",
394
+ "myField[3].bar.baz",
395
+ "myField[3].bar.test[0]",
396
+ ]);
397
+ });
398
+ });
399
+ }