UNPKG

remix-validated-form

Version:

Form component and utils for easy form validation in remix

351 lines (350 loc) 12.4 kB
import { getPath, setPath } from "set-get"; import invariant from "tiny-invariant"; //// // All of these array helpers are written in a way that mutates the original array. // This is because we're working with immer. //// export const getArray = (values, field) => { const value = getPath(values, field); if (value === undefined || value === null) { const newValue = []; setPath(values, field, newValue); return newValue; } invariant(Array.isArray(value), `FieldArray: defaultValue value for ${field} must be an array, null, or undefined`); return value; }; export const swap = (array, indexA, indexB) => { const itemA = array[indexA]; const itemB = array[indexB]; const hasItemA = indexA in array; const hasItemB = indexB in array; // If we're dealing with a sparse array (i.e. one of the indeces doesn't exist), // we should keep it sparse if (hasItemA) { array[indexB] = itemA; } else { delete array[indexB]; } if (hasItemB) { array[indexA] = itemB; } else { delete array[indexA]; } }; // A splice that can handle sparse arrays function sparseSplice(array, start, deleteCount, item) { // Inserting an item into an array won't behave as we need it to if the array isn't // at least as long as the start index. We can force the array to be long enough like this. if (array.length < start && item) { array.length = start; } // If we just pass item in, it'll be undefined and splice will delete the item. if (arguments.length === 4) return array.splice(start, deleteCount, item); return array.splice(start, deleteCount); } export const move = (array, from, to) => { const [item] = sparseSplice(array, from, 1); sparseSplice(array, to, 0, item); }; export const insert = (array, index, value) => { sparseSplice(array, index, 0, value); }; export const remove = (array, index) => { sparseSplice(array, index, 1); }; export const replace = (array, index, value) => { sparseSplice(array, index, 1, value); }; /** * The purpose of this helper is to make it easier to update `fieldErrors` and `touchedFields`. * We key those objects by full paths to the fields. * When we're doing array mutations, that makes it difficult to update those objects. */ export const mutateAsArray = (field, obj, mutate) => { const beforeKeys = new Set(); const arr = []; for (const [key, value] of Object.entries(obj)) { if (key.startsWith(field) && key !== field) { beforeKeys.add(key); setPath(arr, key.substring(field.length), value); } } mutate(arr); for (const key of beforeKeys) { delete obj[key]; } const newKeys = getDeepArrayPaths(arr); for (const key of newKeys) { const val = getPath(arr, key); if (val !== undefined) { obj[`${field}${key}`] = val; } } }; const getDeepArrayPaths = (obj, basePath = "") => { // This only needs to handle arrays and plain objects // and we can assume the first call is always an array. if (Array.isArray(obj)) { return obj.flatMap((item, index) => getDeepArrayPaths(item, `${basePath}[${index}]`)); } if (typeof obj === "object") { return Object.keys(obj).flatMap((key) => getDeepArrayPaths(obj[key], `${basePath}.${key}`)); } return [basePath]; }; if (import.meta.vitest) { const { describe, expect, it } = import.meta.vitest; // Count the actual number of items in the array // instead of just getting the length. // This is useful for validating that sparse arrays are handled correctly. const countArrayItems = (arr) => { let count = 0; arr.forEach(() => count++); return count; }; describe("getArray", () => { it("shoud get a deeply nested array that can be mutated to update the nested value", () => { const values = { d: [ { foo: "bar", baz: [true, false] }, { e: true, f: "hi" }, ], }; const result = getArray(values, "d[0].baz"); const finalValues = { d: [ { foo: "bar", baz: [true, false, true] }, { e: true, f: "hi" }, ], }; expect(result).toEqual([true, false]); result.push(true); expect(values).toEqual(finalValues); }); it("should return an empty array that can be mutated if result is null or undefined", () => { const values = {}; const result = getArray(values, "a.foo[0].bar"); const finalValues = { a: { foo: [{ bar: ["Bob ross"] }] }, }; expect(result).toEqual([]); result.push("Bob ross"); expect(values).toEqual(finalValues); }); it("should throw if the value is defined and not an array", () => { const values = { foo: "foo" }; expect(() => getArray(values, "foo")).toThrow(); }); }); describe("swap", () => { it("should swap two items", () => { const array = [1, 2, 3]; swap(array, 0, 1); expect(array).toEqual([2, 1, 3]); }); it("should work for sparse arrays", () => { // A bit of a sanity check for native array behavior const arr = []; arr[0] = true; swap(arr, 0, 2); expect(countArrayItems(arr)).toEqual(1); expect(0 in arr).toBe(false); expect(2 in arr).toBe(true); expect(arr[2]).toEqual(true); }); }); describe("move", () => { it("should move an item to a new index", () => { const array = [1, 2, 3]; move(array, 0, 1); expect(array).toEqual([2, 1, 3]); }); it("should work with sparse arrays", () => { const array = [1]; move(array, 0, 2); expect(countArrayItems(array)).toEqual(1); expect(array).toEqual([undefined, undefined, 1]); }); }); describe("insert", () => { it("should insert an item at a new index", () => { const array = [1, 2, 3]; insert(array, 1, 4); expect(array).toEqual([1, 4, 2, 3]); }); it("should be able to insert falsey values", () => { const array = [1, 2, 3]; insert(array, 1, null); expect(array).toEqual([1, null, 2, 3]); }); it("should handle sparse arrays", () => { const array = []; array[2] = true; insert(array, 0, true); expect(countArrayItems(array)).toEqual(2); expect(array).toEqual([true, undefined, undefined, true]); }); }); describe("remove", () => { it("should remove an item at a given index", () => { const array = [1, 2, 3]; remove(array, 1); expect(array).toEqual([1, 3]); }); it("should handle sparse arrays", () => { const array = []; array[2] = true; remove(array, 0); expect(countArrayItems(array)).toEqual(1); expect(array).toEqual([undefined, true]); }); }); describe("replace", () => { it("should replace an item at a given index", () => { const array = [1, 2, 3]; replace(array, 1, 4); expect(array).toEqual([1, 4, 3]); }); it("should handle sparse arrays", () => { const array = []; array[2] = true; replace(array, 0, true); expect(countArrayItems(array)).toEqual(2); expect(array).toEqual([true, undefined, true]); }); }); describe("mutateAsArray", () => { it("should handle swap", () => { const values = { myField: "something", "myField[0]": "foo", "myField[2]": "bar", otherField: "baz", "otherField[0]": "something else", }; mutateAsArray("myField", values, (arr) => { swap(arr, 0, 2); }); expect(values).toEqual({ myField: "something", "myField[0]": "bar", "myField[2]": "foo", otherField: "baz", "otherField[0]": "something else", }); }); it("should swap sparse arrays", () => { const values = { myField: "something", "myField[0]": "foo", otherField: "baz", "otherField[0]": "something else", }; mutateAsArray("myField", values, (arr) => { swap(arr, 0, 2); }); expect(values).toEqual({ myField: "something", "myField[2]": "foo", otherField: "baz", "otherField[0]": "something else", }); }); it("should handle arrays with nested values", () => { const values = { myField: "something", "myField[0].title": "foo", "myField[0].note": "bar", "myField[2].title": "other", "myField[2].note": "other", otherField: "baz", "otherField[0]": "something else", }; mutateAsArray("myField", values, (arr) => { swap(arr, 0, 2); }); expect(values).toEqual({ myField: "something", "myField[0].title": "other", "myField[0].note": "other", "myField[2].title": "foo", "myField[2].note": "bar", otherField: "baz", "otherField[0]": "something else", }); }); it("should handle move", () => { const values = { myField: "something", "myField[0]": "foo", "myField[1]": "bar", "myField[2]": "baz", "otherField[0]": "something else", }; mutateAsArray("myField", values, (arr) => { move(arr, 0, 2); }); expect(values).toEqual({ myField: "something", "myField[0]": "bar", "myField[1]": "baz", "myField[2]": "foo", "otherField[0]": "something else", }); }); it("should not create keys for `undefined`", () => { const values = { "myField[0]": "foo", }; mutateAsArray("myField", values, (arr) => { arr.unshift(undefined); }); expect(Object.keys(values)).toHaveLength(1); expect(values).toEqual({ "myField[1]": "foo", }); }); it("should handle remove", () => { const values = { myField: "something", "myField[0]": "foo", "myField[1]": "bar", "myField[2]": "baz", "otherField[0]": "something else", }; mutateAsArray("myField", values, (arr) => { remove(arr, 1); }); expect(values).toEqual({ myField: "something", "myField[0]": "foo", "myField[1]": "baz", "otherField[0]": "something else", }); expect("myField[2]" in values).toBe(false); }); }); describe("getDeepArrayPaths", () => { it("should return all paths recursively", () => { const obj = [ true, true, [true, true], { foo: true, bar: { baz: true, test: [true] } }, ]; expect(getDeepArrayPaths(obj, "myField")).toEqual([ "myField[0]", "myField[1]", "myField[2][0]", "myField[2][1]", "myField[3].foo", "myField[3].bar.baz", "myField[3].bar.test[0]", ]); }); }); }