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