@idem.agency/form-builder 0.0.9
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 +32 -0
- package/README.md +20 -0
- package/eslint.config.js +23 -0
- package/index.html +14 -0
- package/package.json +39 -0
- package/src/app/debug.tsx +89 -0
- package/src/app/index.tsx +69 -0
- package/src/app/test.css +1 -0
- package/src/index.ts +6 -0
- package/src/main.tsx +9 -0
- package/src/shared/hook/useUpdateEffect.tsx +23 -0
- package/src/shared/lib/VisibleCore.spec.ts +103 -0
- package/src/shared/lib/VisibleCore.ts +43 -0
- package/src/shared/lib/validation/core.spec.ts +103 -0
- package/src/shared/lib/validation/core.ts +79 -0
- package/src/shared/lib/validation/rules/base.ts +10 -0
- package/src/shared/lib/validation/rules/confirm.spec.ts +17 -0
- package/src/shared/lib/validation/rules/confirm.ts +32 -0
- package/src/shared/lib/validation/rules/email.spec.ts +12 -0
- package/src/shared/lib/validation/rules/email.ts +13 -0
- package/src/shared/lib/validation/rules/require.spec.ts +13 -0
- package/src/shared/lib/validation/rules/require.ts +12 -0
- package/src/shared/model/index.ts +13 -0
- package/src/shared/model/store.tsx +52 -0
- package/src/shared/types/common.ts +79 -0
- package/src/shared/utils.ts +25 -0
- package/src/widgets/dynamicBuilder/index.tsx +63 -0
- package/src/widgets/form/index.ts +2 -0
- package/src/widgets/form/ui/group/index.tsx +27 -0
- package/src/widgets/form/ui/input/index.tsx +29 -0
- package/tsconfig.app.json +26 -0
- package/tsconfig.json +7 -0
- package/tsconfig.node.json +24 -0
- package/vite-env.d.ts +1 -0
- package/vite.config.ts +20 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Change Log
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
|
5
|
+
|
|
6
|
+
## [0.0.9](https://gitlab.idem.agency/idem-project/front-package/compare/@idem.agency/form-builder@0.0.8...@idem.agency/form-builder@0.0.9) (2026-01-13)
|
|
7
|
+
|
|
8
|
+
**Note:** Version bump only for package @idem.agency/form-builder
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
## [0.0.8](https://gitlab.idem.agency/idem-project/front-package/compare/@idem.agency/form-builder@0.0.7...@idem.agency/form-builder@0.0.8) (2026-01-13)
|
|
15
|
+
|
|
16
|
+
**Note:** Version bump only for package @idem.agency/form-builder
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
## 0.0.7 (2026-01-13)
|
|
23
|
+
|
|
24
|
+
**Note:** Version bump only for package @idem.agency/form-builder
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
## 0.0.6 (2026-01-13)
|
|
31
|
+
|
|
32
|
+
**Note:** Version bump only for package @idem.agency/form-builder
|
package/README.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# React Form Builder
|
|
2
|
+
|
|
3
|
+
Построитель форм с валидацией и проверкой отображения полей.
|
|
4
|
+
|
|
5
|
+
Легко расширяется за счет системы плагинов для валидаторов и полей
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Параметры
|
|
9
|
+
|
|
10
|
+
| Название параметра | Описание | Является обязательным |
|
|
11
|
+
|:-------------------|:------------------------------------------|:---------------------:|
|
|
12
|
+
| formData | Стартовые данные формы | |
|
|
13
|
+
| layout | Структура формы | Да |
|
|
14
|
+
| plugins | Объект с доступными компонентами формы | Да |
|
|
15
|
+
| onChange | Событие на изменение данных формы | |
|
|
16
|
+
| onSubmit | Событие на отправку формы | |
|
|
17
|
+
| children | Элемент который добавляется внутри формы | |
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
package/eslint.config.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import js from '@eslint/js'
|
|
2
|
+
import globals from 'globals'
|
|
3
|
+
import reactHooks from 'eslint-plugin-react-hooks'
|
|
4
|
+
import reactRefresh from 'eslint-plugin-react-refresh'
|
|
5
|
+
import tseslint from 'typescript-eslint'
|
|
6
|
+
import { globalIgnores } from 'eslint/config'
|
|
7
|
+
|
|
8
|
+
export default tseslint.config([
|
|
9
|
+
globalIgnores(['dist']),
|
|
10
|
+
{
|
|
11
|
+
files: ['**/*.{ts,tsx}'],
|
|
12
|
+
extends: [
|
|
13
|
+
js.configs.recommended,
|
|
14
|
+
tseslint.configs.recommended,
|
|
15
|
+
reactHooks.configs['recommended-latest'],
|
|
16
|
+
reactRefresh.configs.vite,
|
|
17
|
+
],
|
|
18
|
+
languageOptions: {
|
|
19
|
+
ecmaVersion: 2020,
|
|
20
|
+
globals: globals.browser,
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
])
|
package/index.html
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
|
+
<title>Виджет для генерации форм</title>
|
|
8
|
+
<link href="/src/app/test.css" rel="stylesheet">
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<div id="root"></div>
|
|
12
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
13
|
+
</body>
|
|
14
|
+
</html>
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@idem.agency/form-builder",
|
|
3
|
+
"version": "0.0.9",
|
|
4
|
+
"description": "Построитель форм",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"author": "idem.agency",
|
|
7
|
+
"license": "ISC",
|
|
8
|
+
"type": "module",
|
|
9
|
+
"scripts": {
|
|
10
|
+
"dev": "vite",
|
|
11
|
+
"build": "tsc -b && vite build",
|
|
12
|
+
"lint": "eslint .",
|
|
13
|
+
"preview": "vite preview",
|
|
14
|
+
"test": "vitest run"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"clsx": "2.1.1",
|
|
18
|
+
"react": "19.2.3",
|
|
19
|
+
"react-dom": "19.2.3"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@eslint/js": "9.39.2",
|
|
23
|
+
"@tailwindcss/vite": "4.1.18",
|
|
24
|
+
"@testing-library/react": "16.3.1",
|
|
25
|
+
"@types/react": "19.2.8",
|
|
26
|
+
"@types/react-dom": "19.2.3",
|
|
27
|
+
"@vitejs/plugin-react-swc": "3.11.0",
|
|
28
|
+
"eslint": "9.39.2",
|
|
29
|
+
"eslint-plugin-react-hooks": "5.2.0",
|
|
30
|
+
"eslint-plugin-react-refresh": "0.4.26",
|
|
31
|
+
"globals": "17.0.0",
|
|
32
|
+
"tailwindcss": "4.1.18",
|
|
33
|
+
"typescript": "5.9.3",
|
|
34
|
+
"typescript-eslint": "8.53.0",
|
|
35
|
+
"vite": "7.3.1",
|
|
36
|
+
"vite-plugin-dts": "4.5.4",
|
|
37
|
+
"vitest": "4.0.17"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import FormBuilder from "./index.tsx";
|
|
2
|
+
import type {FormBuilderRef, FormElementRegistry, FormFieldConfig} from "../shared/types/common.ts";
|
|
3
|
+
import {FormGroup, TextField} from "../widgets/form";
|
|
4
|
+
import {useRef} from "react";
|
|
5
|
+
|
|
6
|
+
function Debug() {
|
|
7
|
+
|
|
8
|
+
const plugins: FormElementRegistry = {
|
|
9
|
+
text: TextField,
|
|
10
|
+
group: FormGroup,
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const layout: FormFieldConfig[] = [
|
|
14
|
+
{
|
|
15
|
+
id: 'username',
|
|
16
|
+
name: 'username',
|
|
17
|
+
label: 'Имя пользователя',
|
|
18
|
+
type: 'text',
|
|
19
|
+
placeholder: 'Введите ваше имя',
|
|
20
|
+
defaultValue: 'Мое имя',
|
|
21
|
+
validation: ['required', 'confirm:personal.username_2,Имя 2 пользователя'],
|
|
22
|
+
viewConfig: {
|
|
23
|
+
logic: 'and',
|
|
24
|
+
rules: [
|
|
25
|
+
{
|
|
26
|
+
field: 'personal.username_2',
|
|
27
|
+
operator: 'in',
|
|
28
|
+
value: ['Alex']
|
|
29
|
+
},
|
|
30
|
+
]
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: 'personal',
|
|
35
|
+
name: 'personal',
|
|
36
|
+
label: 'Группа',
|
|
37
|
+
type: 'group',
|
|
38
|
+
variant: 'col',
|
|
39
|
+
fields: [
|
|
40
|
+
{
|
|
41
|
+
id: 'username_2',
|
|
42
|
+
name: 'username_2',
|
|
43
|
+
label: 'Имя 2 пользователя',
|
|
44
|
+
type: 'text',
|
|
45
|
+
placeholder: 'Введите ваше имя 2',
|
|
46
|
+
defaultValue: 'Мое имя',
|
|
47
|
+
validation: ['required'],
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
id: 'username_3',
|
|
51
|
+
name: 'username_3',
|
|
52
|
+
label: 'Имя 3 пользователя',
|
|
53
|
+
type: 'text',
|
|
54
|
+
placeholder: 'Введите ваше имя 2',
|
|
55
|
+
defaultValue: 'Мое имя',
|
|
56
|
+
validation: ['required'],
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
id: 'username_4',
|
|
60
|
+
name: 'username_4',
|
|
61
|
+
label: 'Имя 4 пользователя',
|
|
62
|
+
type: 'text',
|
|
63
|
+
placeholder: 'Введите ваше имя 2',
|
|
64
|
+
defaultValue: 'Мое имя',
|
|
65
|
+
validation: ['required'],
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
const ref = useRef<FormBuilderRef>(null);
|
|
72
|
+
return <div>
|
|
73
|
+
<FormBuilder ref={ref} layout={layout} plugins={plugins} formData={{
|
|
74
|
+
personal: {
|
|
75
|
+
username_2: "Alex"
|
|
76
|
+
}
|
|
77
|
+
}}></FormBuilder>
|
|
78
|
+
<button onClick={() => {
|
|
79
|
+
ref.current?.reset();
|
|
80
|
+
}}>Сбросить
|
|
81
|
+
</button>
|
|
82
|
+
<button onClick={() => {
|
|
83
|
+
ref.current?.submit();
|
|
84
|
+
}}>Отправить
|
|
85
|
+
</button>
|
|
86
|
+
</div>
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export default Debug
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type {FormBuilderRef, TFormBuilder} from "../shared/types/common";
|
|
4
|
+
import {StoreContext, storeReducer,} from "../shared/model/store";
|
|
5
|
+
import {DynamicBuilder} from "../widgets/dynamicBuilder";
|
|
6
|
+
import { forwardRef, useImperativeHandle, useMemo, useReducer } from "react";
|
|
7
|
+
import {ValidationCore} from "../shared/lib/validation/core";
|
|
8
|
+
import { useUpdateEffect } from '../shared/hook/useUpdateEffect.tsx';
|
|
9
|
+
|
|
10
|
+
export const FormBuilder = forwardRef<FormBuilderRef, TFormBuilder>((props, ref) => {
|
|
11
|
+
|
|
12
|
+
const validator = useMemo(() => {
|
|
13
|
+
return (new ValidationCore(props.layout, props.plugins))
|
|
14
|
+
}, [props.layout, props.plugins]);
|
|
15
|
+
|
|
16
|
+
const [state, dispatch] = useReducer(storeReducer, {
|
|
17
|
+
formData: props.formData ?? {},
|
|
18
|
+
errors: {},
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
useUpdateEffect(() => {
|
|
22
|
+
if (props.onChange) {
|
|
23
|
+
props.onChange(state.formData);
|
|
24
|
+
}
|
|
25
|
+
}, [state.formData, props.onChange]);
|
|
26
|
+
|
|
27
|
+
const submit = () => {
|
|
28
|
+
const errors = validator.validate(state.formData);
|
|
29
|
+
if (Object.keys(errors).length == 0) {
|
|
30
|
+
if (props.onSubmit) {
|
|
31
|
+
props.onSubmit(state.formData)
|
|
32
|
+
}
|
|
33
|
+
} else {
|
|
34
|
+
dispatch({
|
|
35
|
+
type: 'setErrors',
|
|
36
|
+
payload: {
|
|
37
|
+
errors
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
useImperativeHandle(ref, () => ({
|
|
44
|
+
reset: () => {
|
|
45
|
+
dispatch({type: 'reset'})
|
|
46
|
+
},
|
|
47
|
+
submit: () => {
|
|
48
|
+
submit();
|
|
49
|
+
},
|
|
50
|
+
errors: () => {
|
|
51
|
+
return state?.errors ?? {};
|
|
52
|
+
}
|
|
53
|
+
}), [state, props.onSubmit]);
|
|
54
|
+
|
|
55
|
+
return <form onSubmit={(e) => {
|
|
56
|
+
e.preventDefault();
|
|
57
|
+
submit();
|
|
58
|
+
}}
|
|
59
|
+
className={props?.className}
|
|
60
|
+
>
|
|
61
|
+
<StoreContext.Provider value={{state, dispatch}}>
|
|
62
|
+
<DynamicBuilder layout={props.layout} plugins={props.plugins} />
|
|
63
|
+
</StoreContext.Provider>
|
|
64
|
+
<input type="submit" style={{ display: 'none' }}/>
|
|
65
|
+
{props.children}
|
|
66
|
+
</form>
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
export default FormBuilder;
|
package/src/app/test.css
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
@import "tailwindcss";
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import FormBuilder from "./app";
|
|
2
|
+
import type { IRule, TGroupRules, FormFieldConfig, FormElementProps, FormElementRegistry, FormBuilderRef} from "./shared/types/common.ts";
|
|
3
|
+
export {FormGroup, TextField} from "./widgets/form";
|
|
4
|
+
export type {IRule, TGroupRules, FormFieldConfig, FormElementProps, FormElementRegistry, FormBuilderRef};
|
|
5
|
+
export { FormBuilder };
|
|
6
|
+
export { fieldShema } from './shared/model';
|
package/src/main.tsx
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useEffect,
|
|
3
|
+
useRef,
|
|
4
|
+
type DependencyList,
|
|
5
|
+
type EffectCallback,
|
|
6
|
+
} from 'react';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Хук как useEffect, но не выполняется на первом рендере.
|
|
10
|
+
* Выполняется только на обновлениях, когда меняются зависимости.
|
|
11
|
+
*/
|
|
12
|
+
export const useUpdateEffect = (effect: EffectCallback, deps: DependencyList): void => {
|
|
13
|
+
const isFirstRenderRef = useRef(true);
|
|
14
|
+
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
if (isFirstRenderRef.current) {
|
|
17
|
+
isFirstRenderRef.current = false;
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return effect();
|
|
22
|
+
}, deps);
|
|
23
|
+
};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import {expect, test} from "vitest";
|
|
2
|
+
import {VisibleCore} from "./VisibleCore.ts";
|
|
3
|
+
import type { TGroupRules } from "../../index.ts";
|
|
4
|
+
|
|
5
|
+
test.each([
|
|
6
|
+
{
|
|
7
|
+
operator: 'logic: and, operator: in',
|
|
8
|
+
setting: {
|
|
9
|
+
logic: 'and',
|
|
10
|
+
rules: [
|
|
11
|
+
{
|
|
12
|
+
field: 'test',
|
|
13
|
+
operator: 'in',
|
|
14
|
+
value: ['Alex']
|
|
15
|
+
},
|
|
16
|
+
]
|
|
17
|
+
} as TGroupRules,
|
|
18
|
+
formData: {},
|
|
19
|
+
result: false,
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
operator: 'logic: and, operator: in',
|
|
23
|
+
setting: {
|
|
24
|
+
logic: 'and',
|
|
25
|
+
rules: [
|
|
26
|
+
{
|
|
27
|
+
field: 'test',
|
|
28
|
+
operator: 'in',
|
|
29
|
+
value: ['Alex', 'Habr']
|
|
30
|
+
},
|
|
31
|
+
]
|
|
32
|
+
} as TGroupRules,
|
|
33
|
+
formData: {test: 'Alex'},
|
|
34
|
+
result: true,
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
operator: 'logic: and, operator =',
|
|
38
|
+
setting: {
|
|
39
|
+
logic: 'and',
|
|
40
|
+
rules: [
|
|
41
|
+
{
|
|
42
|
+
field: 'test',
|
|
43
|
+
operator: '=',
|
|
44
|
+
value: "habr"
|
|
45
|
+
},
|
|
46
|
+
]
|
|
47
|
+
} as TGroupRules,
|
|
48
|
+
formData: {test: 'habr'},
|
|
49
|
+
result: true,
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
operator: 'logic: or, operator =',
|
|
53
|
+
setting: {
|
|
54
|
+
logic: 'or',
|
|
55
|
+
rules: [
|
|
56
|
+
{
|
|
57
|
+
field: 'test',
|
|
58
|
+
operator: '=',
|
|
59
|
+
value: "habr"
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
field: 'operator',
|
|
63
|
+
operator: '=',
|
|
64
|
+
value: "habr"
|
|
65
|
+
},
|
|
66
|
+
]
|
|
67
|
+
} as TGroupRules,
|
|
68
|
+
formData: {operator: 'habr'},
|
|
69
|
+
result: true,
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
operator: 'logic: or, deep rules, operator =',
|
|
73
|
+
setting: {
|
|
74
|
+
logic: 'or',
|
|
75
|
+
rules: [
|
|
76
|
+
{
|
|
77
|
+
field: 'test',
|
|
78
|
+
operator: '=',
|
|
79
|
+
value: "habr"
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
logic: 'and',
|
|
83
|
+
rules: [
|
|
84
|
+
{
|
|
85
|
+
field: 'test',
|
|
86
|
+
operator: '=',
|
|
87
|
+
value: "1"
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
field: 'test2',
|
|
91
|
+
operator: '=',
|
|
92
|
+
value: "2"
|
|
93
|
+
}
|
|
94
|
+
]
|
|
95
|
+
},
|
|
96
|
+
]
|
|
97
|
+
} as TGroupRules,
|
|
98
|
+
formData: {test: '1', test2: '2'},
|
|
99
|
+
result: true,
|
|
100
|
+
},
|
|
101
|
+
])('is visible for operation $operator with data $formData', ({setting, formData, result}) => {
|
|
102
|
+
expect(VisibleCore.isVisible(setting, formData)).toBe(result)
|
|
103
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type {TCommonRule, TGroupRules, FormData, TInRule, TEqualRule} from "../types/common";
|
|
2
|
+
import {getNestedValue} from "../utils";
|
|
3
|
+
|
|
4
|
+
export class VisibleCore {
|
|
5
|
+
|
|
6
|
+
private static isGroupRule(i: TGroupRules | TCommonRule): i is TGroupRules {
|
|
7
|
+
return 'rules' in i;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
private static isInOperator(i: TCommonRule): i is TInRule {
|
|
11
|
+
return i.operator == 'in' ;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
private static isEqualOperand(i: TCommonRule): i is TEqualRule {
|
|
15
|
+
return i.operator == '=' ;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
static checkGroup(groupRule: TGroupRules, formData: FormData): boolean {
|
|
19
|
+
const logic = groupRule.logic;
|
|
20
|
+
const items: boolean[] = [];
|
|
21
|
+
groupRule.rules.forEach((rule: TGroupRules | TCommonRule) => {
|
|
22
|
+
if (this.isGroupRule(rule)) {
|
|
23
|
+
items.push(this.checkGroup(rule, formData));
|
|
24
|
+
} else {
|
|
25
|
+
const value = getNestedValue(formData, rule.field.split('.'))
|
|
26
|
+
if (this.isInOperator(rule)) {
|
|
27
|
+
items.push(rule.value.indexOf(value) !== -1);
|
|
28
|
+
} else if (this.isEqualOperand(rule)) {
|
|
29
|
+
items.push(rule.value == value);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
if (logic == 'or') {
|
|
34
|
+
return items.indexOf(true) != -1
|
|
35
|
+
} else {
|
|
36
|
+
return items.indexOf(false) == -1;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
static isVisible(setting: TGroupRules, formData: FormData): boolean {
|
|
41
|
+
return this.checkGroup(setting, formData);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import {expect, test} from "vitest";
|
|
2
|
+
import {ValidationCore} from "./core.ts";
|
|
3
|
+
import {FormGroup, TextField} from "../../../widgets/form";
|
|
4
|
+
|
|
5
|
+
import type {FormElementRegistry, FormFieldConfig} from "../../../index.ts";
|
|
6
|
+
import {RequireRule} from "./rules/require.ts";
|
|
7
|
+
|
|
8
|
+
const testFormConfig: FormFieldConfig[] = [{
|
|
9
|
+
id: 'test',
|
|
10
|
+
name: 'test',
|
|
11
|
+
type: 'text',
|
|
12
|
+
label: '',
|
|
13
|
+
}];
|
|
14
|
+
|
|
15
|
+
const testFormPlugins: FormElementRegistry = {
|
|
16
|
+
text: TextField,
|
|
17
|
+
group: FormGroup,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
test('Validation core empty data with required has error', () => {
|
|
21
|
+
testFormConfig[0].validation = ['required'];
|
|
22
|
+
const validationCode = new ValidationCore(testFormConfig, testFormPlugins);
|
|
23
|
+
expect(validationCode.validate({})).not.toBe({})
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('Validation core has data not errors', () => {
|
|
27
|
+
testFormConfig[0].validation = ['required'];
|
|
28
|
+
const validationCode = new ValidationCore(testFormConfig, testFormPlugins);
|
|
29
|
+
expect(validationCode.validate({test: 'Alex'})).toEqual({});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('Validation core: rule as class', () => {
|
|
33
|
+
testFormConfig[0].validation = [new RequireRule()];
|
|
34
|
+
const validationCode = new ValidationCore(testFormConfig, testFormPlugins);
|
|
35
|
+
expect(validationCode.validate({test: 'Alex'})).toEqual({});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('Validation core: rule as string with params', () => {
|
|
39
|
+
testFormConfig[0].validation = ['confirm:confirm'];
|
|
40
|
+
const validationCode = new ValidationCore(testFormConfig, testFormPlugins);
|
|
41
|
+
expect(validationCode.validate({test: 'Alex', confirm: 'Alex'})).toEqual({});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('Validation core: rule as string with params get correct message', () => {
|
|
45
|
+
testFormConfig[0].validation = ['required', 'email', 'confirm:confirm,подтверждением'];
|
|
46
|
+
const validationCode = new ValidationCore(testFormConfig, testFormPlugins);
|
|
47
|
+
expect(validationCode.validate({test: 1})).toStrictEqual({
|
|
48
|
+
"test": {
|
|
49
|
+
"required": "Ошибка валидации поля",
|
|
50
|
+
"email": "Ошибка валидации поля",
|
|
51
|
+
"confirm": "Поле не совпадает с подтверждением",
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
test('Validation core: check empty data', () => {
|
|
58
|
+
testFormConfig[0].validation = ['required', 'email'];
|
|
59
|
+
const validationCode = new ValidationCore(testFormConfig, testFormPlugins);
|
|
60
|
+
expect(validationCode.validate({})).toStrictEqual({
|
|
61
|
+
"test": {
|
|
62
|
+
"required": "Ошибка валидации поля",
|
|
63
|
+
"email": "Ошибка валидации поля",
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('Validation core: check in deep data group', () => {
|
|
69
|
+
const config: FormFieldConfig[] = [{
|
|
70
|
+
id: 'group',
|
|
71
|
+
name: 'group',
|
|
72
|
+
type: 'group',
|
|
73
|
+
label: '',
|
|
74
|
+
fields: [
|
|
75
|
+
{
|
|
76
|
+
id: 'personal',
|
|
77
|
+
name: 'personal',
|
|
78
|
+
type: 'group',
|
|
79
|
+
label: '',
|
|
80
|
+
fields: [
|
|
81
|
+
{
|
|
82
|
+
id: 'test',
|
|
83
|
+
name: 'test',
|
|
84
|
+
type: 'text',
|
|
85
|
+
label: '',
|
|
86
|
+
validation: ['required', 'email']
|
|
87
|
+
}
|
|
88
|
+
]
|
|
89
|
+
}
|
|
90
|
+
]
|
|
91
|
+
}];
|
|
92
|
+
const validationCode = new ValidationCore(config, testFormPlugins);
|
|
93
|
+
expect(validationCode.validate({})).toStrictEqual({
|
|
94
|
+
"group": {
|
|
95
|
+
"personal": {
|
|
96
|
+
"test": {
|
|
97
|
+
"required": "Ошибка валидации поля",
|
|
98
|
+
"email": "Ошибка валидации поля",
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type {FormData, FormElementRegistry, FormFieldConfig, IRule} from "../../types/common";
|
|
2
|
+
import {getNestedValue, updateNestedValue} from "../../utils";
|
|
3
|
+
import {RequireRule} from "./rules/require";
|
|
4
|
+
import {EmailRule} from "./rules/email";
|
|
5
|
+
import {ConfirmRule} from "./rules/confirm";
|
|
6
|
+
|
|
7
|
+
export class ValidationCore {
|
|
8
|
+
private readonly layout: FormFieldConfig[];
|
|
9
|
+
private readonly plugins: FormData;
|
|
10
|
+
constructor(layout: FormFieldConfig[], plugins: FormElementRegistry) {
|
|
11
|
+
this.layout = layout;
|
|
12
|
+
this.plugins = plugins
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
private validationIsConfig(i: any): i is FormFieldConfig[] {
|
|
16
|
+
return i && Array.isArray(i);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
private checkRules(layout: FormFieldConfig[], data: FormData, path: string[] = [], errors = {}) {
|
|
20
|
+
layout.map((i) => {
|
|
21
|
+
const element = this.plugins[i.type];
|
|
22
|
+
const currentPath = [...path, i.id];
|
|
23
|
+
|
|
24
|
+
if (element && element.fieldProps) {
|
|
25
|
+
element.fieldProps.forEach((fieldProps: string) => {
|
|
26
|
+
if (this.validationIsConfig(i[fieldProps])) {
|
|
27
|
+
errors = this.checkRules(i[fieldProps], data, currentPath, errors);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (i.validation && Array.isArray(i.validation)) {
|
|
33
|
+
i.validation.forEach(validation => {
|
|
34
|
+
let validationResult: boolean | string = true;
|
|
35
|
+
let ruleName: string;
|
|
36
|
+
|
|
37
|
+
const value = getNestedValue(data, currentPath);
|
|
38
|
+
|
|
39
|
+
let validationClass: IRule|null = null;
|
|
40
|
+
if (typeof validation === "string") {
|
|
41
|
+
const [validator, rawParams] = validation.split(':');
|
|
42
|
+
const params: string[] = (rawParams ?? '').split(',');
|
|
43
|
+
switch (validator) {
|
|
44
|
+
case "required":
|
|
45
|
+
validationClass = new RequireRule(...params);
|
|
46
|
+
break;
|
|
47
|
+
case "email":
|
|
48
|
+
validationClass = new EmailRule(...params);
|
|
49
|
+
break;
|
|
50
|
+
case "confirm":
|
|
51
|
+
validationClass = new ConfirmRule(...params);
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
} else {
|
|
55
|
+
validationClass = validation;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (validationClass) {
|
|
59
|
+
ruleName = validationClass.getName(),
|
|
60
|
+
validationResult = validationClass.validate(value, data);
|
|
61
|
+
} else {
|
|
62
|
+
ruleName = '';
|
|
63
|
+
validationResult = true;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (validationClass && validationResult !== true) {
|
|
67
|
+
errors = updateNestedValue(errors, [...currentPath, ruleName], validationClass.message())
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
return errors;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
validate(data: FormData) {
|
|
77
|
+
return this.checkRules(this.layout, data);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import {expect, test} from 'vitest'
|
|
2
|
+
import {ConfirmRule} from "./confirm.ts"
|
|
3
|
+
test('Confirm rule. Empty data', () => {
|
|
4
|
+
const rule = new ConfirmRule('personal');
|
|
5
|
+
expect(rule.validate('test', {})).toBe(false);
|
|
6
|
+
})
|
|
7
|
+
|
|
8
|
+
test('Confirm rule. Success data', () => {
|
|
9
|
+
const rule = new ConfirmRule('personal');
|
|
10
|
+
expect(rule.validate('test', {personal: 'test'})).toBe(true);
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
test('Confirm rule. Set error message', () => {
|
|
14
|
+
const rule = new ConfirmRule('personal', 'Personal');
|
|
15
|
+
const validator = rule.validate('test', {personal2: 'test'});
|
|
16
|
+
expect(!validator ? rule.message() : '').toBe('Поле не совпадает с Personal');
|
|
17
|
+
})
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type {IRule, FormData} from "../../../types/common";
|
|
2
|
+
import {BaseRule} from "./base";
|
|
3
|
+
import {getNestedValue} from "../../../utils";
|
|
4
|
+
|
|
5
|
+
export class ConfirmRule extends BaseRule implements IRule {
|
|
6
|
+
protected confirmField?: string;
|
|
7
|
+
protected confirmLabel?: string;
|
|
8
|
+
constructor(...args: string[] ) {
|
|
9
|
+
super(...args);
|
|
10
|
+
const confirmField = args.shift();
|
|
11
|
+
const confirmLabel = args.shift();
|
|
12
|
+
|
|
13
|
+
if (confirmField) {
|
|
14
|
+
this.confirmField = confirmField;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (confirmLabel) {
|
|
18
|
+
this.confirmLabel = confirmLabel;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
getName(): string {
|
|
22
|
+
return "confirm";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
validate(value: string, formData: FormData): boolean {
|
|
26
|
+
return this.confirmField ? value == getNestedValue(formData, this.confirmField.split('.')) : false;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
message(): string {
|
|
30
|
+
return `Поле не совпадает с ${this.confirmLabel ?? this.confirmField}`;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import {expect, test} from 'vitest'
|
|
2
|
+
import {EmailRule} from "./email.ts";
|
|
3
|
+
|
|
4
|
+
const rule = new EmailRule();
|
|
5
|
+
test.each([
|
|
6
|
+
['test@test.ru', true],
|
|
7
|
+
['123123123', false],
|
|
8
|
+
['', false],
|
|
9
|
+
['123123123@', false],
|
|
10
|
+
])('Email validate %s -> %d', ($email, $result) => {
|
|
11
|
+
expect(rule.validate($email)).toBe($result);
|
|
12
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type {IRule} from "../../../types/common";
|
|
2
|
+
import {BaseRule} from "./base";
|
|
3
|
+
|
|
4
|
+
export class EmailRule extends BaseRule implements IRule {
|
|
5
|
+
private emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
|
|
6
|
+
getName(): string {
|
|
7
|
+
return "email";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
validate(value: string): boolean {
|
|
11
|
+
return this.emailRegex.test(value);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import {expect, test} from 'vitest'
|
|
2
|
+
import {RequireRule} from "./require.ts";
|
|
3
|
+
|
|
4
|
+
const rule = new RequireRule();
|
|
5
|
+
|
|
6
|
+
test.each([
|
|
7
|
+
['1123123', true],
|
|
8
|
+
['sdfsdfsdf', true],
|
|
9
|
+
['', false],
|
|
10
|
+
[null, false],
|
|
11
|
+
])('Required validate %s -> %d', ($data, $result) => {
|
|
12
|
+
expect(rule.validate($data as string)).toBe($result);
|
|
13
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type {IRule} from "../../../types/common";
|
|
2
|
+
import {BaseRule} from "./base";
|
|
3
|
+
|
|
4
|
+
export class RequireRule extends BaseRule implements IRule{
|
|
5
|
+
getName(): string {
|
|
6
|
+
return "required";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
validate(value: string | null | undefined): boolean {
|
|
10
|
+
return value !== undefined && value !== null && value.length > 0;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
const fieldShema = z.object({
|
|
4
|
+
id: z.string(),
|
|
5
|
+
name: z.string(),
|
|
6
|
+
label: z.string().optional(),
|
|
7
|
+
type: z.string(),
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
type TField = z.infer<typeof fieldShema>;
|
|
11
|
+
|
|
12
|
+
export type { TField };
|
|
13
|
+
export { fieldShema };
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type {FormData} from "../types/common";
|
|
2
|
+
import {
|
|
3
|
+
type ActionDispatch,
|
|
4
|
+
createContext,
|
|
5
|
+
type SetStateAction,
|
|
6
|
+
useContext,
|
|
7
|
+
} from "react";
|
|
8
|
+
import {updateNestedValue} from "../utils";
|
|
9
|
+
|
|
10
|
+
const initState: {
|
|
11
|
+
formData: FormData
|
|
12
|
+
errors: Record<string, any>
|
|
13
|
+
} = {
|
|
14
|
+
formData: {},
|
|
15
|
+
errors: {}
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const StoreContext = createContext<{
|
|
19
|
+
state: typeof initState;
|
|
20
|
+
dispatch: ActionDispatch<SetStateAction<any>>;
|
|
21
|
+
}>(null!);
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
export function storeReducer(state: any, action: SetStateAction<any>): any {
|
|
25
|
+
const newData = {...state};
|
|
26
|
+
|
|
27
|
+
switch (action.type){
|
|
28
|
+
case 'setValue':
|
|
29
|
+
newData.formData = updateNestedValue(newData.formData, action.payload.path, action.payload.value);
|
|
30
|
+
break;
|
|
31
|
+
case 'setError':
|
|
32
|
+
newData.errors = updateNestedValue(newData.errors, action.payload.path, action.payload.value);
|
|
33
|
+
break;
|
|
34
|
+
case 'reset':
|
|
35
|
+
newData.formData = {};
|
|
36
|
+
newData.errors = {};
|
|
37
|
+
break;
|
|
38
|
+
case 'setErrors':
|
|
39
|
+
newData.errors = {...action.payload.errors};
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return newData;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const useStore = () => {
|
|
47
|
+
const context = useContext(StoreContext);
|
|
48
|
+
if (context === undefined) {
|
|
49
|
+
throw new Error('useStore must be used within a StoreProvider');
|
|
50
|
+
}
|
|
51
|
+
return context;
|
|
52
|
+
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import {type FC, type ReactNode} from "react";
|
|
2
|
+
import type { TField } from '../model';
|
|
3
|
+
|
|
4
|
+
export type ConfigFunctionComponent<P> = FC<P> & {
|
|
5
|
+
fieldProps?: string[];
|
|
6
|
+
}
|
|
7
|
+
export type RC<T = {}> = ConfigFunctionComponent<T & { className?: string, children?: ReactNode, }>;
|
|
8
|
+
export type FormData = Record<string, any>;
|
|
9
|
+
|
|
10
|
+
export interface IRule {
|
|
11
|
+
getName: () => string;
|
|
12
|
+
validate: (value: string, formData: FormData) => boolean;
|
|
13
|
+
message: () => string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type FormFieldBase = {
|
|
17
|
+
validation?: (string | IRule)[];
|
|
18
|
+
viewConfig?: TGroupRules
|
|
19
|
+
} & TField;
|
|
20
|
+
|
|
21
|
+
export type FormFieldConfig = FormFieldBase & Record<string, any>;
|
|
22
|
+
|
|
23
|
+
export type FormElementProps<F extends FormFieldConfig = FormFieldConfig> = {
|
|
24
|
+
field: F;
|
|
25
|
+
builder: TDynamicBuilder,
|
|
26
|
+
plugins: FormElementRegistry,
|
|
27
|
+
path: string[];
|
|
28
|
+
value: any;
|
|
29
|
+
errors?: Record<string, string>;
|
|
30
|
+
onChange: (value: any) => void;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type FormElementComponent<F extends FormFieldConfig = FormFieldConfig> = RC<FormElementProps<F>>;
|
|
34
|
+
export type FormElementRegistry = Record<string, FormElementComponent<any>>;
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
export type TFormBuilder = {
|
|
38
|
+
formData?: FormData,
|
|
39
|
+
className?: string,
|
|
40
|
+
layout: FormFieldConfig[],
|
|
41
|
+
plugins: FormElementRegistry,
|
|
42
|
+
onChange?: (formData: any) => void,
|
|
43
|
+
onSubmit?: (formData: any) => void,
|
|
44
|
+
children?: ReactNode,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type TDynamicBuilder = RC<{
|
|
48
|
+
layout: FormFieldConfig[],
|
|
49
|
+
plugins: FormElementRegistry,
|
|
50
|
+
path?: string[],
|
|
51
|
+
}>;
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
export type TInRule = {
|
|
55
|
+
operator: 'in',
|
|
56
|
+
value: (string | number | boolean)[]
|
|
57
|
+
} & TCommonRule
|
|
58
|
+
|
|
59
|
+
export type TEqualRule = {
|
|
60
|
+
operator: '=',
|
|
61
|
+
value: string | number | boolean
|
|
62
|
+
} & TCommonRule
|
|
63
|
+
|
|
64
|
+
export type TCommonRule = {
|
|
65
|
+
operator: string,
|
|
66
|
+
field: string,
|
|
67
|
+
value: any,
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export type TGroupRules = {
|
|
71
|
+
logic: 'and' | 'or',
|
|
72
|
+
rules: (TGroupRules | TCommonRule)[]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export type FormBuilderRef = {
|
|
76
|
+
reset: () => void;
|
|
77
|
+
submit: () => void;
|
|
78
|
+
errors: () => Record<string, any>
|
|
79
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export function updateNestedValue(obj: any, path: string[], value: any): any {
|
|
2
|
+
if (path.length === 0) {
|
|
3
|
+
return value;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const newObj = Array.isArray(obj) ? [...obj] : { ...obj }; // Создаем копию
|
|
7
|
+
const currentKey = path[0];
|
|
8
|
+
|
|
9
|
+
if (path.length === 1) {
|
|
10
|
+
// Последний элемент пути, просто обновляем значение
|
|
11
|
+
newObj[currentKey] = value;
|
|
12
|
+
} else {
|
|
13
|
+
const remainingPath = path.slice(1);
|
|
14
|
+
const nestedObj = newObj[currentKey];
|
|
15
|
+
|
|
16
|
+
if (typeof nestedObj === 'undefined' || nestedObj === null) {
|
|
17
|
+
newObj[currentKey] = typeof remainingPath[0] === 'number' ? [] : {};
|
|
18
|
+
}
|
|
19
|
+
newObj[currentKey] = updateNestedValue(newObj[currentKey], remainingPath, value);
|
|
20
|
+
}
|
|
21
|
+
return newObj;
|
|
22
|
+
}
|
|
23
|
+
export function getNestedValue(obj: any, path: string[]): any {
|
|
24
|
+
return path.reduce((acc, key) => (acc && acc[key] !== undefined ? acc[key] : undefined), obj);
|
|
25
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type {FormFieldConfig, TDynamicBuilder} from "../../shared/types/common";
|
|
2
|
+
import {getNestedValue} from "../../shared/utils";
|
|
3
|
+
import {useCallback} from "react";
|
|
4
|
+
import {VisibleCore} from "../../shared/lib/VisibleCore";
|
|
5
|
+
import {useStore} from "../../shared/model/store";
|
|
6
|
+
|
|
7
|
+
export const DynamicBuilder: TDynamicBuilder = (props) => {
|
|
8
|
+
const { state, dispatch } = useStore();
|
|
9
|
+
const path = props.path ?? [];
|
|
10
|
+
|
|
11
|
+
const isShow = useCallback((field: FormFieldConfig) => {
|
|
12
|
+
let result = true;
|
|
13
|
+
|
|
14
|
+
if (field.viewConfig) {
|
|
15
|
+
result = VisibleCore.isVisible(field.viewConfig, state.formData);
|
|
16
|
+
}
|
|
17
|
+
return result;
|
|
18
|
+
}, [state]);
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
return props.layout.map((field) => {
|
|
22
|
+
const FormElement = props.plugins[field.type];
|
|
23
|
+
|
|
24
|
+
if (!FormElement) {
|
|
25
|
+
console.warn(`Неизвестный тип поля: ${field.type}. Проверьте formRegistry.`);
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const currentFieldPath = [...path, field.id];
|
|
30
|
+
|
|
31
|
+
const currentValue = getNestedValue(state.formData, currentFieldPath);
|
|
32
|
+
const currentErrors = getNestedValue(state.errors, currentFieldPath) as (Record<string, string> | undefined);
|
|
33
|
+
|
|
34
|
+
return isShow(field) ? (
|
|
35
|
+
<FormElement
|
|
36
|
+
key={field.id}
|
|
37
|
+
field={{...field}}
|
|
38
|
+
builder={DynamicBuilder}
|
|
39
|
+
path={currentFieldPath}
|
|
40
|
+
plugins={props.plugins}
|
|
41
|
+
value={currentValue}
|
|
42
|
+
errors={currentErrors}
|
|
43
|
+
onChange={(value: any) => {
|
|
44
|
+
dispatch({
|
|
45
|
+
type: 'setValue',
|
|
46
|
+
payload: {
|
|
47
|
+
path: currentFieldPath,
|
|
48
|
+
value
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
dispatch({
|
|
53
|
+
type: 'setError',
|
|
54
|
+
payload: {
|
|
55
|
+
path: currentFieldPath,
|
|
56
|
+
undefined
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
}}
|
|
60
|
+
/>
|
|
61
|
+
) : null;
|
|
62
|
+
})
|
|
63
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type {FormElementProps, FormFieldBase, FormFieldConfig, RC} from "../../../../shared/types/common.ts";
|
|
2
|
+
import clsx from "clsx";
|
|
3
|
+
export type FormGroupConfig = FormFieldBase & { variant?: 'row' | 'col', fields: FormFieldConfig[] };
|
|
4
|
+
|
|
5
|
+
function isGroupConfig(field: FormFieldConfig): field is FormGroupConfig {
|
|
6
|
+
return field.fields;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
export const FormGroup: RC<FormElementProps<FormGroupConfig>> = ({field, path, builder, plugins}) => {
|
|
11
|
+
if (!isGroupConfig(field)) {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
const variant = field.variant ?? 'col';
|
|
15
|
+
const Builder = builder;
|
|
16
|
+
|
|
17
|
+
const className = variant == 'col' ? 'flex-col' : 'flex-row';
|
|
18
|
+
|
|
19
|
+
return <div>
|
|
20
|
+
<div>{field.label}</div>
|
|
21
|
+
<div className={clsx(className, 'flex')}>
|
|
22
|
+
<Builder layout={field.fields} plugins={plugins} path={path}/>
|
|
23
|
+
</div>
|
|
24
|
+
</div>;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
FormGroup.fieldProps = ['fields'];
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type {FormElementProps, FormFieldConfig, FormFieldBase, RC} from '../../../../shared/types/common';
|
|
2
|
+
|
|
3
|
+
export type TextFieldConfig = FormFieldBase & { type: 'text' | 'email' | 'password'; placeholder?: string; };
|
|
4
|
+
|
|
5
|
+
function isTextFieldConfig(field: FormFieldConfig): field is TextFieldConfig {
|
|
6
|
+
return field.type === 'text' || field.type === 'email' || field.type === 'password';
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const TextField: RC<FormElementProps> = ({ field, value, errors, onChange }) => {
|
|
10
|
+
if (!isTextFieldConfig(field)) {
|
|
11
|
+
console.warn(`TextField received an invalid field config for type: ${field.type}`);
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
return (
|
|
15
|
+
<div style={{ marginBottom: '15px' }}>
|
|
16
|
+
<label htmlFor={field.id}>{field.label}:</label>
|
|
17
|
+
<input
|
|
18
|
+
type={field.type}
|
|
19
|
+
id={field.id}
|
|
20
|
+
name={field.name}
|
|
21
|
+
placeholder={field.placeholder}
|
|
22
|
+
value={value || ''}
|
|
23
|
+
onChange={(e) => onChange(e.target.value)}
|
|
24
|
+
style={{ borderColor: errors ? 'red' : '#ccc' }}
|
|
25
|
+
/>
|
|
26
|
+
{errors && <p style={{ color: 'red', fontSize: '0.8em' }}>{Object.values(errors).join(', ')}</p>}
|
|
27
|
+
</div>
|
|
28
|
+
);
|
|
29
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"useDefineForClassFields": true,
|
|
5
|
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
6
|
+
"module": "ESNext",
|
|
7
|
+
"skipLibCheck": true,
|
|
8
|
+
|
|
9
|
+
/* Bundler mode */
|
|
10
|
+
"moduleResolution": "bundler",
|
|
11
|
+
"allowImportingTsExtensions": true,
|
|
12
|
+
"verbatimModuleSyntax": true,
|
|
13
|
+
"moduleDetection": "force",
|
|
14
|
+
"noEmit": true,
|
|
15
|
+
"jsx": "react-jsx",
|
|
16
|
+
|
|
17
|
+
/* Linting */
|
|
18
|
+
"strict": true,
|
|
19
|
+
"noUnusedLocals": true,
|
|
20
|
+
"noUnusedParameters": true,
|
|
21
|
+
"erasableSyntaxOnly": true,
|
|
22
|
+
"noFallthroughCasesInSwitch": true,
|
|
23
|
+
"noUncheckedSideEffectImports": true
|
|
24
|
+
},
|
|
25
|
+
"include": ["src"]
|
|
26
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2023",
|
|
4
|
+
"lib": ["ES2023"],
|
|
5
|
+
"module": "ESNext",
|
|
6
|
+
"skipLibCheck": true,
|
|
7
|
+
|
|
8
|
+
/* Bundler mode */
|
|
9
|
+
"moduleResolution": "bundler",
|
|
10
|
+
"allowImportingTsExtensions": true,
|
|
11
|
+
"verbatimModuleSyntax": true,
|
|
12
|
+
"moduleDetection": "force",
|
|
13
|
+
"noEmit": true,
|
|
14
|
+
|
|
15
|
+
/* Linting */
|
|
16
|
+
"strict": true,
|
|
17
|
+
"noUnusedLocals": true,
|
|
18
|
+
"noUnusedParameters": true,
|
|
19
|
+
"erasableSyntaxOnly": true,
|
|
20
|
+
"noFallthroughCasesInSwitch": true,
|
|
21
|
+
"noUncheckedSideEffectImports": true
|
|
22
|
+
},
|
|
23
|
+
"include": ["vite.config.ts"]
|
|
24
|
+
}
|
package/vite-env.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
package/vite.config.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { defineConfig } from 'vite'
|
|
2
|
+
import react from '@vitejs/plugin-react-swc'
|
|
3
|
+
import tailwindcss from "@tailwindcss/vite";
|
|
4
|
+
import dts from 'vite-plugin-dts';
|
|
5
|
+
|
|
6
|
+
export default defineConfig({
|
|
7
|
+
plugins: [react(), tailwindcss(), dts({ rollupTypes: true, tsconfigPath: './tsconfig.app.json' })],
|
|
8
|
+
build: {
|
|
9
|
+
lib: {
|
|
10
|
+
entry: "./src/index.ts",
|
|
11
|
+
name: 'index',
|
|
12
|
+
fileName: "index",
|
|
13
|
+
formats: ['es']
|
|
14
|
+
},
|
|
15
|
+
copyPublicDir: false,
|
|
16
|
+
rollupOptions: {
|
|
17
|
+
external: ['react', 'react/jsx-runtime'],
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
})
|