@react-fabric/form 1.0.0-beta.6 → 1.0.0-beta.60

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 (187) hide show
  1. package/{src/input/ArrayInput.module.css → css/_arrayInput.css} +8 -8
  2. package/css/_colorPicker.css +65 -0
  3. package/{src/internal/Field.module.css → css/_field.css} +66 -30
  4. package/{src/input/File.module.css → css/_file.css} +21 -6
  5. package/css/styles.css +27 -0
  6. package/dist/cjs/builder/FileField.js +40 -28
  7. package/dist/cjs/builder/FormField.js +50 -34
  8. package/dist/cjs/builder/SchemaEditor.js +72 -0
  9. package/dist/cjs/builder/components/AddSchemaFields.js +62 -0
  10. package/dist/cjs/builder/components/SchemaArrayForm.js +145 -0
  11. package/dist/cjs/builder/components/SchemaField.js +414 -0
  12. package/dist/cjs/form/Controller.js +46 -1
  13. package/dist/cjs/form/Form.js +70 -9
  14. package/dist/cjs/hooks/useFileUploader.js +16 -0
  15. package/dist/cjs/hooks/useFormBuilder.js +57 -15
  16. package/dist/cjs/i18n/ar.json.js +5 -4
  17. package/dist/cjs/i18n/en.json.js +5 -5
  18. package/dist/cjs/index.js +12 -2
  19. package/dist/cjs/input/ArrayInput.js +126 -36
  20. package/dist/cjs/input/AvatarInput.js +43 -23
  21. package/dist/cjs/input/Checkbox.js +16 -6
  22. package/dist/cjs/input/ColorInput.js +113 -58
  23. package/dist/cjs/input/ColorPicker.js +60 -76
  24. package/dist/cjs/input/DateInput.js +197 -0
  25. package/dist/cjs/input/Field.js +10 -4
  26. package/dist/cjs/input/File.js +14 -7
  27. package/dist/cjs/input/Hidden.js +13 -2
  28. package/dist/cjs/input/Input.js +24 -10
  29. package/dist/cjs/input/Number.js +22 -10
  30. package/dist/cjs/input/Password.js +22 -12
  31. package/dist/cjs/input/Radio.js +12 -3
  32. package/dist/cjs/input/RadioGroup.js +10 -1
  33. package/dist/cjs/input/{Range.js → RangeSlider.js} +16 -8
  34. package/dist/cjs/input/Search.js +30 -19
  35. package/dist/cjs/input/Slider.js +12 -6
  36. package/dist/cjs/input/Switch.js +20 -9
  37. package/dist/cjs/input/Textarea.js +29 -8
  38. package/dist/cjs/internal/ErrorIcon.js +1 -1
  39. package/dist/cjs/internal/FieldWrapper.js +11 -4
  40. package/dist/cjs/internal/InputWrapper.js +27 -20
  41. package/dist/cjs/select/AutoComplete.js +21 -11
  42. package/dist/cjs/select/DualList.js +34 -12
  43. package/dist/cjs/select/List.js +68 -22
  44. package/dist/cjs/select/Options.js +49 -42
  45. package/dist/cjs/select/Select.js +40 -10
  46. package/dist/cjs/select/useSelect.js +22 -11
  47. package/dist/cjs/types/schema.js +43 -0
  48. package/dist/cjs/utils/eventHandlers.js +15 -5
  49. package/dist/esm/builder/FileField.js +42 -30
  50. package/dist/esm/builder/FormField.js +50 -34
  51. package/dist/esm/builder/SchemaEditor.js +72 -0
  52. package/dist/esm/builder/components/AddSchemaFields.js +62 -0
  53. package/dist/esm/builder/components/SchemaArrayForm.js +145 -0
  54. package/dist/esm/builder/components/SchemaField.js +414 -0
  55. package/dist/esm/form/Controller.js +46 -1
  56. package/dist/esm/form/Form.js +72 -12
  57. package/dist/esm/hooks/useFileUploader.js +16 -0
  58. package/dist/esm/hooks/useFormBuilder.js +55 -13
  59. package/dist/esm/i18n/ar.json.js +5 -4
  60. package/dist/esm/i18n/en.json.js +5 -5
  61. package/dist/esm/index.js +6 -2
  62. package/dist/esm/input/ArrayInput.js +126 -36
  63. package/dist/esm/input/AvatarInput.js +44 -24
  64. package/dist/esm/input/Checkbox.js +16 -6
  65. package/dist/esm/input/ColorInput.js +115 -60
  66. package/dist/esm/input/ColorPicker.js +60 -76
  67. package/dist/esm/input/DateInput.js +197 -0
  68. package/dist/esm/input/Field.js +11 -5
  69. package/dist/esm/input/File.js +16 -9
  70. package/dist/esm/input/Hidden.js +13 -2
  71. package/dist/esm/input/Input.js +26 -12
  72. package/dist/esm/input/Number.js +24 -12
  73. package/dist/esm/input/Password.js +24 -14
  74. package/dist/esm/input/Radio.js +12 -3
  75. package/dist/esm/input/RadioGroup.js +11 -2
  76. package/dist/esm/input/{Range.js → RangeSlider.js} +16 -8
  77. package/dist/esm/input/Search.js +32 -21
  78. package/dist/esm/input/Slider.js +12 -6
  79. package/dist/esm/input/Switch.js +20 -9
  80. package/dist/esm/input/Textarea.js +30 -9
  81. package/dist/esm/internal/ErrorIcon.js +1 -1
  82. package/dist/esm/internal/FieldWrapper.js +11 -4
  83. package/dist/esm/internal/InputWrapper.js +27 -20
  84. package/dist/esm/select/AutoComplete.js +21 -11
  85. package/dist/esm/select/DualList.js +34 -12
  86. package/dist/esm/select/List.js +69 -23
  87. package/dist/esm/select/Options.js +49 -42
  88. package/dist/esm/select/Select.js +40 -10
  89. package/dist/esm/select/useSelect.js +23 -12
  90. package/{src/input/ColorPicker.module.css → dist/esm/types/schema.js} +18 -27
  91. package/dist/esm/utils/eventHandlers.js +15 -5
  92. package/dist/types/builder/FileField.d.ts +4 -2
  93. package/dist/types/builder/FormField.d.ts +3 -1
  94. package/dist/types/builder/SchemaEditor.d.ts +9 -0
  95. package/dist/types/builder/components/AddSchemaFields.d.ts +2 -0
  96. package/dist/types/builder/components/SchemaArrayForm.d.ts +2 -0
  97. package/dist/types/builder/components/SchemaField.d.ts +10 -0
  98. package/dist/types/form/Controller.d.ts +47 -2
  99. package/dist/types/form/Form.d.ts +48 -2
  100. package/dist/types/hooks/useFileUploader.d.ts +16 -0
  101. package/dist/types/hooks/useFormBuilder.d.ts +32 -1
  102. package/dist/types/index.d.ts +6 -3
  103. package/dist/types/input/ArrayInput.d.ts +69 -12
  104. package/dist/types/input/AvatarInput.d.ts +23 -3
  105. package/dist/types/input/Checkbox.d.ts +13 -4
  106. package/dist/types/input/ColorInput.d.ts +15 -2
  107. package/dist/types/input/ColorPicker.d.ts +11 -1
  108. package/dist/types/input/DateInput.d.ts +28 -0
  109. package/dist/types/input/Field.d.ts +5 -1
  110. package/dist/types/input/File.d.ts +9 -0
  111. package/dist/types/input/Hidden.d.ts +13 -1
  112. package/dist/types/input/Input.d.ts +9 -1
  113. package/dist/types/input/Number.d.ts +9 -1
  114. package/dist/types/input/Password.d.ts +7 -1
  115. package/dist/types/input/Radio.d.ts +9 -1
  116. package/dist/types/input/RadioGroup.d.ts +8 -1
  117. package/dist/types/input/RangeSlider.d.ts +67 -0
  118. package/dist/types/input/Search.d.ts +14 -1
  119. package/dist/types/input/Slider.d.ts +8 -2
  120. package/dist/types/input/Switch.d.ts +10 -2
  121. package/dist/types/input/Textarea.d.ts +15 -1
  122. package/dist/types/internal/FieldWrapper.d.ts +1 -1
  123. package/dist/types/internal/InputWrapper.d.ts +1 -1
  124. package/dist/types/ranger/index.d.ts +2 -2
  125. package/dist/types/select/AutoComplete.d.ts +8 -1
  126. package/dist/types/select/DualList.d.ts +17 -2
  127. package/dist/types/select/List.d.ts +9 -1
  128. package/dist/types/select/Options.d.ts +4 -3
  129. package/dist/types/select/Select.d.ts +9 -1
  130. package/dist/types/select/useSelect.d.ts +10 -6
  131. package/dist/types/types/index.d.ts +34 -5
  132. package/dist/types/types/schema.d.ts +78 -33
  133. package/dist/types/utils/eventHandlers.d.ts +2 -2
  134. package/package.json +22 -20
  135. package/src/builder/FileField.tsx +62 -30
  136. package/src/builder/FormField.tsx +52 -32
  137. package/src/builder/SchemaEditor.tsx +79 -0
  138. package/src/builder/components/AddSchemaFields.tsx +74 -0
  139. package/src/builder/components/SchemaArrayForm.tsx +161 -0
  140. package/src/builder/components/SchemaField.tsx +436 -0
  141. package/src/form/Controller.tsx +48 -3
  142. package/src/form/Form.tsx +80 -8
  143. package/src/hooks/useFileUploader.ts +16 -0
  144. package/src/hooks/useFormBuilder.tsx +58 -15
  145. package/src/i18n/ar.json +5 -4
  146. package/src/i18n/en.json +5 -5
  147. package/src/index.ts +12 -3
  148. package/src/input/ArrayInput.tsx +199 -64
  149. package/src/input/AvatarInput.tsx +67 -19
  150. package/src/input/Checkbox.tsx +16 -6
  151. package/src/input/ColorInput.tsx +131 -71
  152. package/src/input/ColorPicker.tsx +54 -72
  153. package/src/input/DateInput.tsx +260 -0
  154. package/src/input/Field.tsx +15 -11
  155. package/src/input/File.tsx +16 -14
  156. package/src/input/Hidden.tsx +17 -2
  157. package/src/input/Input.tsx +27 -13
  158. package/src/input/Number.tsx +22 -13
  159. package/src/input/Password.tsx +24 -16
  160. package/src/input/Radio.tsx +12 -3
  161. package/src/input/RadioGroup.tsx +12 -3
  162. package/src/input/{Range.tsx → RangeSlider.tsx} +17 -9
  163. package/src/input/Search.tsx +33 -22
  164. package/src/input/Slider.tsx +20 -11
  165. package/src/input/Switch.tsx +17 -6
  166. package/src/input/Textarea.tsx +34 -9
  167. package/src/internal/ErrorIcon.tsx +1 -1
  168. package/src/internal/FieldWrapper.tsx +8 -4
  169. package/src/internal/InputWrapper.tsx +49 -27
  170. package/src/select/AutoComplete.tsx +14 -3
  171. package/src/select/DualList.tsx +31 -16
  172. package/src/select/List.tsx +52 -8
  173. package/src/select/Options.tsx +50 -46
  174. package/src/select/Select.tsx +25 -5
  175. package/src/select/useSelect.tsx +26 -9
  176. package/src/types/index.ts +37 -6
  177. package/src/types/schema.ts +96 -41
  178. package/src/utils/eventHandlers.ts +14 -4
  179. package/dist/cjs/input/ArrayInput.module.css +0 -1
  180. package/dist/cjs/input/ColorPicker.module.css +0 -1
  181. package/dist/cjs/input/File.module.css +0 -1
  182. package/dist/cjs/internal/Field.module.css +0 -1
  183. package/dist/esm/input/ArrayInput.module.css +0 -1
  184. package/dist/esm/input/ColorPicker.module.css +0 -1
  185. package/dist/esm/input/File.module.css +0 -1
  186. package/dist/esm/internal/Field.module.css +0 -1
  187. package/dist/types/input/Range.d.ts +0 -59
@@ -21,38 +21,47 @@
21
21
  * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
22
22
  */
23
23
 
24
+ import { EmptyContent } from "@react-fabric/core";
24
25
  import { isEmpty, isNil, yup } from "@react-fabric/utilities";
25
26
  import { Fragment, useMemo } from "react";
26
27
  import { FormField } from "../builder/FormField";
27
- import { type FormSchema } from "../types/schema";
28
+ import { type BaseSelectProps } from "../types";
29
+ import { DATA_TYPES, type FormSchema, type SchemaDef } from "../types/schema";
28
30
  import { type UploadHandler } from "./useFileUploader";
29
31
 
30
- const makeYupField = (field: FormSchema) => {
31
- let yupField: AnyObject = yup.string();
32
- if (field.datatype === "schema") {
32
+ const makeYupField = (field: SchemaDef) => {
33
+ let yupField: AnyObject = yup.string().trim();
34
+ if (field.datatype === DATA_TYPES.SCHEMA) {
33
35
  const child: yup.ObjectShape = Object.fromEntries(
34
36
  field.schema.map((field) => [field.id, makeYupField(field)]),
35
37
  );
36
38
  yupField = yup.object(child);
37
39
  }
38
- if (field.datatype === "boolean") yupField = yup.boolean();
40
+ if (field.datatype === DATA_TYPES.BOOL) {
41
+ yupField = yup.boolean();
42
+ if (field.required)
43
+ yupField = yupField.test({
44
+ test: (value: boolean) => !!value,
45
+ });
46
+ }
39
47
  if (
40
- field.datatype === "decimal" ||
41
- field.datatype === "number" ||
42
- field.datatype === "range"
48
+ field.datatype === DATA_TYPES.NUMBER ||
49
+ field.datatype === DATA_TYPES.RANGE
43
50
  ) {
44
51
  yupField = yup.number();
45
52
  !isNil(field.min) && (yupField = yupField.min(field.min));
46
53
  !isNil(field.max) && (yupField = yupField.max(field.max));
47
54
 
48
- if (field.datatype === "range") yupField = yup.array(yupField);
55
+ if (field.datatype === DATA_TYPES.RANGE) yupField = yup.array(yupField);
49
56
  }
50
- if (field.datatype === "string") {
51
- field.options && (yupField = yupField.oneOf(field.options));
57
+ if (field.datatype === DATA_TYPES.STRING) {
58
+ field.options &&
59
+ !field.allowCustom &&
60
+ (yupField = yupField.oneOf(field.options));
52
61
  field.regex && (yupField = yupField.matches(new RegExp(field.regex)));
53
62
  }
54
63
 
55
- if (field.datatype === "file") {
64
+ if (field.datatype === DATA_TYPES.FILE) {
56
65
  yupField = yup
57
66
  .object({
58
67
  filename: yup.string().required(),
@@ -83,13 +92,43 @@ const makeYupField = (field: FormSchema) => {
83
92
  return yupField.label(field.label);
84
93
  };
85
94
 
86
- export const useFormBuilder = (
87
- schema: FormSchema[],
95
+ /**
96
+ * useFormBuilder hook to create a form definition based on a schema.
97
+ * It generates a Yup validation schema and a form definition component.
98
+ *
99
+ * @param {FormSchema} schema - The schema defining the form fields.
100
+ * @param {Object} options - Optional parameters for the form builder.
101
+ * @returns An object containing the form definition and the Yup validation schema.
102
+ *
103
+ * @example
104
+ * ```jsx
105
+ * const { formDef, schemaDef } = useFormBuilder(schema, {
106
+ * prefix: "form",
107
+ * inline: true,
108
+ * fileUrl: (path) => `/files/${path}`,
109
+ * uploadHandler: async (file) => {
110
+ * // handle file upload
111
+ * },
112
+ * optionLists: {
113
+ * // define option lists for select fields
114
+ * }
115
+ * * });
116
+ * <FormBuilder
117
+ * schema={schemaDef}
118
+ * onSubmit={(data) => console.log(data)}
119
+ * >
120
+ * {formDef}
121
+ * </FormBuilder>
122
+ * ```
123
+ */
124
+ export const useFormBuilder = <T extends AnyObject = string>(
125
+ schema: FormSchema,
88
126
  options: {
89
127
  prefix?: string;
90
128
  inline?: boolean;
91
129
  fileUrl?: (path: string) => string;
92
130
  uploadHandler?: UploadHandler;
131
+ optionLists?: KeyValue<BaseSelectProps<T>>;
93
132
  } = {},
94
133
  ) => {
95
134
  const filteredSchema = useMemo(
@@ -106,11 +145,12 @@ export const useFormBuilder = (
106
145
  <Fragment>
107
146
  {filteredSchema.map((field, idx) => (
108
147
  <div className="mb-4" key={field.id}>
109
- {field.datatype === "schema" ? null : (
148
+ {field.datatype === DATA_TYPES.SCHEMA ? null : (
110
149
  <FormField
111
150
  {...field}
112
151
  autoFocus={idx === 0}
113
152
  fileUrl={options.fileUrl}
153
+ optionLists={options.optionLists}
114
154
  uploadHandler={options.uploadHandler}
115
155
  inline={options.inline}
116
156
  prefix={options.prefix}
@@ -118,6 +158,9 @@ export const useFormBuilder = (
118
158
  )}
119
159
  </div>
120
160
  ))}
161
+ {filteredSchema.length === 0 && (
162
+ <EmptyContent message="No schema defined" />
163
+ )}
121
164
  </Fragment>
122
165
  );
123
166
 
package/src/i18n/ar.json CHANGED
@@ -1,17 +1,18 @@
1
1
  {
2
2
  "addArray": "اضافة عنصر",
3
3
  "password": {
4
- "show": "عرض كلمة المرور",
5
- "hide": "اخفاء كلمة المرور"
4
+ "hide": "اخفاء كلمة المرور",
5
+ "show": "عرض كلمة المرور"
6
6
  },
7
7
  "select": {
8
+ "createOption": "إنشاء خيار جديد ل ",
8
9
  "emptyList": "لا توجد خيارات متاحة",
9
- "notFound": "لم يتم العثور على خيارات ل ",
10
- "createOption": "إنشاء خيار جديد ل "
10
+ "notFound": "لم يتم العثور على خيارات ل "
11
11
  },
12
12
  "yup": {
13
13
  "arrayMax": "{{label}} حدد ما يصل {{max}} غرض",
14
14
  "arrayMin": "{{label}} اختر على الأقل {{min}} غرض",
15
+ "empty": "{{label}} يجب ألا يكون فارغًا",
15
16
  "invalid": "{{label}} نوع بيانات غير صالح",
16
17
  "invalidType": "{{label}} غير صالح",
17
18
  "numberMax": "{{label}} الحد الأقصى {{max}}",
package/src/i18n/en.json CHANGED
@@ -1,19 +1,19 @@
1
1
  {
2
2
  "addArray": "Add Item",
3
3
  "password": {
4
- "show": "Show password",
5
- "hide": "Hide password"
4
+ "hide": "Hide password",
5
+ "show": "Show password"
6
6
  },
7
7
  "select": {
8
+ "createOption": "Create new option for <0>{{query}}</0>",
8
9
  "emptyList": "No options available",
9
- "notFound": "No options found for <0>{{query}}</0>",
10
- "createOption": "Create new option for <0>{{query}}</0>"
10
+ "notFound": "No options found for <0>{{query}}</0>"
11
11
  },
12
12
  "yup": {
13
13
  "arrayMax": "{{label}} must contain upto {{max}} item",
14
14
  "arrayMin": "{{label}} must contain atleast {{min}} item",
15
- "invalid": "{{label}} invalid",
16
15
  "empty": "{{label}} must not be empty",
16
+ "invalid": "{{label}} invalid",
17
17
  "invalidType": "{{label}} invalid datatype",
18
18
  "numberMax": "{{label}} maximum {{max}}",
19
19
  "numberMin": "{{label}} minimum {{min}}",
package/src/index.ts CHANGED
@@ -24,17 +24,26 @@
24
24
  import "./i18n";
25
25
 
26
26
  export { Controller } from "./form/Controller";
27
- export { Form, type FormRef } from "./form/Form";
27
+ export { Form, useForm, type FormRef } from "./form/Form";
28
+
29
+ export { SchemaEditor } from "./builder/SchemaEditor";
28
30
 
29
31
  export { useFileUploader } from "./hooks/useFileUploader";
30
32
  export { useFormBuilder } from "./hooks/useFormBuilder";
31
33
 
32
- export { type FormSchema } from "./types/schema";
34
+ export {
35
+ DATA_TYPES,
36
+ SCHEMA_CUSTOM_LIST,
37
+ type FormSchema,
38
+ type SchemaDef,
39
+ } from "./types/schema";
33
40
 
34
41
  export { ArrayInput } from "./input/ArrayInput";
42
+ export { AvatarInput } from "./input/AvatarInput";
35
43
  export { Checkbox } from "./input/Checkbox";
36
44
  export { ColorInput } from "./input/ColorInput";
37
45
  export { ColorPicker } from "./input/ColorPicker";
46
+ export { DateInput } from "./input/DateInput";
38
47
  export { Field } from "./input/Field";
39
48
  export { FileInput } from "./input/File";
40
49
  export { HiddenInput } from "./input/Hidden";
@@ -43,7 +52,7 @@ export { Number } from "./input/Number";
43
52
  export { Password } from "./input/Password";
44
53
  export { Radio } from "./input/Radio";
45
54
  export { RadioGroup } from "./input/RadioGroup";
46
- export { Range } from "./input/Range";
55
+ export { RangeSlider } from "./input/RangeSlider";
47
56
  export { Search } from "./input/Search";
48
57
  export { Slider } from "./input/Slider";
49
58
  export { Switch } from "./input/Switch";
@@ -23,27 +23,33 @@
23
23
 
24
24
  import { DndContext, type DragEndEvent } from "@dnd-kit/core";
25
25
  import { SortableContext, useSortable } from "@dnd-kit/sortable";
26
- import { Button, CoreIcons, Icon, Tooltip } from "@react-fabric/core";
27
- import { isString } from "@react-fabric/utilities";
26
+ import {
27
+ Button,
28
+ cloneChildren,
29
+ CoreIcons,
30
+ Icon,
31
+ Tooltip,
32
+ } from "@react-fabric/core";
33
+ import { isFalse, isString } from "@react-fabric/utilities";
28
34
  import classNames from "classnames";
29
35
  import {
30
- Children,
31
36
  cloneElement,
32
37
  useCallback,
38
+ useEffect,
33
39
  useImperativeHandle,
34
40
  useMemo,
41
+ useState,
35
42
  type PropsWithChildren,
36
- type ReactNode,
37
- type RefObject,
43
+ type Ref,
38
44
  } from "react";
39
45
  import { useFieldArray, useFormContext } from "react-hook-form";
40
46
  import { useTranslation } from "react-i18next";
41
47
  import { FieldWrapper } from "../internal/FieldWrapper";
42
- import classes from "./ArrayInput.module.css";
43
48
 
44
49
  import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
45
50
  import { CSS } from "@dnd-kit/utilities";
46
- export interface ArrayInputProps {
51
+
52
+ export interface ArrayInputProps<T extends AnyObject = string> {
47
53
  /**
48
54
  * input label
49
55
  */
@@ -77,6 +83,10 @@ export interface ArrayInputProps {
77
83
  * minimum required length
78
84
  */
79
85
  minItems?: number;
86
+ /**
87
+ * maximum length
88
+ */
89
+ maxItems?: number;
80
90
  /**
81
91
  * field name to focus on add
82
92
  */
@@ -87,23 +97,35 @@ export interface ArrayInputProps {
87
97
  enableSorting?: boolean;
88
98
  children:
89
99
  | React.ReactNode
90
- | ((props: { index: number; name: string }) => ReactNode);
100
+ | ((props: { index: number; name: string; item: T }) => React.ReactNode);
91
101
  /**
92
102
  * add item button label
93
103
  */
94
104
  addLabel?: string;
95
- /**
96
- * fixed length list
97
- */
98
- fixedList?: boolean;
99
- arrayRef?: RefObject<{
100
- addItem: (item: AnyObject) => void;
105
+ buttonPosition?: "top" | "bottom" | "both";
106
+ arrayRef?: Ref<{
107
+ addItem: (item: T) => void;
101
108
  removeItem: (idx: number) => void;
102
109
  }>;
103
110
  /**
104
- * Add item
111
+ * add new item
112
+ */
113
+ onAdd?: () => T;
114
+ /**
115
+ * on item remove
105
116
  */
106
- onAdd?: () => AnyObject;
117
+ onRemove?: (item: T, idx: number) => void;
118
+ /**
119
+ * before item remove
120
+ */
121
+ onBeforeRemove?: (item: T, idx: number) => Promise<boolean> | boolean;
122
+ /**
123
+ * can remove item
124
+ */
125
+ canRemove?:
126
+ | false
127
+ | "newonly"
128
+ | ((props: { item: T; index: number; lastItem: boolean }) => boolean);
107
129
 
108
130
  /**
109
131
  * field width
@@ -119,20 +141,63 @@ export interface ArrayInputProps {
119
141
  labelWidth?: string;
120
142
  }
121
143
 
122
- export const ArrayInput = ({
144
+ /**
145
+ * ArrayInput component for managing an array of items in a form.
146
+ * It allows adding, removing, and sorting items within the array.
147
+ * It integrates with react-hook-form for form state management and validation.
148
+ * It supports fixed-length lists, custom add/remove handlers, and can display validation errors.
149
+ * It also provides options for inline display and custom button positioning.
150
+ *
151
+ * It uses the `useFieldArray` hook from `react-hook-form` to manage the array state and provides a simple interface for adding and removing items.
152
+ *
153
+ * It also supports drag-and-drop sorting using `@dnd-kit/core` and `@dnd-kit/sortable` for a better user experience when reordering items.
154
+ *
155
+ * @param {ArrayInputProps<T>} props - The properties for the ArrayInput component.
156
+ * @returns {JSX.Element} The rendered ArrayInput component.
157
+ *
158
+ * @example
159
+ * ```jsx
160
+ * <ArrayInput
161
+ * name="items"
162
+ * label="Items"
163
+ * addLabel="Add Item"
164
+ * onAdd={() => ({ name: "", value: "" })}
165
+ * onRemove={(item, index) => console.log("Removed item:", item, "at index:", index)}
166
+ * onBeforeRemove={(item, index) => confirm(`Are you sure you want to remove item at index ${index}?`)}
167
+ * canRemove={({ item, index, lastItem }) => !lastItem} // Prevent removing the last item
168
+ * >
169
+ * {({ index, name }) => (
170
+ * <Input
171
+ * name={name}
172
+ * label={`Item ${index + 1}`}
173
+ * placeholder="Enter item value"
174
+ * required={index === 0} // Make the first item required
175
+ * />
176
+ * )}
177
+ * </ArrayInput>
178
+ * ```
179
+ *
180
+ * @see {@link https://react-hook-form.com/api/usefieldarray} for more details on `useFieldArray`.
181
+ * @see {@link https://react-dnd.github.io/react-dnd/about} for more details on DnD context and sorting.
182
+ */
183
+ export const ArrayInput = <T extends AnyObject = string>({
123
184
  name,
124
185
  children,
125
186
  addLabel,
126
- fixedList,
127
187
  focusName = "",
188
+ buttonPosition = "bottom",
128
189
  onAdd,
190
+ onRemove,
191
+ onBeforeRemove,
192
+ canRemove,
129
193
  arrayRef,
130
194
  enableSorting,
131
195
  disabled = false,
132
196
  readOnly = false,
133
197
  minItems = 0,
198
+ maxItems = Number.MAX_SAFE_INTEGER,
134
199
  ...rest
135
- }: ArrayInputProps) => {
200
+ }: ArrayInputProps<T>) => {
136
201
  const { t } = useTranslation("form");
137
202
  const form = useFormContext();
138
203
 
@@ -141,9 +206,15 @@ export const ArrayInput = ({
141
206
 
142
207
  const { fields, append, remove, move } = useFieldArray({
143
208
  name,
209
+ keyName: "__ID__",
144
210
  control: form.control,
145
211
  });
146
212
 
213
+ const [initialList, setInitialList] = useState<string[]>([]);
214
+ useEffect(() => {
215
+ setInitialList(fields.map((f) => f.__ID__));
216
+ }, []);
217
+
147
218
  const error = useMemo<AnyObject>(
148
219
  () =>
149
220
  form.formState.errors[name]?.message ??
@@ -151,8 +222,27 @@ export const ArrayInput = ({
151
222
  [form.formState.errors],
152
223
  );
153
224
 
225
+ const removeCheck = useCallback(
226
+ (item: T, index: number) => {
227
+ if (canRemove === false) return false;
228
+ if (canRemove === "newonly") {
229
+ return !initialList.includes((item as any).__ID__);
230
+ }
231
+ if (typeof canRemove === "function") {
232
+ return canRemove({
233
+ item,
234
+ index,
235
+ lastItem: index + 1 === fields.length,
236
+ });
237
+ }
238
+ return true;
239
+ },
240
+ [canRemove, initialList, fields],
241
+ );
242
+
154
243
  const handleAdd = useCallback(
155
- (item: AnyObject) => {
244
+ (item?: T) => {
245
+ if (!item) return;
156
246
  append(item);
157
247
  setTimeout(() => {
158
248
  form.setFocus(
@@ -163,16 +253,30 @@ export const ArrayInput = ({
163
253
  [name, fields, focusName],
164
254
  );
165
255
 
256
+ const handleRemove = useCallback(
257
+ (item: T, index: number) => {
258
+ const ret = onBeforeRemove?.(item, index);
259
+ void Promise.resolve(ret).then((b) => {
260
+ if (!isFalse(b)) {
261
+ remove(index);
262
+ onRemove?.(item, index);
263
+ }
264
+ });
265
+ },
266
+ [remove, onRemove, onBeforeRemove],
267
+ );
268
+
166
269
  useImperativeHandle(arrayRef, () => ({
167
270
  addItem: handleAdd,
168
271
  removeItem: remove,
169
272
  }));
170
273
 
171
274
  const Wrapper = useCallback(
172
- ({ children }: PropsWithChildren) => {
275
+ // eslint-disable-next-line @typescript-eslint/promise-function-async
276
+ ({ children }: { children: React.ReactNode }) => {
173
277
  if (enableSorting) {
174
278
  // pass id list to dnd context
175
- const idMap = fields.map((item) => item.id);
279
+ const idMap = fields.map((item) => item.__ID__);
176
280
  const handleDragEnd = (e: DragEndEvent) => {
177
281
  if (e.over && e.active.id !== e.over.id) {
178
282
  // find index of item and drop over
@@ -240,22 +344,81 @@ export const ArrayInput = ({
240
344
  // @ts-expect-error ignore
241
345
  SortableItem.displayName = "SortableItem";
242
346
 
347
+ const addButton = useMemo(
348
+ () => (
349
+ <div className="flex justify-end">
350
+ <Tooltip
351
+ color="danger-200"
352
+ content={
353
+ isString(error)
354
+ ? `${error}`
355
+ : (t(
356
+ error?.key,
357
+ error?.values
358
+ ? {
359
+ ...error?.values,
360
+ label: t(
361
+ error.values?.label ?? "form:badkey",
362
+ error.values?.path,
363
+ ),
364
+ }
365
+ : {},
366
+ ) as string)
367
+ }
368
+ >
369
+ <Button
370
+ size="sm"
371
+ aria-label="Add item"
372
+ icon={CoreIcons.insert}
373
+ onClick={() => handleAdd(onAdd?.())}
374
+ data-invalid={!!error}
375
+ disabled={!!disabled || readOnly || fields.length > maxItems}
376
+ className={classNames(
377
+ "fabric-addButton",
378
+ canRemove !== false && "me-10",
379
+ )}
380
+ >
381
+ {addLabel ?? t("form:addArray")}
382
+ </Button>
383
+ </Tooltip>
384
+ </div>
385
+ ),
386
+ [
387
+ error,
388
+ fields,
389
+ handleAdd,
390
+ canRemove,
391
+ disabled,
392
+ readOnly,
393
+ addLabel,
394
+ maxItems,
395
+ ],
396
+ );
397
+
243
398
  return (
244
- <div className={classes.arrayInput}>
245
- <FieldWrapper {...rest}>
399
+ <div className={"fabric-arrayInput"}>
400
+ <FieldWrapper
401
+ {...rest}
402
+ appendLabel={
403
+ <div className="-me-2">
404
+ {onAdd && buttonPosition !== "bottom" && addButton}
405
+ </div>
406
+ }
407
+ >
246
408
  <Wrapper>
247
409
  {fields.map((item, index) => (
248
410
  <SortableItem
249
- key={item.id}
250
- id={item.id}
411
+ key={item.__ID__}
412
+ id={item.__ID__}
251
413
  className="flex items-center flex-nowrap gap-2 mb-1"
252
414
  >
253
415
  {typeof children === "function"
254
416
  ? children({
255
417
  index,
418
+ item: item as any,
256
419
  name: `${name}.${index}`,
257
420
  })
258
- : Children.map(children, (child: AnyObject) =>
421
+ : cloneChildren(children, (child: AnyObject) =>
259
422
  cloneElement(
260
423
  child,
261
424
  Object.assign(
@@ -277,55 +440,27 @@ export const ArrayInput = ({
277
440
  ),
278
441
  ),
279
442
  )}
280
- {!fixedList && (
443
+ {canRemove !== false && (
281
444
  <Button
282
445
  aria-label="Remove item"
283
446
  icon={CoreIcons.remove}
284
447
  color="danger"
285
448
  variant="link"
286
449
  className="self-end"
287
- disabled={disabled || readOnly || fields.length <= minItems}
288
- onClick={() => remove(index)}
450
+ disabled={
451
+ disabled ||
452
+ readOnly ||
453
+ fields.length <= minItems ||
454
+ !removeCheck(item as any, index)
455
+ }
456
+ onClick={() => handleRemove(item as any, index)}
289
457
  />
290
458
  )}
291
459
  </SortableItem>
292
460
  ))}
293
461
  </Wrapper>
294
462
  </FieldWrapper>
295
- {!fixedList && onAdd && (
296
- <div className="flex justify-end">
297
- <Tooltip
298
- color="danger"
299
- content={
300
- isString(error)
301
- ? `${error}`
302
- : (t(
303
- error?.key,
304
- error?.values
305
- ? {
306
- ...error?.values,
307
- label: t(
308
- error.values?.label ?? "form:badkey",
309
- error.values?.path,
310
- ),
311
- }
312
- : {},
313
- ) as string)
314
- }
315
- >
316
- <Button
317
- aria-label="Add item"
318
- icon={CoreIcons.insert}
319
- onClick={() => handleAdd(onAdd())}
320
- data-invalid={!!error}
321
- disabled={!!disabled || readOnly}
322
- className={classNames(classes.addButton, "me-10")}
323
- >
324
- {addLabel ?? t("addArray")}
325
- </Button>
326
- </Tooltip>
327
- </div>
328
- )}
463
+ {onAdd && buttonPosition !== "top" && addButton}
329
464
  </div>
330
465
  );
331
466
  };