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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/.turbo/turbo-build.log +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
+ }