diff --git a/.changeset/mighty-wasps-speak.md b/.changeset/mighty-wasps-speak.md new file mode 100644 index 00000000..73b05f7d --- /dev/null +++ b/.changeset/mighty-wasps-speak.md @@ -0,0 +1,5 @@ +--- +"react-cool-form": patch +--- + +fix(useFieldArray): nested field array not working diff --git a/README.md b/README.md index 66c2d62d..46dc403d 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,7 @@ const App = () => { ✨ Pretty easy right? React Cool Form is more powerful than you think. Let's [explore it](https://react-cool-form.netlify.app) now! -## Articles / Blog Posts +## Articles / Blog Posts > 💡 If you have written any blog post or article about React Cool Form, please open a PR to add it here. diff --git a/app/src/Playground/index.tsx b/app/src/Playground/index.tsx index ce93eb03..7bc26a20 100644 --- a/app/src/Playground/index.tsx +++ b/app/src/Playground/index.tsx @@ -1,23 +1,92 @@ /* eslint-disable no-console */ -import { useForm } from "react-cool-form"; +import { useForm, useFieldArray } from "react-cool-form"; export default () => { - const { form, runValidation } = useForm({ - // validate: () => ({ foo: "Required" }), - focusOnError: ["foo"], + const { form } = useForm({ + defaultValues: { + foo: [ + { + name: "Iron Man", + arr: [{ name: "iron arr.0" }, { name: "iron arr.1" }], + }, + ], + }, + onSubmit: (values) => console.log("LOG ===> Form data: ", values), }); + const [fields, { push, insert, remove }] = useFieldArray("foo"); return ( - <> -
- - - {/* */} -
- - +
+ + + + + + + + + + {fields.map((key, i) => ( + + + + + + ))} + +
NameArrActions
+ + + + + +
+
+ + +
+ + +
); }; + +function Arr({ field }: any) { + const [fields, { push }] = useFieldArray(`${field}.arr`); + + return ( +
+ {fields.map((key, i) => ( + + ))} + +
+ ); +} diff --git a/src/types/index.ts b/src/types/index.ts index 14390f9f..216dd068 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -92,7 +92,7 @@ export type Fields = Map< export type Parsers = ObjMap>; -export type FieldArray = ObjMap<{ fields: ObjMap; reset: () => void }>; +export type FieldArray = Map void }>; interface EventOptions { removeField: RemoveField; diff --git a/src/useFieldArray.ts b/src/useFieldArray.ts index 6b2e3000..27be03a7 100644 --- a/src/useFieldArray.ts +++ b/src/useFieldArray.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { FieldArrayConfig, @@ -18,6 +18,7 @@ import { compact, get, getIsDirty, + getUid, invariant, isUndefined, set, @@ -48,15 +49,19 @@ export default ( runValidation, removeField, } = methods; + const keysRef = useRef([]); const getFields = useCallback( - (init = false): string[] => { + (init = false) => { let fields = getState(name); if (init && isUndefined(fields)) fields = defaultValue; return Array.isArray(fields) - ? fields.map((_, index) => `${name}[${index}]`) + ? fields.map((_, index) => { + keysRef.current[index] = keysRef.current[index] || getUid(); + return keysRef.current[index]; + }) : []; }, // eslint-disable-next-line react-hooks/exhaustive-deps @@ -65,13 +70,14 @@ export default ( const [fields, setFields] = useState(getFields(true)); - const updateFields = useCallback(() => { - setFields(getFields()); - setNodesOrValues(getState("values"), { - shouldSetValues: false, - fields: Object.keys(fieldArrayRef.current[name].fields), - }); - }, [fieldArrayRef, getFields, getState, name, setNodesOrValues]); + useEffect( + () => + setNodesOrValues(getState("values"), { + shouldSetValues: false, + fields: Object.keys(fieldArrayRef.current.get(name)!.fields), + }), + [fieldArrayRef, fields, getState, name, setNodesOrValues] + ); useEffect(() => { if ( @@ -79,20 +85,20 @@ export default ( !isUndefined(defaultValue) ) { setDefaultValue(name, defaultValue, true); - updateFields(); + setFields(getFields()); } return () => { - if (shouldRemoveField(name)) removeField(name); + if (shouldRemoveField(name)) removeField(name, ["defaultValue"]); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - if (!fieldArrayRef.current[name]) - fieldArrayRef.current[name] = { - reset: updateFields, + if (!fieldArrayRef.current.has(name)) + fieldArrayRef.current.set(name, { + update: () => setFields(getFields()), fields: {}, - }; + }); if (validate) fieldValidatorsRef.current[name] = validate; const setState = useCallback( @@ -106,8 +112,8 @@ export default ( let state = getState(); (["values", "touched", "errors", "dirty"] as Keys[]).forEach((key) => { - const value = state[key][name]; - const fieldsLength = state.values[name]?.length; + const value = get(state[key], name); + const fieldsLength = get(state.values, name)?.length; if ( key === "values" || @@ -117,25 +123,22 @@ export default ( ) state = set( state, - key, - { - ...state[key], - [name]: handler( - Array.isArray(value) ? [...value] : [], - key, - fieldsLength ? fieldsLength - 1 : 0 - ), - }, + `${key}.${name}`, + handler( + Array.isArray(value) ? [...value] : [], + key, + fieldsLength ? fieldsLength - 1 : 0 + ), true ); }); setStateRef("", { ...state, shouldDirty: getIsDirty(state.dirty) }); - updateFields(); + setFields(getFields()); if (validateOnChange) runValidation(name); }, - [getState, name, runValidation, setStateRef, updateFields, validateOnChange] + [getFields, getState, name, runValidation, setStateRef, validateOnChange] ); const push = useCallback>( @@ -143,6 +146,7 @@ export default ( const handler: StateHandler = (f, type, lastIndex = 0) => { if (type === "values") { f.push(value); + keysRef.current.push(getUid()); } else if ( (type === "touched" && shouldTouched) || (type === "dirty" && shouldDirty) @@ -163,6 +167,7 @@ export default ( const handler: StateHandler = (f, type) => { if (type === "values") { f.splice(index, 0, value); + keysRef.current.splice(index, 0, getUid()); } else if ( (type === "touched" && shouldTouched) || (type === "dirty" && shouldDirty) @@ -184,6 +189,7 @@ export default ( (index) => { const handler: StateHandler = (f) => { f.splice(index, 1); + keysRef.current.splice(index, 1); return compact(f).length ? f : []; }; const value = (getState(name) || [])[index]; @@ -199,6 +205,10 @@ export default ( (indexA, indexB) => { const handler: StateHandler = (f) => { [f[indexA], f[indexB]] = [f[indexB], f[indexA]]; + [keysRef.current[indexA], keysRef.current[indexB]] = [ + keysRef.current[indexB], + keysRef.current[indexA], + ]; return f; }; @@ -211,6 +221,7 @@ export default ( (from, to) => { const handler: StateHandler = (f) => { f.splice(to, 0, f.splice(from, 1)[0]); + keysRef.current.splice(to, 0, f.splice(from, 1)[0]); return f; }; diff --git a/src/useForm.ts b/src/useForm.ts index b003ee65..77b32847 100644 --- a/src/useForm.ts +++ b/src/useForm.ts @@ -90,7 +90,7 @@ export default ({ const formRef = useRef(); const fieldsRef = useRef(new Map()); const fieldParsersRef = useRef({}); - const fieldArrayRef = useRef({}); + const fieldArrayRef = useRef(new Map()); const controlsRef = useRef({}); const formValidatorRef = useLatest(validate); const fieldValidatorsRef = useRef>>({}); @@ -183,7 +183,7 @@ export default ({ const fieldArrayName = isFieldArray(fieldArrayRef.current, name); if (fieldArrayName) - fieldArrayRef.current[fieldArrayName].fields[name] = true; + fieldArrayRef.current.get(fieldArrayName)!.fields[name] = true; acc.set(name, { ...acc.get(name), @@ -389,7 +389,7 @@ export default ({ }, [builtInValidationMode, runBuiltInValidation]); const runFieldValidation = useCallback( - async (name: string): Promise => { + async (name: string) => { const value = get(stateRef.current.values, name); if (!fieldValidatorsRef.current[name] || isUndefined(value)) @@ -704,7 +704,7 @@ export default ({ setNodeValue(name, value); isFieldArray(fieldArrayRef.current, name, (key) => - fieldArrayRef.current[key].reset() + fieldArrayRef.current.get(key)!.update() ); if (shouldTouched) setTouched(name, true, { shouldValidate: false }); @@ -774,7 +774,7 @@ export default ({ setStateRef("", state); onResetRef.current(state.values, getOptions(), e); - Object.values(fieldArrayRef.current).forEach((field) => field.reset()); + fieldArrayRef.current.forEach((field) => field.update()); }, [getOptions, onResetRef, setNodesOrValues, setStateRef, stateRef] ); @@ -842,8 +842,8 @@ export default ({ ? removeOnUnmounted : [ ...Array.from(fieldsRef.current.keys()), + ...Array.from(fieldArrayRef.current.keys()), ...Object.keys(controlsRef.current), - ...Object.keys(fieldArrayRef.current), ]; names = isFunction(removeOnUnmounted) ? removeOnUnmounted(names) : names; @@ -882,10 +882,9 @@ export default ({ delete fieldParsersRef.current[name]; delete fieldValidatorsRef.current[name]; - delete fieldArrayRef.current[name]; delete controlsRef.current[name]; - - if (fieldsRef.current.has(name)) fieldsRef.current.delete(name); + fieldArrayRef.current.delete(name); + fieldsRef.current.delete(name); }, [handleUnset, stateRef] ); diff --git a/src/utils/getUid.test.ts b/src/utils/getUid.test.ts new file mode 100644 index 00000000..2c77fa77 --- /dev/null +++ b/src/utils/getUid.test.ts @@ -0,0 +1,9 @@ +import getUid from "./getUid"; + +describe("getUid", () => { + it("should work correctly", () => { + // @ts-expect-error + window.crypto = { getRandomValues: () => new Array(16).fill(0) }; + expect(getUid()).toBe("00000000-0000-4000-8000-000000000000"); + }); +}); diff --git a/src/utils/getUid.ts b/src/utils/getUid.ts new file mode 100644 index 00000000..05a19290 --- /dev/null +++ b/src/utils/getUid.ts @@ -0,0 +1,18 @@ +/* eslint-disable no-bitwise */ + +const hex: string[] = []; + +for (let i = 0; i < 256; i += 1) hex[i] = (i < 16 ? "0" : "") + i.toString(16); + +export default (): string => { + const r = crypto.getRandomValues(new Uint8Array(16)); + + r[6] = (r[6] & 0x0f) | 0x40; + r[8] = (r[8] & 0x3f) | 0x80; + + return `${hex[r[0]] + hex[r[1]] + hex[r[2]] + hex[r[3]]}-${hex[r[4]]}${ + hex[r[5]] + }-${hex[r[6]]}${hex[r[7]]}-${hex[r[8]]}${hex[r[9]]}-${hex[r[10]]}${ + hex[r[11]] + }${hex[r[12]]}${hex[r[13]]}${hex[r[14]]}${hex[r[15]]}`; +}; diff --git a/src/utils/index.ts b/src/utils/index.ts index 7be11357..b6185eec 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -6,6 +6,7 @@ export { default as filterErrors } from "./filterErrors"; export { default as get } from "./get"; export { default as getIsDirty } from "./getIsDirty"; export { default as getPath } from "./getPath"; +export { default as getUid } from "./getUid"; export { default as invariant } from "./invariant"; export { default as isAsyncFunction } from "./isAsyncFunction"; export { default as isCheckboxInput } from "./isCheckboxInput"; diff --git a/src/utils/isFieldArray.test.ts b/src/utils/isFieldArray.test.ts index d16a7001..159767a1 100644 --- a/src/utils/isFieldArray.test.ts +++ b/src/utils/isFieldArray.test.ts @@ -2,20 +2,26 @@ import isFieldArray from "./isFieldArray"; describe("isFieldArray", () => { it("should work correctly", () => { - // @ts-expect-error - expect(isFieldArray({ foo: true }, "foo[0].a")).toBe("foo"); + const value = { fields: {}, update: () => null }; + const foo = new Map([ + ["foo", value], + ["foo[0].a[0]", value], + ]); - // @ts-expect-error - expect(isFieldArray({ foo: true }, "bar[0].a")).toBeUndefined(); + expect(isFieldArray(foo, "foo[0].a")).toBe("foo"); + + expect(isFieldArray(foo, "foo[0].a[0].b")).toBe("foo[0].a[0]"); + + expect(isFieldArray(foo, "bar[0].a")).toBeUndefined(); let callback = jest.fn(); - // @ts-expect-error - isFieldArray({ foo: true }, "foo[0].a", callback); + + isFieldArray(foo, "foo[0].a", callback); expect(callback).toHaveBeenCalledWith("foo"); callback = jest.fn(); - // @ts-expect-error - isFieldArray({ foo: true }, "bar[0].a", callback); + + isFieldArray(foo, "bar[0].a", callback); expect(callback).not.toHaveBeenCalled(); }); }); diff --git a/src/utils/isFieldArray.ts b/src/utils/isFieldArray.ts index 324a14c2..23f9d6e2 100644 --- a/src/utils/isFieldArray.ts +++ b/src/utils/isFieldArray.ts @@ -7,14 +7,16 @@ export default ( ): string | void => { let fieldName; - Object.keys(fields).some((key) => { - if (name.startsWith(key)) { - fieldName = key; - if (callback) callback(key); - return true; - } - return false; - }); + Array.from(fields) + .reverse() + .some(([key]) => { + if (name.startsWith(key)) { + fieldName = key; + if (callback) callback(key); + return true; + } + return false; + }); return fieldName; };