@idem.agency/form-builder 0.0.12 → 0.0.13
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/package.json +5 -2
- package/CHANGELOG.md +0 -16
- package/eslint.config.js +0 -23
- package/public/index.html +0 -13
- package/public/main.tsx +0 -97
- package/src/app/index.test.tsx +0 -137
- package/src/app/index.tsx +0 -34
- package/src/app/test.css +0 -1
- package/src/entity/dynamicBuilder/element.tsx +0 -71
- package/src/entity/dynamicBuilder/index.tsx +0 -21
- package/src/entity/dynamicBuilder/model/createBuilderContext.tsx +0 -52
- package/src/entity/dynamicBuilder/model/index.ts +0 -7
- package/src/entity/inputs/index.ts +0 -2
- package/src/entity/inputs/ui/group/index.test.tsx +0 -46
- package/src/entity/inputs/ui/group/index.tsx +0 -26
- package/src/entity/inputs/ui/input/index.test.tsx +0 -73
- package/src/entity/inputs/ui/input/index.tsx +0 -31
- package/src/plugins/validation/index.ts +0 -39
- package/src/plugins/validation/rules/confirm.test.ts +0 -23
- package/src/plugins/validation/rules/confirm.ts +0 -11
- package/src/plugins/validation/rules/email.test.ts +0 -20
- package/src/plugins/validation/rules/email.ts +0 -10
- package/src/plugins/validation/rules/index.ts +0 -5
- package/src/plugins/validation/rules/required.test.ts +0 -24
- package/src/plugins/validation/rules/required.ts +0 -9
- package/src/plugins/validation/types/index.ts +0 -7
- package/src/plugins/validation/validation.test.ts +0 -69
- package/src/plugins/validation/validation.ts +0 -57
- package/src/plugins/visibility/core.ts +0 -56
- package/src/plugins/visibility/index.ts +0 -25
- package/src/plugins/visibility/types.ts +0 -20
- package/src/shared/hook/useUpdateEffect.tsx +0 -23
- package/src/shared/model/index.ts +0 -12
- package/src/shared/model/plugins/EventBus.ts +0 -15
- package/src/shared/model/plugins/FieldRegistry.ts +0 -15
- package/src/shared/model/plugins/HookRegistry.ts +0 -21
- package/src/shared/model/plugins/MiddlewareRegistry.ts +0 -17
- package/src/shared/model/plugins/PipelineRegistry.ts +0 -73
- package/src/shared/model/plugins/PluginManager.ts +0 -84
- package/src/shared/model/plugins/context.tsx +0 -10
- package/src/shared/model/plugins/hooks.ts +0 -22
- package/src/shared/model/plugins/index.ts +0 -23
- package/src/shared/model/plugins/types.ts +0 -99
- package/src/shared/model/store/createStoreContext.tsx +0 -70
- package/src/shared/model/store/index.test.tsx +0 -113
- package/src/shared/model/store/index.tsx +0 -96
- package/src/shared/model/store/store.test.ts +0 -64
- package/src/shared/model/store/store.ts +0 -38
- package/src/shared/test-utils.tsx +0 -33
- package/src/shared/types/common.ts +0 -45
- package/src/shared/utils.test.ts +0 -55
- package/src/shared/utils.ts +0 -36
- package/src/widgets/form/form.tsx +0 -59
- package/tsconfig.json +0 -24
- package/tsdown.config.ts +0 -10
- package/vite.config.ts +0 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@idem.agency/form-builder",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.13",
|
|
4
4
|
"description": "Построитель форм",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"author": "idem.agency",
|
|
@@ -23,6 +23,9 @@
|
|
|
23
23
|
"react-dom": "19.2.3",
|
|
24
24
|
"zod": "4.3.6"
|
|
25
25
|
},
|
|
26
|
+
"files": [
|
|
27
|
+
"dist"
|
|
28
|
+
],
|
|
26
29
|
"devDependencies": {
|
|
27
30
|
"@eslint/js": "9.39.2",
|
|
28
31
|
"@tailwindcss/vite": "4.1.18",
|
|
@@ -43,5 +46,5 @@
|
|
|
43
46
|
"vite-tsconfig-paths": "6.0.5",
|
|
44
47
|
"vitest": "4.0.17"
|
|
45
48
|
},
|
|
46
|
-
"gitHead": "
|
|
49
|
+
"gitHead": "700ffe97c5801f382c16129af4ecb85fd476e030"
|
|
47
50
|
}
|
package/CHANGELOG.md
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
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.12](https://gitlab.idem.agency/idem-project/front-package/compare/@idem.agency/form-builder@0.0.11...@idem.agency/form-builder@0.0.12) (2026-03-16)
|
|
7
|
-
|
|
8
|
-
**Note:** Version bump only for package @idem.agency/form-builder
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
## [0.0.11](https://gitlab.idem.agency/idem-project/front-package/compare/@idem.agency/form-builder@0.0.10...@idem.agency/form-builder@0.0.11) (2026-02-09)
|
|
15
|
-
|
|
16
|
-
**Note:** Version bump only for package @idem.agency/form-builder
|
package/eslint.config.js
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
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/public/index.html
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
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
|
-
</head>
|
|
9
|
-
<body>
|
|
10
|
-
<div id="root"></div>
|
|
11
|
-
<script type="module" src="main.tsx"></script>
|
|
12
|
-
</body>
|
|
13
|
-
</html>
|
package/public/main.tsx
DELETED
|
@@ -1,97 +0,0 @@
|
|
|
1
|
-
import {StrictMode} from 'react'
|
|
2
|
-
import {createRoot} from 'react-dom/client'
|
|
3
|
-
import {plugins, FormBuilder, inputs} from "@/index";
|
|
4
|
-
import type {FormBuilderRef, FormElementRegistry, FormFieldConfig} from "@/index";
|
|
5
|
-
import {useRef} from "react";
|
|
6
|
-
|
|
7
|
-
function Debug() {
|
|
8
|
-
|
|
9
|
-
const fields: FormElementRegistry = {
|
|
10
|
-
text: inputs.TextField,
|
|
11
|
-
group: inputs.FormGroup,
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
const layout: FormFieldConfig[] = [
|
|
15
|
-
{
|
|
16
|
-
name: 'username',
|
|
17
|
-
label: 'Имя пользователя',
|
|
18
|
-
type: 'text',
|
|
19
|
-
placeholder: 'Введите ваше имя',
|
|
20
|
-
defaultValue: 'Мое имя',
|
|
21
|
-
validation: ['required', 'confirm:personal.username_2,Имя 2 пользователя'],
|
|
22
|
-
visibility: {
|
|
23
|
-
logic: 'and',
|
|
24
|
-
rules: [
|
|
25
|
-
{
|
|
26
|
-
field: 'personal.username_2',
|
|
27
|
-
operator: 'in',
|
|
28
|
-
value: ['Alex']
|
|
29
|
-
},
|
|
30
|
-
]
|
|
31
|
-
}
|
|
32
|
-
},
|
|
33
|
-
{
|
|
34
|
-
name: 'personal',
|
|
35
|
-
label: 'Группа',
|
|
36
|
-
type: 'group',
|
|
37
|
-
variant: 'col',
|
|
38
|
-
fields: [
|
|
39
|
-
{
|
|
40
|
-
name: 'username_2',
|
|
41
|
-
label: 'Имя 2 пользователя',
|
|
42
|
-
type: 'text',
|
|
43
|
-
placeholder: 'Введите ваше имя 2',
|
|
44
|
-
defaultValue: 'Мое имя',
|
|
45
|
-
validation: ['required'],
|
|
46
|
-
},
|
|
47
|
-
{
|
|
48
|
-
name: 'username_3',
|
|
49
|
-
label: 'Имя 3 пользователя',
|
|
50
|
-
type: 'text',
|
|
51
|
-
placeholder: 'Введите ваше имя 2',
|
|
52
|
-
defaultValue: 'Мое имя',
|
|
53
|
-
validation: ['required'],
|
|
54
|
-
},
|
|
55
|
-
{
|
|
56
|
-
name: 'username_4',
|
|
57
|
-
label: 'Имя 4 пользователя',
|
|
58
|
-
type: 'text',
|
|
59
|
-
placeholder: 'Введите ваше имя 2',
|
|
60
|
-
defaultValue: 'Мое имя',
|
|
61
|
-
validation: ['required'],
|
|
62
|
-
},
|
|
63
|
-
],
|
|
64
|
-
},
|
|
65
|
-
];
|
|
66
|
-
|
|
67
|
-
const ref = useRef<FormBuilderRef>(null);
|
|
68
|
-
|
|
69
|
-
const validation = plugins.createValidationPlugin();
|
|
70
|
-
const visibility = plugins.createVisibilityPlugin();
|
|
71
|
-
|
|
72
|
-
return <>
|
|
73
|
-
<FormBuilder plugins={[validation, visibility]} ref={ref} layout={layout} onChange={(e) => {
|
|
74
|
-
console.log(e);
|
|
75
|
-
}} onSubmit={(e) => {
|
|
76
|
-
console.log(e);
|
|
77
|
-
}} fields={fields} formData={{
|
|
78
|
-
personal: {
|
|
79
|
-
username_2: "Alex"
|
|
80
|
-
}
|
|
81
|
-
}}></FormBuilder>
|
|
82
|
-
<button onClick={() => {
|
|
83
|
-
ref.current?.reset();
|
|
84
|
-
}}>Сбросить
|
|
85
|
-
</button>
|
|
86
|
-
<button onClick={() => {
|
|
87
|
-
ref.current?.submit();
|
|
88
|
-
}}>Отправить
|
|
89
|
-
</button>
|
|
90
|
-
</>
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
createRoot(document.getElementById('root')!).render(
|
|
94
|
-
<StrictMode>
|
|
95
|
-
<Debug/>
|
|
96
|
-
</StrictMode>,
|
|
97
|
-
)
|
package/src/app/index.test.tsx
DELETED
|
@@ -1,137 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
-
import { render, fireEvent, act } from '@testing-library/react'
|
|
3
|
-
import { createRef } from 'react'
|
|
4
|
-
import { FormBuilder } from './index'
|
|
5
|
-
import type { FormBuilderRef, FormElementProps } from '@/shared/types/common'
|
|
6
|
-
|
|
7
|
-
const TextField = ({ field, value, onChange }: FormElementProps) => (
|
|
8
|
-
<input
|
|
9
|
-
data-testid={`field-${field.name}`}
|
|
10
|
-
name={field.name}
|
|
11
|
-
value={value || ''}
|
|
12
|
-
onChange={e => onChange(e.target.value)}
|
|
13
|
-
/>
|
|
14
|
-
)
|
|
15
|
-
|
|
16
|
-
const fields = { text: TextField }
|
|
17
|
-
|
|
18
|
-
const layout = [
|
|
19
|
-
{ type: 'text', name: 'username', label: 'Username' },
|
|
20
|
-
]
|
|
21
|
-
|
|
22
|
-
describe('FormBuilder (integration)', () => {
|
|
23
|
-
it('renders fields from layout via registry', () => {
|
|
24
|
-
const { getByTestId } = render(
|
|
25
|
-
<FormBuilder layout={layout} fields={fields} />
|
|
26
|
-
)
|
|
27
|
-
expect(getByTestId('field-username')).toBeTruthy()
|
|
28
|
-
})
|
|
29
|
-
|
|
30
|
-
it('calls onChange when a field value changes', async () => {
|
|
31
|
-
const onChange = vi.fn()
|
|
32
|
-
const { getByTestId } = render(
|
|
33
|
-
<FormBuilder layout={layout} fields={fields} onChange={onChange} />
|
|
34
|
-
)
|
|
35
|
-
await act(async () => {
|
|
36
|
-
fireEvent.change(getByTestId('field-username'), { target: { value: 'John' } })
|
|
37
|
-
})
|
|
38
|
-
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ username: 'John' }))
|
|
39
|
-
})
|
|
40
|
-
|
|
41
|
-
it('ref.reset() clears the form', async () => {
|
|
42
|
-
const ref = createRef<FormBuilderRef>()
|
|
43
|
-
const { getByTestId } = render(
|
|
44
|
-
<FormBuilder ref={ref} layout={layout} fields={fields} />
|
|
45
|
-
)
|
|
46
|
-
await act(async () => {
|
|
47
|
-
fireEvent.change(getByTestId('field-username'), { target: { value: 'John' } })
|
|
48
|
-
})
|
|
49
|
-
expect((getByTestId('field-username') as HTMLInputElement).value).toBe('John')
|
|
50
|
-
act(() => ref.current!.reset())
|
|
51
|
-
expect((getByTestId('field-username') as HTMLInputElement).value).toBe('')
|
|
52
|
-
})
|
|
53
|
-
|
|
54
|
-
it('ref.submit() calls onSubmit with formData when valid', async () => {
|
|
55
|
-
const onSubmit = vi.fn()
|
|
56
|
-
const ref = createRef<FormBuilderRef>()
|
|
57
|
-
render(
|
|
58
|
-
<FormBuilder ref={ref} layout={layout} fields={fields} onSubmit={onSubmit} />
|
|
59
|
-
)
|
|
60
|
-
await act(async () => { await ref.current!.submit() })
|
|
61
|
-
expect(onSubmit).toHaveBeenCalledWith(expect.any(Object))
|
|
62
|
-
})
|
|
63
|
-
|
|
64
|
-
it('ref.submit() does NOT call onSubmit when validation fails and sets errors', async () => {
|
|
65
|
-
const onSubmit = vi.fn()
|
|
66
|
-
const ref = createRef<FormBuilderRef>()
|
|
67
|
-
const layoutWithValidation = [
|
|
68
|
-
{ type: 'text', name: 'username', label: 'Username', validation: ['required'] },
|
|
69
|
-
]
|
|
70
|
-
render(
|
|
71
|
-
<FormBuilder ref={ref} layout={layoutWithValidation} fields={fields} onSubmit={onSubmit} />
|
|
72
|
-
)
|
|
73
|
-
await act(async () => { await ref.current!.submit() })
|
|
74
|
-
expect(onSubmit).not.toHaveBeenCalled()
|
|
75
|
-
const errors = ref.current!.errors()
|
|
76
|
-
expect(errors.username).toBeDefined()
|
|
77
|
-
})
|
|
78
|
-
|
|
79
|
-
it('ref.errors() returns current errors', async () => {
|
|
80
|
-
const ref = createRef<FormBuilderRef>()
|
|
81
|
-
render(
|
|
82
|
-
<FormBuilder ref={ref} layout={layout} fields={fields} />
|
|
83
|
-
)
|
|
84
|
-
const errors = ref.current!.errors()
|
|
85
|
-
expect(errors).toEqual({})
|
|
86
|
-
})
|
|
87
|
-
|
|
88
|
-
it('formData prop initializes the form with initial values', () => {
|
|
89
|
-
const { getByTestId } = render(
|
|
90
|
-
<FormBuilder
|
|
91
|
-
layout={layout}
|
|
92
|
-
fields={fields}
|
|
93
|
-
formData={{ username: 'InitialUser' }}
|
|
94
|
-
/>
|
|
95
|
-
)
|
|
96
|
-
expect((getByTestId('field-username') as HTMLInputElement).value).toBe('InitialUser')
|
|
97
|
-
})
|
|
98
|
-
|
|
99
|
-
it('plugins can intercept field:change pipeline', async () => {
|
|
100
|
-
const stages: string[] = []
|
|
101
|
-
const testPlugin = {
|
|
102
|
-
name: 'test',
|
|
103
|
-
install(ctx: any) {
|
|
104
|
-
ctx.pipeline.use('field:change', (data: any, next: any) => {
|
|
105
|
-
stages.push('plugin')
|
|
106
|
-
return next({ ...data, value: data.value.toUpperCase() })
|
|
107
|
-
})
|
|
108
|
-
},
|
|
109
|
-
}
|
|
110
|
-
const onChange = vi.fn()
|
|
111
|
-
const { getByTestId } = render(
|
|
112
|
-
<FormBuilder layout={layout} fields={fields} onChange={onChange} plugins={[testPlugin]} />
|
|
113
|
-
)
|
|
114
|
-
await act(async () => {
|
|
115
|
-
fireEvent.change(getByTestId('field-username'), { target: { value: 'john' } })
|
|
116
|
-
})
|
|
117
|
-
expect(stages).toContain('plugin')
|
|
118
|
-
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ username: 'JOHN' }))
|
|
119
|
-
})
|
|
120
|
-
|
|
121
|
-
it('plugins can tap form:submit:before hook', async () => {
|
|
122
|
-
const hookCalled = vi.fn()
|
|
123
|
-
const testPlugin = {
|
|
124
|
-
name: 'test',
|
|
125
|
-
install(ctx: any) {
|
|
126
|
-
ctx.events.tap('form:submit:before', hookCalled)
|
|
127
|
-
},
|
|
128
|
-
}
|
|
129
|
-
const onSubmit = vi.fn()
|
|
130
|
-
const ref = createRef<FormBuilderRef>()
|
|
131
|
-
render(
|
|
132
|
-
<FormBuilder ref={ref} layout={layout} fields={fields} onSubmit={onSubmit} plugins={[testPlugin]} />
|
|
133
|
-
)
|
|
134
|
-
await act(async () => { await ref.current!.submit() })
|
|
135
|
-
expect(hookCalled).toHaveBeenCalled()
|
|
136
|
-
})
|
|
137
|
-
})
|
package/src/app/index.tsx
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
import type { FormBuilderRef, TFormBuilder } from "../shared/types/common";
|
|
2
|
-
import { forwardRef, useEffect, useMemo } from "react";
|
|
3
|
-
import { FormStoreProvider } from "@/shared/model/store";
|
|
4
|
-
import { Form } from "@/widgets/form/form";
|
|
5
|
-
import { PluginManager } from '@/shared/model/plugins/PluginManager';
|
|
6
|
-
import { PluginSystemContext } from '@/shared/model/plugins/context';
|
|
7
|
-
import { createValidationPlugin } from '@/plugins/validation';
|
|
8
|
-
|
|
9
|
-
export const FormBuilder = forwardRef<FormBuilderRef, TFormBuilder>((props, ref) => {
|
|
10
|
-
const { formData, ...innerProps } = props;
|
|
11
|
-
|
|
12
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
13
|
-
const pluginManager = useMemo(() => {
|
|
14
|
-
const pm = new PluginManager([createValidationPlugin(props.validator), ...(props.plugins ?? [])]);
|
|
15
|
-
pm.install();
|
|
16
|
-
return pm;
|
|
17
|
-
}, []);
|
|
18
|
-
|
|
19
|
-
useEffect(() => {
|
|
20
|
-
// Re-install after Strict Mode's simulated unmount clears subscriptions
|
|
21
|
-
pluginManager.install();
|
|
22
|
-
return () => pluginManager.uninstall();
|
|
23
|
-
}, [pluginManager]);
|
|
24
|
-
|
|
25
|
-
return (
|
|
26
|
-
<PluginSystemContext.Provider value={pluginManager}>
|
|
27
|
-
<FormStoreProvider formData={formData}>
|
|
28
|
-
<Form {...innerProps} ref={ref} />
|
|
29
|
-
</FormStoreProvider>
|
|
30
|
-
</PluginSystemContext.Provider>
|
|
31
|
-
);
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
export default FormBuilder;
|
package/src/app/test.css
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
@import "tailwindcss";
|
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
import { useEffect } from 'react';
|
|
2
|
-
import { useFormStore, useFormDispatch, useFormStoreInstance } from "@/shared/model/store";
|
|
3
|
-
import { getNestedValue } from "@/shared/utils";
|
|
4
|
-
import type { FormFieldConfig, FormElementComponent } from "@/shared/types/common";
|
|
5
|
-
import { useCallEvents, useRunPipeline, useRunPipelineSync } from '@/shared/model/plugins/hooks';
|
|
6
|
-
|
|
7
|
-
type BuilderElementProps = {
|
|
8
|
-
element: FormElementComponent;
|
|
9
|
-
field: FormFieldConfig;
|
|
10
|
-
path: string;
|
|
11
|
-
};
|
|
12
|
-
|
|
13
|
-
export const DynamicBuilderElement = (props: BuilderElementProps) => {
|
|
14
|
-
const Element = props.element;
|
|
15
|
-
const field = props.field;
|
|
16
|
-
const currentFieldPath = props.path ? `${props.path}.${field.name}` : field.name;
|
|
17
|
-
const value = useFormStore((s) => getNestedValue(s.formData, currentFieldPath));
|
|
18
|
-
const errors = useFormStore((s) => s.errors[currentFieldPath]);
|
|
19
|
-
const dispatch = useFormDispatch();
|
|
20
|
-
const store = useFormStoreInstance();
|
|
21
|
-
const callEvents = useCallEvents();
|
|
22
|
-
const runPipeline = useRunPipeline();
|
|
23
|
-
const runPipelineSync = useRunPipelineSync();
|
|
24
|
-
|
|
25
|
-
useEffect(() => {
|
|
26
|
-
callEvents('field:register', { path: currentFieldPath, field });
|
|
27
|
-
}, [currentFieldPath]);
|
|
28
|
-
|
|
29
|
-
const visible = useFormStore((s) =>
|
|
30
|
-
runPipelineSync('field:visible', {
|
|
31
|
-
visible: true,
|
|
32
|
-
field,
|
|
33
|
-
path: currentFieldPath,
|
|
34
|
-
formData: s.formData,
|
|
35
|
-
}).visible
|
|
36
|
-
);
|
|
37
|
-
|
|
38
|
-
if (!visible) return null;
|
|
39
|
-
|
|
40
|
-
return (
|
|
41
|
-
<Element
|
|
42
|
-
field={{ ...field }}
|
|
43
|
-
path={currentFieldPath}
|
|
44
|
-
value={value}
|
|
45
|
-
errors={errors}
|
|
46
|
-
onChange={async (value: any) => {
|
|
47
|
-
const data = store.getState().formData;
|
|
48
|
-
const { value: finalValue, errors } = await runPipeline('field:change', {
|
|
49
|
-
value,
|
|
50
|
-
field,
|
|
51
|
-
path: currentFieldPath,
|
|
52
|
-
formData: data,
|
|
53
|
-
errors: [],
|
|
54
|
-
});
|
|
55
|
-
dispatch({
|
|
56
|
-
type: 'setFieldValue',
|
|
57
|
-
path: currentFieldPath,
|
|
58
|
-
value: finalValue,
|
|
59
|
-
errors,
|
|
60
|
-
});
|
|
61
|
-
await callEvents('field:change', {
|
|
62
|
-
value: finalValue,
|
|
63
|
-
prevValue: value,
|
|
64
|
-
field,
|
|
65
|
-
path: currentFieldPath,
|
|
66
|
-
formData: store.getState().formData,
|
|
67
|
-
});
|
|
68
|
-
}}
|
|
69
|
-
/>
|
|
70
|
-
);
|
|
71
|
-
};
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
import type {TDynamicBuilder} from "@/shared/types/common";
|
|
2
|
-
import {DynamicBuilderElement} from "./element";
|
|
3
|
-
import { useFields } from "@/entity/dynamicBuilder/model";
|
|
4
|
-
|
|
5
|
-
export const DynamicBuilder: TDynamicBuilder = (props) => {
|
|
6
|
-
const path = props.path ?? '';
|
|
7
|
-
const fields = useFields();
|
|
8
|
-
|
|
9
|
-
return props.layout.map((field, index) => {
|
|
10
|
-
const FormElement = fields[field.type];
|
|
11
|
-
|
|
12
|
-
if (!FormElement) {
|
|
13
|
-
console.warn(`Неизвестный тип поля: ${field.type}. Проверьте formRegistry.`);
|
|
14
|
-
return null;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
return <DynamicBuilderElement key={`${field.name}-${index}`} element={FormElement} path={path} field={field}/>
|
|
18
|
-
})
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export * from './model';
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
import React, {createContext, type ReactNode, useCallback, useContext} from "react";
|
|
2
|
-
import {DynamicBuilder} from "@/entity/dynamicBuilder";
|
|
3
|
-
import type {FormElementRegistry} from "~/src";
|
|
4
|
-
|
|
5
|
-
export type TFnBuilder = (layout: any, path: string | undefined, children?: ReactNode) => ReactNode;
|
|
6
|
-
export function createBuilderContext() {
|
|
7
|
-
const BuilderContext = createContext<{
|
|
8
|
-
builder: TFnBuilder,
|
|
9
|
-
fields: FormElementRegistry,
|
|
10
|
-
} | null>(null);
|
|
11
|
-
|
|
12
|
-
const Provider: React.FC<{ children: React.ReactNode, fields: FormElementRegistry }> = ({
|
|
13
|
-
fields,
|
|
14
|
-
children
|
|
15
|
-
}) => {
|
|
16
|
-
const builder = useCallback<TFnBuilder>((layout, path, children) => {
|
|
17
|
-
return <DynamicBuilder layout={layout} path={path}>{children}</DynamicBuilder>
|
|
18
|
-
}, [fields]);
|
|
19
|
-
|
|
20
|
-
return (
|
|
21
|
-
<BuilderContext.Provider value={{builder, fields}}>
|
|
22
|
-
{children}
|
|
23
|
-
</BuilderContext.Provider>
|
|
24
|
-
);
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
function useBuilder(): TFnBuilder {
|
|
28
|
-
const store = useContext(BuilderContext);
|
|
29
|
-
|
|
30
|
-
if (!store) {
|
|
31
|
-
throw new Error("StoreProvider missing");
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
return store.builder
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function useFields(): FormElementRegistry {
|
|
38
|
-
const store = useContext(BuilderContext);
|
|
39
|
-
|
|
40
|
-
if (!store) {
|
|
41
|
-
throw new Error("StoreProvider missing");
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
return store.fields
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
return {
|
|
48
|
-
Provider,
|
|
49
|
-
useBuilder,
|
|
50
|
-
useFields,
|
|
51
|
-
};
|
|
52
|
-
}
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import React from 'react'
|
|
3
|
-
import { renderWithProviders } from '@/shared/test-utils'
|
|
4
|
-
import { FormGroup } from './index'
|
|
5
|
-
import type { FormGroupConfig } from './index'
|
|
6
|
-
|
|
7
|
-
const baseGroupField: FormGroupConfig = {
|
|
8
|
-
type: 'group',
|
|
9
|
-
name: 'myGroup',
|
|
10
|
-
label: 'My Group',
|
|
11
|
-
fields: [],
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
describe('FormGroup', () => {
|
|
15
|
-
it('renders the label', () => {
|
|
16
|
-
const { getByText } = renderWithProviders(
|
|
17
|
-
<FormGroup field={baseGroupField} path="myGroup" value={undefined} onChange={() => {}} />
|
|
18
|
-
)
|
|
19
|
-
expect(getByText('My Group')).toBeTruthy()
|
|
20
|
-
})
|
|
21
|
-
|
|
22
|
-
it('applies flex-col by default', () => {
|
|
23
|
-
const { container } = renderWithProviders(
|
|
24
|
-
<FormGroup field={baseGroupField} path="myGroup" value={undefined} onChange={() => {}} />
|
|
25
|
-
)
|
|
26
|
-
const inner = container.querySelector('.flex-col')
|
|
27
|
-
expect(inner).toBeTruthy()
|
|
28
|
-
})
|
|
29
|
-
|
|
30
|
-
it('applies flex-row when variant is row', () => {
|
|
31
|
-
const rowField: FormGroupConfig = { ...baseGroupField, variant: 'row' }
|
|
32
|
-
const { container } = renderWithProviders(
|
|
33
|
-
<FormGroup field={rowField} path="myGroup" value={undefined} onChange={() => {}} />
|
|
34
|
-
)
|
|
35
|
-
const inner = container.querySelector('.flex-row')
|
|
36
|
-
expect(inner).toBeTruthy()
|
|
37
|
-
})
|
|
38
|
-
|
|
39
|
-
it('returns null when fields is not an array', () => {
|
|
40
|
-
const invalidField = { type: 'group', name: 'g', label: 'G' } as any
|
|
41
|
-
const { container } = renderWithProviders(
|
|
42
|
-
<FormGroup field={invalidField} path="g" value={undefined} onChange={() => {}} />
|
|
43
|
-
)
|
|
44
|
-
expect(container.firstChild).toBeNull()
|
|
45
|
-
})
|
|
46
|
-
})
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
import type {FormElementProps, FormFieldBase, FormFieldConfig, RC} from "../../../../shared/types/common.ts";
|
|
2
|
-
import clsx from "clsx";
|
|
3
|
-
import {useBuilder} from "@/entity/dynamicBuilder";
|
|
4
|
-
export type FormGroupConfig = FormFieldBase & { variant?: 'row' | 'col', fields: FormFieldConfig[] };
|
|
5
|
-
|
|
6
|
-
function isGroupConfig(field: FormFieldConfig): field is FormGroupConfig {
|
|
7
|
-
return Array.isArray(field.fields);
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
export const FormGroup: RC<FormElementProps<FormGroupConfig>> = ({field, path}) => {
|
|
12
|
-
if (!isGroupConfig(field)) {
|
|
13
|
-
return null;
|
|
14
|
-
}
|
|
15
|
-
const Builder = useBuilder()(field.fields, path);
|
|
16
|
-
const variant = field.variant ?? 'col';
|
|
17
|
-
|
|
18
|
-
const className = variant == 'col' ? 'flex-col' : 'flex-row';
|
|
19
|
-
|
|
20
|
-
return <div>
|
|
21
|
-
<div>{field.label}</div>
|
|
22
|
-
<div className={clsx(className, 'flex')}>
|
|
23
|
-
{Builder}
|
|
24
|
-
</div>
|
|
25
|
-
</div>;
|
|
26
|
-
};
|
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
-
import { render, fireEvent } from '@testing-library/react'
|
|
3
|
-
import React from 'react'
|
|
4
|
-
import { TextField } from './index'
|
|
5
|
-
|
|
6
|
-
const baseField = { type: 'text' as const, name: 'username', label: 'Username' }
|
|
7
|
-
|
|
8
|
-
describe('TextField', () => {
|
|
9
|
-
it('renders label and input', () => {
|
|
10
|
-
const { getByText, getByRole } = render(
|
|
11
|
-
<TextField field={baseField} path="username" value="" onChange={() => {}} />
|
|
12
|
-
)
|
|
13
|
-
expect(getByText('Username:')).toBeTruthy()
|
|
14
|
-
expect(getByRole('textbox')).toBeTruthy()
|
|
15
|
-
})
|
|
16
|
-
|
|
17
|
-
it('displays the passed value', () => {
|
|
18
|
-
const { getByRole } = render(
|
|
19
|
-
<TextField field={baseField} path="username" value="John" onChange={() => {}} />
|
|
20
|
-
)
|
|
21
|
-
expect((getByRole('textbox') as HTMLInputElement).value).toBe('John')
|
|
22
|
-
})
|
|
23
|
-
|
|
24
|
-
it('calls onChange when input changes', () => {
|
|
25
|
-
const onChange = vi.fn()
|
|
26
|
-
const { getByRole } = render(
|
|
27
|
-
<TextField field={baseField} path="username" value="" onChange={onChange} />
|
|
28
|
-
)
|
|
29
|
-
fireEvent.change(getByRole('textbox'), { target: { value: 'Jane' } })
|
|
30
|
-
expect(onChange).toHaveBeenCalledWith('Jane')
|
|
31
|
-
})
|
|
32
|
-
|
|
33
|
-
it('shows error message and red border when errors provided', () => {
|
|
34
|
-
const { getByText, getByRole } = render(
|
|
35
|
-
<TextField
|
|
36
|
-
field={baseField}
|
|
37
|
-
path="username"
|
|
38
|
-
value=""
|
|
39
|
-
errors={{ required: 'Field is required' }}
|
|
40
|
-
onChange={() => {}}
|
|
41
|
-
/>
|
|
42
|
-
)
|
|
43
|
-
expect(getByText('Field is required')).toBeTruthy()
|
|
44
|
-
const input = getByRole('textbox') as HTMLInputElement
|
|
45
|
-
expect(input.style.borderColor).toBe('red')
|
|
46
|
-
})
|
|
47
|
-
|
|
48
|
-
it('returns null for invalid field type', () => {
|
|
49
|
-
const invalidField = { type: 'group' as any, name: 'g', label: 'G' }
|
|
50
|
-
const { container } = render(
|
|
51
|
-
<TextField field={invalidField} path="g" value="" onChange={() => {}} />
|
|
52
|
-
)
|
|
53
|
-
expect(container.firstChild).toBeNull()
|
|
54
|
-
})
|
|
55
|
-
|
|
56
|
-
it('works with email type', () => {
|
|
57
|
-
const emailField = { type: 'email' as const, name: 'email', label: 'Email' }
|
|
58
|
-
const { getByRole } = render(
|
|
59
|
-
<TextField field={emailField} path="email" value="a@b.com" onChange={() => {}} />
|
|
60
|
-
)
|
|
61
|
-
expect((getByRole('textbox') as HTMLInputElement).type).toBe('email')
|
|
62
|
-
})
|
|
63
|
-
|
|
64
|
-
it('works with password type', () => {
|
|
65
|
-
const passField = { type: 'password' as const, name: 'pass', label: 'Password' }
|
|
66
|
-
const { container } = render(
|
|
67
|
-
<TextField field={passField} path="pass" value="secret" onChange={() => {}} />
|
|
68
|
-
)
|
|
69
|
-
const input = container.querySelector('input[type="password"]') as HTMLInputElement
|
|
70
|
-
expect(input).toBeTruthy()
|
|
71
|
-
expect(input.value).toBe('secret')
|
|
72
|
-
})
|
|
73
|
-
})
|