react-form-manage 1.0.8-beta.16 → 1.0.8-beta.18

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/CHANGELOG.md CHANGED
@@ -2,20 +2,67 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [1.0.8-beta.17] - 2026-02-01
6
+
7
+ ### Features
8
+
9
+ - Add nested path onChange trigger: `setFieldValue` now triggers onChange for child paths when parent path has no listener
10
+ - Support setting array values in FormList with automatic re-render of list items and value updates
11
+ - Enhanced listener types with `type` field (normal/array) and `onArrayChange` callback support
12
+
13
+ ### Improvements
14
+
15
+ - `useFormListControl` now tracks item values and handles cache properly
16
+ - Improved `useFormItemControl` with cache path validation using `lodash.has`
17
+
18
+ ## [1.0.8-beta.16] - 2026-02-01
19
+
20
+ ### Fixes
21
+
22
+ - Fix: Guard listener registration to prevent pushing incomplete listeners without name/formName
23
+ - Fix: Guard cleanup execution - only cleanup listeners that actually exist in the store
24
+ - Add formItemId to dependency array for proper listener re-registration in strict mode
25
+
26
+ ## [1.0.8-beta.15] - 2026-02-01
27
+
28
+ ### Features
29
+
30
+ - Export `useFormStore` and types (`FormInstance`, `ListenerItem`, `CleanUpItem`) to public API
31
+ - Allow advanced users to access and manipulate form state directly
32
+
33
+ ## [1.0.8-beta.13] - 2026-02-01
34
+
35
+ ### Refactoring
36
+
37
+ - Combine separate Zustand stores (`useFormListeners`, `useFormCleanUp`) into single unified `useFormStore`
38
+ - Implement slice-based store pattern for better code organization and maintainability
39
+ - Update all internal imports to use unified store across hooks and components
40
+ - Maintain backward compatibility with deprecated store aliases
41
+
42
+ ## [1.0.8-beta.12] - 2026-01-27
43
+
44
+ ### Features
45
+
46
+ - Add slice-based store architecture using Zustand for better scalability
47
+ - Refactor form state management with proper separation of concerns
48
+
5
49
  ## [1.0.8-beta.3] - 2026-01-22
6
50
 
7
51
  ## [1.0.8-beta.7] - 2026-01-24
52
+
8
53
  - Add control flag for FormItem to support rendering MUI uncontrolled components when initial value is undefined (control on init).
9
54
  - Fix: `onReset` did not restore listener state to init.
10
55
  - Fix: reset did not return Form submit state to `idle`.
11
56
  - Add `hidden` prop to allow hiding components while still assigning a value.
12
57
 
13
58
  Docs: Update usage notes for `FormItem` control-on-init flag and `hidden` prop.
59
+
14
60
  - Add `UseFormItemStateWatchReturn` type export for `useFormItemStateWatch` hook
15
61
 
16
62
  - Add isTouched field to FormItem for tracking user interaction state
17
63
 
18
64
  ## [1.0.8-beta.10] - 2026-01-27
65
+
19
66
  - `useForm()` giờ có thể trả về form instance từ provider lân cận nếu không truyền `formName` (không còn bắt buộc truyền tên form khi đã wrap trong `Form`).
20
67
  - `FormList.add` khi không truyền `index` sẽ mặc định append vào cuối danh sách.
21
68
  - Docs: cập nhật hướng dẫn cho `useForm` và `FormList.add`.
@@ -23,6 +70,7 @@ Docs: Update usage notes for `FormItem` control-on-init flag and `hidden` prop.
23
70
  ## [1.0.8-beta.1] - 2026-01-22
24
71
 
25
72
  - Fix UseFormItemControlReturn errors type to properly export FormFieldError[]
73
+
26
74
  ## [1.0.7-beta.1] - 2026-01-22
27
75
 
28
76
  - Add `FormFieldError` type export for typed error handling
@@ -103,7 +151,3 @@ Docs: Update usage notes for `FormItem` control-on-init flag and `hidden` prop.
103
151
  ## [1.0.5] - 2026-01-21
104
152
 
105
153
  - Export `Form` as a named export in addition to default to improve import ergonomics for consumers.
106
-
107
-
108
-
109
-
package/README.md CHANGED
@@ -157,6 +157,7 @@ Custom validator (async):
157
157
  `FormList` là render-prop component cung cấp `listFields` và các action `add`, `remove`, `move`.
158
158
 
159
159
  Ghi chú:
160
+
160
161
  - `add()` nếu không truyền `index` sẽ tự động append phần tử mới vào cuối danh sách.
161
162
 
162
163
  Ví dụ:
@@ -8,7 +8,7 @@ export interface FormListProps<T = any> {
8
8
  name: string;
9
9
  key: string;
10
10
  }>, operations: {
11
- add: (index: number) => void;
11
+ add: (index?: number) => void;
12
12
  remove: (opts: {
13
13
  index?: number;
14
14
  key?: string;
@@ -1,4 +1,4 @@
1
- import { get, isNil } from "lodash";
1
+ import { get, has, isNil } from "lodash";
2
2
  import { useTaskEffect } from "minh-custom-hooks-release";
3
3
  import { useEffect, useMemo } from "react";
4
4
  import { useShallow } from "zustand/react/shallow";
@@ -319,10 +319,13 @@ function useFormItemControl({ formName, form, name, initialValue, formItemId, ru
319
319
  const cacheData = getCacheData(formName || (form == null ? void 0 : form.formName) || (contextForm == null ? void 0 : contextForm.formName));
320
320
  if (cacheData) {
321
321
  const getNewDataFromCache = get(cacheData, name);
322
- if (!getNewDataFromCache) {
323
- onChange(initialValue);
322
+ const isIncludeDirectoryInCache = has(cacheData, name);
323
+ if (!isIncludeDirectoryInCache && isNil(getNewDataFromCache)) {
324
+ onChange(initialValue, {
325
+ notTriggerDirty: true
326
+ });
324
327
  } else
325
- onChange(getNewDataFromCache);
328
+ onChange(getNewDataFromCache, { notTriggerDirty: true });
326
329
  }
327
330
  }, [name, formName || (form == null ? void 0 : form.formName) || (contextForm == null ? void 0 : contextForm.formName)]);
328
331
  useEffect(() => {
@@ -2,6 +2,7 @@ import type { FormInstance } from "../stores/formStore";
2
2
  type ListField = {
3
3
  name: string;
4
4
  key: string;
5
+ value?: any;
5
6
  };
6
7
  interface UseFormListControlProps {
7
8
  name?: string;
@@ -5,13 +5,18 @@ import { useShallow } from "zustand/react/shallow";
5
5
  import { useFormContext } from "../providers/Form";
6
6
  import { useFormStore } from "../stores/formStore";
7
7
  function useFormListControl({ name, form, initialValues, formName }) {
8
+ const [formItemId] = useState(v4());
8
9
  const contextForm = useFormContext();
9
10
  const getFormValues = useFormStore((state) => state.getFormValues);
10
11
  const [listFormInitValues, setListFormInitValues] = useState(void 0);
11
- const { clearCacheData, setCacheData } = useFormStore(useShallow((state) => ({
12
+ const { clearCacheData, setCacheData, setListener, getListener } = useFormStore(useShallow((state) => ({
13
+ // Cache
12
14
  cacheData: state.cacheData,
13
15
  clearCacheData: state.clearCacheData,
14
- setCacheData: state.setCacheData
16
+ setCacheData: state.setCacheData,
17
+ // Listener
18
+ setListener: state.setListener,
19
+ getListener: state.getListener
15
20
  })));
16
21
  const { setCleanUpStack } = useFormStore(useShallow((state) => ({
17
22
  setCleanUpStack: state.setCleanUpStack
@@ -37,7 +42,16 @@ function useFormListControl({ name, form, initialValues, formName }) {
37
42
  value: d
38
43
  };
39
44
  }).filter(Boolean);
40
- const mapCurWithKey = cur.map((c) => mapPrevWithKey.find((m) => m.key === c.key) || c);
45
+ const mapCurWithKey = cur.map((c) => {
46
+ const find = mapPrevWithKey.find((m) => m.key === c.key);
47
+ if (find) {
48
+ return {
49
+ key: find.key,
50
+ value: isNil(c.value) ? find.value : c.value
51
+ };
52
+ }
53
+ return c;
54
+ });
41
55
  const getNewValueCache = mapCurWithKey.filter(Boolean).map((c) => c.value);
42
56
  const startRemoveIndex = formDataBeforeChange.length - getNewValueCache.length;
43
57
  if (startRemoveIndex > 0) {
@@ -218,6 +232,42 @@ function useFormListControl({ name, form, initialValues, formName }) {
218
232
  clearCacheData();
219
233
  };
220
234
  }, [listFields]);
235
+ useEffect(() => {
236
+ if (!getListener(formItemId)) {
237
+ setListener({
238
+ formName: formName || (form == null ? void 0 : form.formName) || (contextForm == null ? void 0 : contextForm.formName),
239
+ name: name || "",
240
+ formItemId,
241
+ type: "array",
242
+ onArrayChange: (newArr) => {
243
+ setListFields((prev) => {
244
+ const result = newArr.map((_, i) => {
245
+ const itemName = `${name}.${i}`;
246
+ const existingItem = prev[i];
247
+ return {
248
+ key: existingItem ? existingItem.key : v4(),
249
+ name: itemName
250
+ };
251
+ });
252
+ handleCacheListField(prev, result.map((r, i) => {
253
+ return { ...r, value: newArr[i] };
254
+ }));
255
+ return result;
256
+ });
257
+ }
258
+ });
259
+ }
260
+ return () => {
261
+ if (getListener(formItemId)) {
262
+ setListener({
263
+ formName: formName || (form == null ? void 0 : form.formName) || (contextForm == null ? void 0 : contextForm.formName),
264
+ name: name || "",
265
+ formItemId,
266
+ onArrayChange: void 0
267
+ });
268
+ }
269
+ };
270
+ }, []);
221
271
  return { listFields, move, add, remove };
222
272
  }
223
273
  export {
@@ -0,0 +1 @@
1
+ export * from "./index";
package/dist/index.d.ts CHANGED
@@ -1,11 +1,11 @@
1
- import Form, { useForm, useWatch, useSubmitDataWatch, useFormStateWatch, type FormProps, type ValidationRule, type FormFieldError, type SubmitState, type UseFormItemStateWatchReturn } from "./providers/Form";
2
1
  import { SUBMIT_STATE } from "./constants/form";
2
+ import Form, { useForm, useFormStateWatch, useSubmitDataWatch, useWatch, type FormFieldError, type FormProps, type SubmitState, type UseFormItemStateWatchReturn, type ValidationRule } from "./providers/Form";
3
3
  import FormItem, { type FormItemProps } from "./components/Form/FormItem";
4
4
  import FormList, { type FormListProps } from "./components/Form/FormList";
5
- import Input from "./components/Input";
6
5
  import InputWrapper, { type InputWrapperProps } from "./components/Form/InputWrapper";
6
+ import Input from "./components/Input";
7
7
  import useFormItemControl from "./hooks/useFormItemControl";
8
8
  import useFormListControl from "./hooks/useFormListControl";
9
- import { useFormStore, type FormInstance, type ListenerItem, type CleanUpItem } from "./stores/formStore";
10
- export { Form, FormItem, FormList, Input, InputWrapper, useFormItemControl, useFormListControl, useForm, useWatch, useSubmitDataWatch, useFormStateWatch, useFormStore, type FormProps, type FormItemProps, type FormListProps, type InputWrapperProps, type ValidationRule, type FormFieldError, type SubmitState, type UseFormItemStateWatchReturn, type FormInstance, type ListenerItem, type CleanUpItem, SUBMIT_STATE, };
9
+ import { useFormStore, type CleanUpItem, type FormInstance, type ListenerItem } from "./stores/formStore";
10
+ export { Form, FormItem, FormList, Input, InputWrapper, SUBMIT_STATE, useForm, useFormItemControl, useFormListControl, useFormStateWatch, useFormStore, useSubmitDataWatch, useWatch, type CleanUpItem, type FormFieldError, type FormInstance, type FormItemProps, type FormListProps, type FormProps, type InputWrapperProps, type ListenerItem, type SubmitState, type UseFormItemStateWatchReturn, type ValidationRule, };
11
11
  export default Form;
@@ -0,0 +1 @@
1
+ export * from "./index";
package/dist/index.js CHANGED
@@ -1,9 +1,9 @@
1
- import Form, { useForm, useWatch, useSubmitDataWatch, useFormStateWatch } from "./providers/Form";
2
1
  import { SUBMIT_STATE } from "./constants/form";
2
+ import Form, { useForm, useFormStateWatch, useSubmitDataWatch, useWatch } from "./providers/Form";
3
3
  import FormItem from "./components/Form/FormItem";
4
4
  import FormList from "./components/Form/FormList";
5
- import Input from "./components/Input";
6
5
  import InputWrapper from "./components/Form/InputWrapper";
6
+ import Input from "./components/Input";
7
7
  import useFormItemControl from "./hooks/useFormItemControl";
8
8
  import useFormListControl from "./hooks/useFormListControl";
9
9
  import { useFormStore } from "./stores/formStore";
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { cloneDeep, get, isEqual, isNil, last, set, uniqBy } from "lodash";
2
+ import { cloneDeep, get, isEqual, isNil, isPlainObject, last, set, uniqBy } from "lodash";
3
3
  import { useTask } from "minh-custom-hooks-release";
4
4
  import { createContext, useContext, useEffect, useState } from "react";
5
5
  import { flushSync } from "react-dom";
@@ -47,9 +47,36 @@ function Form({ children, formName, initialValues, onFinish, onReject, onFinally
47
47
  const setFieldValue = (name, value, options) => {
48
48
  const listener = getListeners().find((l) => l.name === name && l.formName === formName);
49
49
  if (listener) {
50
- listener.onChange(value, options);
50
+ if (listener.type === "array") {
51
+ if (!isEqual(getFormItemValue(formName, name), value)) {
52
+ listener.onArrayChange(value, options);
53
+ const allStringPath = getAllNoneObjStringPath(value);
54
+ allStringPath.forEach((p) => {
55
+ const findListener = getListeners().find((l) => l.name === `${name}.${p}` && l.formName === formName);
56
+ if (findListener) {
57
+ findListener.onChange(get(value, p), options);
58
+ } else {
59
+ setData(formName, `${name}.${p}`, get(value, p));
60
+ }
61
+ });
62
+ }
63
+ } else {
64
+ listener.onChange(value, options);
65
+ }
51
66
  } else {
52
- setData(formName, name, value);
67
+ if (isPlainObject(value) || Array.isArray(value)) {
68
+ const allStringPath = getAllNoneObjStringPath(value);
69
+ allStringPath.forEach((p) => {
70
+ const findListener = getListeners().find((l) => l.name === `${name}.${p}` && l.formName === formName);
71
+ if (findListener) {
72
+ findListener.onChange(get(value, p), options);
73
+ } else {
74
+ setData(formName, `${name}.${p}`, get(value, p));
75
+ }
76
+ });
77
+ } else {
78
+ setData(formName, name, value);
79
+ }
53
80
  }
54
81
  };
55
82
  const setFieldValues = (values, options = { notTriggerDirty: false }) => {
@@ -57,7 +84,24 @@ function Form({ children, formName, initialValues, onFinish, onReject, onFinally
57
84
  allStringPath.forEach((p) => {
58
85
  const listener = getListeners().find((l) => l.name === p && l.formName === formName);
59
86
  if (listener) {
60
- listener.onChange(get(values, listener.name), options);
87
+ if (listener.type === "array") {
88
+ if (!isEqual(getFormItemValue(formName, p), get(values, p))) {
89
+ listener.onArrayChange(get(values, listener.name), options);
90
+ const nestedAllStringPath = getAllNoneObjStringPath(get(values, p));
91
+ nestedAllStringPath.forEach((np) => {
92
+ {
93
+ const findListener = getListeners().find((l) => l.name === `${p}.${np}` && l.formName === formName);
94
+ if (findListener) {
95
+ findListener.onChange(get(values, `${p}.${np}`), options);
96
+ } else {
97
+ setData(formName, `${p}.${np}`, get(values, `${p}.${np}`));
98
+ }
99
+ }
100
+ });
101
+ }
102
+ } else {
103
+ listener.onChange(get(values, listener.name), options);
104
+ }
61
105
  } else {
62
106
  setData(formName, p, get(values, p));
63
107
  }
@@ -1,3 +1,4 @@
1
+ type ListenerFormItemType = "normal" | "array";
1
2
  export interface FormInstance {
2
3
  formName: string;
3
4
  resetFields: (values?: any) => void;
@@ -20,11 +21,13 @@ export interface ListenerItem {
20
21
  isDirty?: boolean;
21
22
  formItemId?: string;
22
23
  internalErrors?: any;
24
+ onArrayChange?: any;
23
25
  onChange?: any;
24
26
  onReset?: any;
25
27
  onFocus?: any;
26
28
  emitFocus?: any;
27
29
  isInitied?: boolean;
30
+ type?: ListenerFormItemType;
28
31
  }
29
32
  export interface CleanUpItem {
30
33
  name?: string;
@@ -200,7 +200,7 @@ const createListenersSlice = (storeSet, storeGet, api) => ({
200
200
  getListeners() {
201
201
  return storeGet().listeners;
202
202
  },
203
- setListener({ formName, name, onChange, onReset, isTouched, isDirty, formItemId, internalErrors, onFocus, emitFocus, isInitied }) {
203
+ setListener({ formName, name, onChange, onReset, isTouched, isDirty, formItemId, internalErrors, onFocus, emitFocus, isInitied, type, onArrayChange }) {
204
204
  return storeSet(produce((state) => {
205
205
  const storeListeners = state.listeners;
206
206
  const findListenerIndex = state.listeners.findIndex((l) => l.formItemId === formItemId);
@@ -235,6 +235,9 @@ const createListenersSlice = (storeSet, storeGet, api) => ({
235
235
  if (!isNil(isInitied)) {
236
236
  storeListeners[findListenerIndex].isInitied = isInitied;
237
237
  }
238
+ if (!isNil(onArrayChange)) {
239
+ storeListeners[findListenerIndex].onArrayChange = onArrayChange;
240
+ }
238
241
  return;
239
242
  }
240
243
  if (name && formName) {
@@ -247,7 +250,9 @@ const createListenersSlice = (storeSet, storeGet, api) => ({
247
250
  internalErrors,
248
251
  onChange,
249
252
  onReset,
250
- isInitied: Boolean(isInitied)
253
+ isInitied: Boolean(isInitied),
254
+ type: type || "normal",
255
+ onArrayChange
251
256
  });
252
257
  }
253
258
  }));
@@ -1 +1 @@
1
- export * from './public';
1
+ export * from "./public";
@@ -1,5 +1,5 @@
1
- import type { GetConstantType } from "./util";
2
1
  import type { SUBMIT_STATE } from "../constants/form";
2
+ import type { GetConstantType } from "./util";
3
3
  export type FormValues<T = any> = T;
4
4
  export interface FormFieldError {
5
5
  ruleName: string | number;
@@ -1,2 +1,2 @@
1
- export declare function getAllNoneObjStringPath(value: any, prevPath?: string): any;
1
+ export declare function getAllNoneObjStringPath(value: any, prevPath?: string): string[];
2
2
  export declare function getAllStringPath(value: any, prevPath?: string): any;
@@ -1,14 +1,25 @@
1
- import { filter, isNil, join } from "lodash";
1
+ import { filter, isNil, isPlainObject, join } from "lodash";
2
2
  function getAllNoneObjStringPath(value, prevPath = "") {
3
- if (typeof value === "object") {
4
- return Object.keys(value).reduce((prev, cur) => {
3
+ if (value === null || value === void 0 || typeof value !== "object" || typeof value === "function") {
4
+ return [prevPath];
5
+ }
6
+ if (Array.isArray(value)) {
7
+ return value.reduce((prev, item, index) => {
5
8
  return [
6
9
  ...prev,
7
- ...getAllNoneObjStringPath(value[cur], join(filter([prevPath, cur], (v) => !isNil(v) && v !== ""), "."))
10
+ ...getAllNoneObjStringPath(item, join(filter([prevPath, String(index)], (v) => !isNil(v) && v !== ""), "."))
8
11
  ];
9
12
  }, []);
10
13
  }
11
- return [prevPath];
14
+ if (!isPlainObject(value)) {
15
+ return [prevPath];
16
+ }
17
+ return Object.keys(value).reduce((prev, cur) => {
18
+ return [
19
+ ...prev,
20
+ ...getAllNoneObjStringPath(value[cur], join(filter([prevPath, cur], (v) => !isNil(v) && v !== ""), "."))
21
+ ];
22
+ }, []);
12
23
  }
13
24
  function getAllStringPath(value, prevPath = "") {
14
25
  if (typeof value === "object") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-form-manage",
3
- "version": "1.0.8-beta.16",
3
+ "version": "1.0.8-beta.18",
4
4
  "description": "Lightweight React form management with list and listener support.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/App.tsx CHANGED
@@ -47,7 +47,7 @@ const App = () => {
47
47
  initialValues={{
48
48
  TestData: "",
49
49
  numericCode: "",
50
- arr: [{ el: "Item 1" }, { el: "Item 2" }],
50
+ // arr: [{ el: "Item 1" }, { el: "Item 2" }],
51
51
  }}
52
52
  onFinish={(values) => {
53
53
  console.log(values);
@@ -103,7 +103,8 @@ const App = () => {
103
103
  <FormList
104
104
  initialValues={[
105
105
  {
106
- el: "",
106
+ el: "sdfsdf",
107
+ d: { child: "Test Child" },
107
108
  },
108
109
  ]}
109
110
  name="arr"
@@ -112,7 +113,15 @@ const App = () => {
112
113
  <div>
113
114
  {fields.map((field, index) => (
114
115
  <div key={field.key} style={{ marginBottom: 8 }}>
115
- <FormItem name={`${field.name}.el`}>
116
+ <FormItem name={`${field.name}.el`} initialValue={"Chém gió"}>
117
+ <InputWrapper>
118
+ <Input placeholder="Item value" style={{ width: 200 }} />
119
+ </InputWrapper>
120
+ </FormItem>
121
+ <FormItem
122
+ name={`${field.name}.d.child`}
123
+ initialValue={"Con của item"}
124
+ >
116
125
  <InputWrapper>
117
126
  <Input placeholder="Item value" style={{ width: 200 }} />
118
127
  </InputWrapper>
@@ -145,6 +154,17 @@ const App = () => {
145
154
  </div>
146
155
  )}
147
156
  </FormList>
157
+ <Button
158
+ onClick={() => {
159
+ form?.setFieldValue("arr", [
160
+ { el: "Set Item 1" },
161
+ { el: "Set Item 2" },
162
+ { el: "Set Item 3" },
163
+ ]);
164
+ }}
165
+ >
166
+ Test set array list value
167
+ </Button>
148
168
  <motion.div
149
169
  initial={{ opacity: 0 }}
150
170
  animate={{ opacity: 1 }}
@@ -9,7 +9,7 @@ export interface FormListProps<T = any> {
9
9
  children: (
10
10
  fields: Array<{ name: string; key: string }>,
11
11
  operations: {
12
- add: (index: number) => void;
12
+ add: (index?: number) => void;
13
13
  remove: (opts: { index?: number; key?: string }) => void;
14
14
  move: (opts: { from?: number; fromKey?: string; to: number }) => void;
15
15
  },
@@ -1,4 +1,4 @@
1
- import { get, isNil } from "lodash";
1
+ import { get, has, isNil } from "lodash";
2
2
  import { useTaskEffect } from "minh-custom-hooks-release";
3
3
  import { useEffect, useMemo, type RefObject } from "react";
4
4
  import { useShallow } from "zustand/react/shallow"; // Import useShallow
@@ -595,13 +595,17 @@ export default function useFormItemControl<T = any>({
595
595
  // console.log("Get cache Data after list change: ", cacheData);
596
596
 
597
597
  if (cacheData) {
598
+ console.log("Cache data found when form item change: ", name, cacheData);
598
599
  const getNewDataFromCache = get(cacheData, name);
600
+ const isIncludeDirectoryInCache = has(cacheData, name);
599
601
 
600
602
  // console.log("Init data when change form ite: ", name, cacheData);
601
603
 
602
- if (!getNewDataFromCache) {
603
- onChange(initialValue);
604
- } else onChange(getNewDataFromCache);
604
+ if (!isIncludeDirectoryInCache && isNil(getNewDataFromCache)) {
605
+ onChange(initialValue, {
606
+ notTriggerDirty: true,
607
+ });
608
+ } else onChange(getNewDataFromCache, { notTriggerDirty: true });
605
609
  }
606
610
  }, [name, formName || form?.formName || contextForm?.formName]);
607
611
 
@@ -7,7 +7,7 @@ import { useFormStore } from "../stores/formStore";
7
7
 
8
8
  import type { FormInstance } from "../stores/formStore";
9
9
 
10
- type ListField = { name: string; key: string };
10
+ type ListField = { name: string; key: string; value?: any };
11
11
 
12
12
  interface UseFormListControlProps {
13
13
  name?: string;
@@ -29,18 +29,25 @@ export default function useFormListControl<T = any>({
29
29
  initialValues,
30
30
  formName,
31
31
  }: UseFormListControlProps): UseFormListControlReturn {
32
+ const [formItemId] = useState<string>(v4());
32
33
  const contextForm = useFormContext();
33
34
  const getFormValues = useFormStore((state) => state.getFormValues);
34
35
  const [listFormInitValues, setListFormInitValues] = useState<
35
36
  any[] | undefined
36
37
  >(undefined);
37
- const { clearCacheData, setCacheData } = useFormStore(
38
- useShallow((state) => ({
39
- cacheData: state.cacheData,
40
- clearCacheData: state.clearCacheData,
41
- setCacheData: state.setCacheData,
42
- })),
43
- );
38
+ const { clearCacheData, setCacheData, setListener, getListener } =
39
+ useFormStore(
40
+ useShallow((state) => ({
41
+ // Cache
42
+ cacheData: state.cacheData,
43
+ clearCacheData: state.clearCacheData,
44
+ setCacheData: state.setCacheData,
45
+
46
+ // Listener
47
+ setListener: state.setListener,
48
+ getListener: state.getListener,
49
+ })),
50
+ );
44
51
  const { setCleanUpStack } = useFormStore(
45
52
  useShallow((state) => ({
46
53
  setCleanUpStack: state.setCleanUpStack,
@@ -94,9 +101,20 @@ export default function useFormListControl<T = any>({
94
101
  })
95
102
  .filter(Boolean);
96
103
 
97
- const mapCurWithKey = cur.map(
98
- (c) => mapPrevWithKey.find((m) => m.key === c.key) || c,
99
- );
104
+ const mapCurWithKey = cur.map((c) => {
105
+ const find = mapPrevWithKey.find((m) => m.key === c.key);
106
+
107
+ if (find) {
108
+ return {
109
+ key: find.key,
110
+ value: isNil(c.value) ? find.value : c.value,
111
+ };
112
+ }
113
+
114
+ return c;
115
+ });
116
+
117
+ console.log("compare prev cur", { prev, cur });
100
118
 
101
119
  const getNewValueCache = mapCurWithKey.filter(Boolean).map((c) => c.value);
102
120
 
@@ -109,7 +127,7 @@ export default function useFormListControl<T = any>({
109
127
  // console.log("Mapping Cur value with prev fields: ", mapCurWithKey);
110
128
  // console.log("After change arr value: ", getNewValueCache);
111
129
 
112
- // Nếu số phần tử trước khi thay đổi mảng lớn hơn thì đẩy 2 phần tử còn lại vào clean up stack để clear
130
+ // Nếu số phần tử trước khi thay đổi mảng lớn hơn thì đẩy các phần tử còn lại vào clean up stack để clear
113
131
  if (startRemoveIndex > 0) {
114
132
  Array.from(Array(startRemoveIndex))
115
133
  .map((_, index) => {
@@ -129,6 +147,12 @@ export default function useFormListControl<T = any>({
129
147
  });
130
148
  }
131
149
 
150
+ console.log("Set cache data for form list: ", {
151
+ formName: formName || form?.formName || contextForm?.formName,
152
+ name,
153
+ getNewValueCache,
154
+ });
155
+
132
156
  // console.log({ getNewValueCache });
133
157
  setCacheData(
134
158
  formName || form?.formName || contextForm?.formName,
@@ -228,7 +252,7 @@ export default function useFormListControl<T = any>({
228
252
  to: number;
229
253
  }) => {
230
254
  setListFields((prev) => {
231
- console.log("move list item: ", { from, to });
255
+ // console.log("move list item: ", { from, to });
232
256
  if (
233
257
  from >= listFields.length ||
234
258
  from < 0 ||
@@ -237,7 +261,7 @@ export default function useFormListControl<T = any>({
237
261
  from === to
238
262
  )
239
263
  return prev;
240
- console.log("Trigger move item: ");
264
+ // console.log("Trigger move item: ");
241
265
 
242
266
  if (!isNil(fromKey)) {
243
267
  const findItemIndex = prev.findIndex((p) => p.key === fromKey);
@@ -383,5 +407,50 @@ export default function useFormListControl<T = any>({
383
407
  };
384
408
  }, [listFields]);
385
409
 
410
+ useEffect(() => {
411
+ if (!getListener(formItemId)) {
412
+ setListener({
413
+ formName: formName || form?.formName || contextForm?.formName,
414
+ name: name || "",
415
+ formItemId: formItemId,
416
+ type: "array",
417
+ onArrayChange: (newArr) => {
418
+ setListFields((prev) => {
419
+ const result = newArr.map((_, i) => {
420
+ const itemName = `${name}.${i}`;
421
+ const existingItem = prev[i];
422
+ return {
423
+ key: existingItem ? existingItem.key : v4(),
424
+ name: itemName,
425
+ };
426
+ });
427
+
428
+ handleCacheListField(
429
+ prev,
430
+ result.map((r, i) => {
431
+ return { ...r, value: newArr[i] };
432
+ }),
433
+ );
434
+ return result;
435
+ });
436
+ },
437
+ });
438
+ }
439
+ return () => {
440
+ // Remove listener on unmount
441
+ if (getListener(formItemId)) {
442
+ console.log("Remove listener for form list: ", {
443
+ formItemId,
444
+ });
445
+ setListener({
446
+ formName: formName || form?.formName || contextForm?.formName,
447
+ name: name || "",
448
+ formItemId: formItemId,
449
+ onArrayChange: undefined,
450
+ });
451
+ }
452
+ };
453
+ }, []);
454
+
386
455
  return { listFields, move, add, remove };
387
456
  }
package/src/index.ts CHANGED
@@ -1,28 +1,30 @@
1
+ import { SUBMIT_STATE } from "./constants/form";
1
2
  import Form, {
2
3
  useForm,
3
- useWatch,
4
- useSubmitDataWatch,
5
4
  useFormStateWatch,
6
- type FormProps,
7
- type ValidationRule,
5
+ useSubmitDataWatch,
6
+ useWatch,
8
7
  type FormFieldError,
8
+ type FormProps,
9
9
  type SubmitState,
10
10
  type UseFormItemStateWatchReturn,
11
+ type ValidationRule,
11
12
  } from "./providers/Form";
12
- import { SUBMIT_STATE } from "./constants/form";
13
13
 
14
14
  import FormItem, { type FormItemProps } from "./components/Form/FormItem";
15
15
  import FormList, { type FormListProps } from "./components/Form/FormList";
16
+ import InputWrapper, {
17
+ type InputWrapperProps,
18
+ } from "./components/Form/InputWrapper";
16
19
  import Input from "./components/Input";
17
- import InputWrapper, { type InputWrapperProps } from "./components/Form/InputWrapper";
18
20
 
19
21
  import useFormItemControl from "./hooks/useFormItemControl";
20
22
  import useFormListControl from "./hooks/useFormListControl";
21
23
  import {
22
24
  useFormStore,
25
+ type CleanUpItem,
23
26
  type FormInstance,
24
27
  type ListenerItem,
25
- type CleanUpItem,
26
28
  } from "./stores/formStore";
27
29
 
28
30
  export {
@@ -31,25 +33,25 @@ export {
31
33
  FormList,
32
34
  Input,
33
35
  InputWrapper,
36
+ SUBMIT_STATE,
37
+ useForm,
34
38
  useFormItemControl,
35
39
  useFormListControl,
36
- useForm,
37
- useWatch,
38
- useSubmitDataWatch,
39
40
  useFormStateWatch,
40
41
  useFormStore,
41
- type FormProps,
42
+ useSubmitDataWatch,
43
+ useWatch,
44
+ type CleanUpItem,
45
+ type FormFieldError,
46
+ type FormInstance,
42
47
  type FormItemProps,
43
48
  type FormListProps,
49
+ type FormProps,
44
50
  type InputWrapperProps,
45
- type ValidationRule,
46
- type FormFieldError,
51
+ type ListenerItem,
47
52
  type SubmitState,
48
53
  type UseFormItemStateWatchReturn,
49
- type FormInstance,
50
- type ListenerItem,
51
- type CleanUpItem,
52
- SUBMIT_STATE,
54
+ type ValidationRule,
53
55
  };
54
56
 
55
57
  export default Form;
@@ -1,4 +1,13 @@
1
- import { cloneDeep, get, isEqual, isNil, last, set, uniqBy } from "lodash";
1
+ import {
2
+ cloneDeep,
3
+ get,
4
+ isEqual,
5
+ isNil,
6
+ isPlainObject,
7
+ last,
8
+ set,
9
+ uniqBy,
10
+ } from "lodash";
2
11
  import { useTask } from "minh-custom-hooks-release";
3
12
  import type { ComponentType, FormHTMLAttributes, ReactNode } from "react";
4
13
  import { createContext, useContext, useEffect, useState } from "react";
@@ -6,7 +15,7 @@ import { flushSync } from "react-dom";
6
15
  import { useShallow } from "zustand/react/shallow"; // Import useShallow
7
16
  import FormCleanUp from "../components/Form/FormCleanUp";
8
17
  import { SUBMIT_STATE } from "../constants/form";
9
- import { useFormStore } from "../stores/formStore";
18
+ import { ListenerItem, useFormStore } from "../stores/formStore";
10
19
  import type {
11
20
  PublicFormInstance,
12
21
  UseFormItemStateWatchReturn,
@@ -90,13 +99,51 @@ export default function Form<T = any>({
90
99
  );
91
100
 
92
101
  const setFieldValue = (name, value, options) => {
93
- const listener = getListeners().find(
102
+ const listener: ListenerItem | null = getListeners().find(
94
103
  (l) => l.name === name && l.formName === formName,
95
104
  );
96
105
  if (listener) {
97
- listener.onChange(value, options);
106
+ // Nếu loại listener là array thì gọi onArrayChange
107
+ if (listener.type === "array") {
108
+ // Do nothing if the value is the same for array item to prevent unnecessary re-renders
109
+ if (!isEqual(getFormItemValue(formName, name), value)) {
110
+ listener.onArrayChange(value, options);
111
+
112
+ // Kiểm tra từng path con của array item xem có listener nào không, nếu có thì gọi onChange của listener đó
113
+ const allStringPath = getAllNoneObjStringPath(value);
114
+ allStringPath.forEach((p) => {
115
+ const findListener = getListeners().find(
116
+ (l) => l.name === `${name}.${p}` && l.formName === formName,
117
+ );
118
+ if (findListener) {
119
+ findListener.onChange(get(value, p), options);
120
+ } else {
121
+ setData(formName, `${name}.${p}`, get(value, p));
122
+ }
123
+ });
124
+ }
125
+ } else {
126
+ listener.onChange(value, options);
127
+ }
98
128
  } else {
99
- setData(formName, name, value);
129
+ // set data for non-listener field
130
+ if (isPlainObject(value) || Array.isArray(value)) {
131
+ // Nếu là object hoặc array thì set từng path con
132
+ const allStringPath = getAllNoneObjStringPath(value);
133
+
134
+ allStringPath.forEach((p) => {
135
+ const findListener = getListeners().find(
136
+ (l) => l.name === `${name}.${p}` && l.formName === formName,
137
+ );
138
+ if (findListener) {
139
+ findListener.onChange(get(value, p), options);
140
+ } else {
141
+ setData(formName, `${name}.${p}`, get(value, p));
142
+ }
143
+ });
144
+ } else {
145
+ setData(formName, name, value);
146
+ }
100
147
  }
101
148
  };
102
149
 
@@ -108,7 +155,31 @@ export default function Form<T = any>({
108
155
  (l) => l.name === p && l.formName === formName,
109
156
  );
110
157
  if (listener) {
111
- listener.onChange(get(values, listener.name), options);
158
+ if (listener.type === "array") {
159
+ // Do nothing if the value is the same for array item to prevent unnecessary re-renders
160
+ if (!isEqual(getFormItemValue(formName, p), get(values, p))) {
161
+ listener.onArrayChange(get(values, listener.name), options);
162
+
163
+ // Kiểm tra từng path con của array item xem có listener nào không, nếu có thì gọi onChange của listener đó
164
+ const nestedAllStringPath: string[] = getAllNoneObjStringPath(
165
+ get(values, p),
166
+ );
167
+ nestedAllStringPath.forEach((np) => {
168
+ {
169
+ const findListener = getListeners().find(
170
+ (l) => l.name === `${p}.${np}` && l.formName === formName,
171
+ );
172
+ if (findListener) {
173
+ findListener.onChange(get(values, `${p}.${np}`), options);
174
+ } else {
175
+ setData(formName, `${p}.${np}`, get(values, `${p}.${np}`));
176
+ }
177
+ }
178
+ });
179
+ }
180
+ } else {
181
+ listener.onChange(get(values, listener.name), options);
182
+ }
112
183
  } else {
113
184
  setData(formName, p, get(values, p));
114
185
  }
@@ -3,7 +3,7 @@ import { cloneDeep, get, isNil, isNumber, last, set, unset } from "lodash";
3
3
  import { v4 } from "uuid";
4
4
  import { create } from "zustand";
5
5
  import { getAllNoneObjStringPath } from "../utils/obj.util";
6
-
6
+ type ListenerFormItemType = "normal" | "array";
7
7
  export interface FormInstance {
8
8
  formName: string;
9
9
  resetFields: (values?: any) => void;
@@ -24,11 +24,13 @@ export interface ListenerItem {
24
24
  isDirty?: boolean;
25
25
  formItemId?: string;
26
26
  internalErrors?: any;
27
+ onArrayChange?: any;
27
28
  onChange?: any;
28
29
  onReset?: any;
29
30
  onFocus?: any;
30
31
  emitFocus?: any;
31
32
  isInitied?: boolean;
33
+ type?: ListenerFormItemType;
32
34
  }
33
35
 
34
36
  export interface CleanUpItem {
@@ -379,7 +381,9 @@ const createListenersSlice = (storeSet: any, storeGet: any, api: any) => ({
379
381
  onFocus,
380
382
  emitFocus,
381
383
  isInitied,
382
- }) {
384
+ type,
385
+ onArrayChange,
386
+ }: Partial<ListenerItem> & { formItemId: string }) {
383
387
  return storeSet(
384
388
  produce<any>((state: any) => {
385
389
  const storeListeners = state.listeners;
@@ -420,6 +424,10 @@ const createListenersSlice = (storeSet: any, storeGet: any, api: any) => ({
420
424
  storeListeners[findListenerIndex].isInitied = isInitied;
421
425
  }
422
426
 
427
+ if (!isNil(onArrayChange)) {
428
+ storeListeners[findListenerIndex].onArrayChange = onArrayChange;
429
+ }
430
+
423
431
  return;
424
432
  }
425
433
  if (name && formName) {
@@ -433,6 +441,8 @@ const createListenersSlice = (storeSet: any, storeGet: any, api: any) => ({
433
441
  onChange,
434
442
  onReset,
435
443
  isInitied: Boolean(isInitied),
444
+ type: type || "normal",
445
+ onArrayChange,
436
446
  });
437
447
  }
438
448
  }),
@@ -1 +1 @@
1
- export * from './public';
1
+ export * from "./public";
@@ -1,5 +1,5 @@
1
- import type { GetConstantType } from "./util";
2
1
  import type { SUBMIT_STATE } from "../constants/form";
2
+ import type { GetConstantType } from "./util";
3
3
 
4
4
  export type FormValues<T = any> = T;
5
5
 
@@ -1,22 +1,53 @@
1
- import { filter, isNil, join } from "lodash";
1
+ import { filter, isNil, isPlainObject, join } from "lodash";
2
2
 
3
- export function getAllNoneObjStringPath(value: any, prevPath: string = "") {
4
- if (typeof value === "object") {
5
- return Object.keys(value).reduce((prev, cur) => {
3
+ export function getAllNoneObjStringPath(
4
+ value: any,
5
+ prevPath: string = "",
6
+ ): string[] {
7
+ // primitive / function / null / undefined => dừng
8
+ if (
9
+ value === null ||
10
+ value === undefined ||
11
+ typeof value !== "object" ||
12
+ typeof value === "function"
13
+ ) {
14
+ return [prevPath];
15
+ }
16
+
17
+ // array thì đi sâu
18
+ if (Array.isArray(value)) {
19
+ return value.reduce((prev: string[], item, index) => {
6
20
  return [
7
21
  ...prev,
8
-
9
22
  ...getAllNoneObjStringPath(
10
- value[cur],
23
+ item,
11
24
  join(
12
- filter([prevPath, cur], (v) => !isNil(v) && v !== ""),
13
- "."
14
- )
25
+ filter([prevPath, String(index)], (v) => !isNil(v) && v !== ""),
26
+ ".",
27
+ ),
15
28
  ),
16
29
  ];
17
30
  }, []);
18
31
  }
19
- return [prevPath];
32
+
33
+ // class instance (non-plain object) => dừng
34
+ if (!isPlainObject(value)) {
35
+ return [prevPath];
36
+ }
37
+
38
+ // plain object => đi sâu
39
+ return Object.keys(value).reduce((prev: string[], cur) => {
40
+ return [
41
+ ...prev,
42
+ ...getAllNoneObjStringPath(
43
+ value[cur],
44
+ join(
45
+ filter([prevPath, cur], (v) => !isNil(v) && v !== ""),
46
+ ".",
47
+ ),
48
+ ),
49
+ ];
50
+ }, []);
20
51
  }
21
52
 
22
53
  export function getAllStringPath(value: any, prevPath: string = "") {
@@ -26,14 +57,14 @@ export function getAllStringPath(value: any, prevPath: string = "") {
26
57
  ...prev,
27
58
  join(
28
59
  filter([prevPath, cur], (v) => !isNil(v) && v !== ""),
29
- "."
60
+ ".",
30
61
  ),
31
62
  ...getAllStringPath(
32
63
  value[cur],
33
64
  join(
34
65
  filter([prevPath, cur], (v) => !isNil(v) && v !== ""),
35
- "."
36
- )
66
+ ".",
67
+ ),
37
68
  ),
38
69
  ];
39
70
  }, []);