@reformer/core 10.2.0 → 11.0.0-beta.2
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/dist/behaviors.js +134 -134
- package/dist/form/form-submitter.d.ts +0 -4
- package/dist/form/hooks/types.d.ts +3 -3
- package/dist/form/index.d.ts +0 -3
- package/dist/form/nodes/array-node.d.ts +0 -84
- package/dist/form/nodes/form-node.d.ts +0 -14
- package/dist/form/nodes/group-node.d.ts +0 -4
- package/dist/form/status-machine.d.ts +0 -38
- package/dist/form/types/contracts.d.ts +0 -6
- package/dist/form/types/deep-schema.d.ts +0 -9
- package/dist/form/types/index.d.ts +4 -38
- package/dist/form/types/schema-node.d.ts +13 -16
- package/dist/form/types/validation-schema.d.ts +0 -25
- package/dist/form/unique-id.d.ts +3 -11
- package/dist/form/validators.d.ts +8 -3
- package/dist/form-model-DhW2HZOB.js +323 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +448 -636
- package/dist/runtime-token.d.ts +23 -0
- package/dist/state/form-model.d.ts +14 -0
- package/dist/state/index.d.ts +2 -1
- package/dist/state/types.d.ts +39 -11
- package/dist/state.js +18 -18
- package/dist/validation.js +1 -1
- package/dist/validators.d.ts +0 -4
- package/dist/validators.js +88 -122
- package/docs/llms/01-api-reference.md +63 -0
- package/docs/llms/02-quick-start.md +143 -0
- package/docs/llms/03-api-signatures.md +273 -0
- package/docs/llms/04-common-patterns.md +202 -0
- package/docs/llms/05-common-mistakes.md +167 -0
- package/docs/llms/06-troubleshooting.md +11 -0
- package/docs/llms/07-complete-import.md +99 -0
- package/docs/llms/08-form-types.md +47 -0
- package/docs/llms/09-formschema.md +143 -0
- package/docs/llms/10-arrays.md +161 -0
- package/docs/llms/11-async-watchfield.md +53 -0
- package/docs/llms/12-array-cleanup.md +31 -0
- package/docs/llms/13-multi-step.md +91 -0
- package/docs/llms/14-extended-mistakes.md +98 -0
- package/docs/llms/15-project-structure.md +125 -0
- package/docs/llms/16-ui-components.md +189 -0
- package/docs/llms/17-nonexistent-api.md +93 -0
- package/docs/llms/18-conditional-fields.md +183 -0
- package/docs/llms/19-reading-values.md +88 -0
- package/docs/llms/20-compute-vs-watch.md +147 -0
- package/docs/llms/21-array-operations.md +113 -0
- package/docs/llms/22-cycle-detection.md +86 -0
- package/docs/llms/23-copy-from.md +141 -0
- package/docs/llms/24-sync-fields.md +114 -0
- package/docs/llms/25-reset-when.md +115 -0
- package/docs/llms/26-transform-value.md +136 -0
- package/docs/llms/27-revalidate-when.md +132 -0
- package/docs/llms/28-submit-and-reset.md +259 -0
- package/docs/llms/29-async-preload.md +193 -0
- package/docs/llms/30-type-safety-recipes.md +159 -0
- package/docs/llms/31-async-validator-debounce.md +135 -0
- package/docs/llms/32-async-options-loading.md +108 -0
- package/docs/llms/33-validation-strategy.md +193 -0
- package/llms.txt +219 -1533
- package/package.json +33 -10
- package/dist/derived-registry-CluZCQ7U.js +0 -16
- package/dist/form-model-DYCzGGKF.js +0 -286
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
## 1.5 QUICK START - Minimal Working Form
|
|
2
|
+
|
|
3
|
+
> **Schema-driven UI rule (read first)**: компонент И его пропсы (label, placeholder,
|
|
4
|
+
> options, type) объявляются в **схеме поля** (`component` + `componentProps`).
|
|
5
|
+
> В JSX рендерится один универсальный `<FormField control={form.x} />` из
|
|
6
|
+
> `@reformer/ui-kit` БЕЗ дополнительных props. Не пиши свои `Input`/`Select`/
|
|
7
|
+
> `Checkbox`-обёртки с `label`-prop'ами — это anti-pattern. См.
|
|
8
|
+
> `find_recipe(package="@reformer/ui-kit", topic="form-field-integration")`.
|
|
9
|
+
|
|
10
|
+
Архитектура M1: сначала создаётся **модель данных** (`createModel`), затем **форма**
|
|
11
|
+
(`createForm({ model, schema })`), где схема привязывает поля к сигналам модели
|
|
12
|
+
(`model.$.field`). Layout-схема НЕ несёт валидаторов — валидация живёт в отдельной
|
|
13
|
+
схеме `defineValidationSchema` из `@reformer/core/validation` и запускается внешним
|
|
14
|
+
раннером `validateModel(model, schema)`.
|
|
15
|
+
|
|
16
|
+
```typescript
|
|
17
|
+
import { createModel, createForm, type FormProxy } from '@reformer/core';
|
|
18
|
+
import { defineValidationSchema, validate, validateModel } from '@reformer/core/validation';
|
|
19
|
+
import { required, email } from '@reformer/core/validators';
|
|
20
|
+
import { FormField, Input, Button } from '@reformer/ui-kit';
|
|
21
|
+
|
|
22
|
+
// 1. Define form type as `type` alias (not `interface` — see Recipe 2)
|
|
23
|
+
type ContactForm = {
|
|
24
|
+
name: string;
|
|
25
|
+
email: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// 2. Model (источник истины значений)
|
|
29
|
+
const model = createModel<ContactForm>({ name: '', email: '' });
|
|
30
|
+
|
|
31
|
+
// 3. Layout-schema: привязка поля к сигналу (model.$.field) + component/componentProps.
|
|
32
|
+
// БЕЗ validators — валидация в отдельной схеме (шаг 4).
|
|
33
|
+
const schema = {
|
|
34
|
+
name: {
|
|
35
|
+
value: model.$.name,
|
|
36
|
+
component: Input,
|
|
37
|
+
componentProps: { label: 'Name', placeholder: 'Your name' },
|
|
38
|
+
},
|
|
39
|
+
email: {
|
|
40
|
+
value: model.$.email,
|
|
41
|
+
component: Input,
|
|
42
|
+
componentProps: { label: 'Email', type: 'email' },
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// 4. Validation-schema — отдельный слой (@reformer/core/validation)
|
|
47
|
+
const contactValidation = defineValidationSchema<ContactForm>(({ model }) => {
|
|
48
|
+
validate(model.$.name, [required({ message: 'Name is required' })]);
|
|
49
|
+
validate(model.$.email, [required({ message: 'Email is required' }), email({ message: 'Invalid email' })]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// 5. Form — ноды поверх сигналов модели
|
|
53
|
+
const form = createForm<ContactForm>({ model, schema });
|
|
54
|
+
|
|
55
|
+
// 6. Use in React component — thin JSX, FormField does ALL heavy lifting
|
|
56
|
+
function ContactFormComponent() {
|
|
57
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
58
|
+
e.preventDefault();
|
|
59
|
+
// form.submit()/validate() НЕ гоняют schema-валидацию — только внешний validateModel
|
|
60
|
+
const ok = await validateModel(model, contactValidation); // Promise<boolean>, ошибки сам роутит в ноды
|
|
61
|
+
if (ok) {
|
|
62
|
+
console.log('Form submitted:', model.get());
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<form onSubmit={handleSubmit}>
|
|
68
|
+
<FormField control={form.name} testId="name" />
|
|
69
|
+
<FormField control={form.email} testId="email" />
|
|
70
|
+
<Button type="submit">Send</Button>
|
|
71
|
+
</form>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 7. Pass form to child components via props (NOT context!)
|
|
76
|
+
type FormStepProps = {
|
|
77
|
+
form: FormProxy<ContactForm>;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
function FormStep({ form }: FormStepProps) {
|
|
81
|
+
return <FormField control={form.name} testId="name" />;
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
> **Стабильность инстанса.** В React создавай model/schema/form ОДИН раз через `useMemo(() => { … }, [])`
|
|
86
|
+
> — иначе форма пересоздаётся на каждый рендер. См. `28-submit-and-reset.md`, `29-async-preload.md`.
|
|
87
|
+
|
|
88
|
+
### Arrays of objects — `{ array, item }` schema node
|
|
89
|
+
|
|
90
|
+
Массивы объектов принадлежат модели (`model.arrayField`). В схеме объявляются узлом
|
|
91
|
+
`{ array: model.<path>, item: (itemModel) => itemSchema }`, где `item` строит под-схему
|
|
92
|
+
для каждого элемента из его под-модели (`FormModel<Item>`):
|
|
93
|
+
|
|
94
|
+
```typescript
|
|
95
|
+
type PropertyItem = {
|
|
96
|
+
type: 'apartment' | 'house';
|
|
97
|
+
description: string;
|
|
98
|
+
estimatedValue: number;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
type MyForm = { properties: PropertyItem[] };
|
|
102
|
+
|
|
103
|
+
const model = createModel<MyForm>({ properties: [] });
|
|
104
|
+
|
|
105
|
+
// под-схема одного элемента: item.$.field — сигнал под-модели элемента
|
|
106
|
+
const propertyItem = (item: FormModel<PropertyItem>) => ({
|
|
107
|
+
type: {
|
|
108
|
+
value: item.$.type,
|
|
109
|
+
component: Select,
|
|
110
|
+
componentProps: { label: 'Тип', options: [/* ... */] },
|
|
111
|
+
},
|
|
112
|
+
description: { value: item.$.description, component: Textarea, componentProps: { label: 'Описание' } },
|
|
113
|
+
estimatedValue: {
|
|
114
|
+
value: item.$.estimatedValue,
|
|
115
|
+
component: Input,
|
|
116
|
+
componentProps: { label: 'Стоимость', type: 'number' },
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const schema = {
|
|
121
|
+
properties: { array: model.properties, item: propertyItem },
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const form = createForm<MyForm>({ model, schema });
|
|
125
|
+
|
|
126
|
+
// Операции над массивом — на модели:
|
|
127
|
+
model.properties.push({ type: 'apartment', description: '', estimatedValue: 0 });
|
|
128
|
+
model.properties.removeAt(0);
|
|
129
|
+
model.properties.length; // реактивная длина
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Подробнее в `10-arrays.md`, `21-array-operations.md` и `find_recipe(topic="form-array")`.
|
|
133
|
+
|
|
134
|
+
### When to write your own field components (advanced — rare)
|
|
135
|
+
|
|
136
|
+
Свои компоненты нужны ТОЛЬКО если:
|
|
137
|
+
|
|
138
|
+
- ты намеренно избегаешь `@reformer/ui-kit` (например, проект уже имеет свою design system)
|
|
139
|
+
- нужен особый низкоуровневый input, который не покрывается `FormField` + `componentProps`
|
|
140
|
+
|
|
141
|
+
В этом случае см. секцию `## 14.5 UI COMPONENT PATTERNS` ниже — но даже там
|
|
142
|
+
паттерн **schema-driven** (label/options не из JSX-props, а из `componentProps` через
|
|
143
|
+
`useFormControl(...).componentProps`).
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
## 2. API SIGNATURES
|
|
2
|
+
|
|
3
|
+
### Model & Form
|
|
4
|
+
|
|
5
|
+
```typescript
|
|
6
|
+
// Модель данных (источник истины значений)
|
|
7
|
+
createModel<T extends object>(initial: T): FormModel<T>
|
|
8
|
+
// model.get() / model.set(full) / model.patch(partial) / model.isDirty()
|
|
9
|
+
// model.reset() / model.captureInitial() / model.signalAt(path)
|
|
10
|
+
// model.$.field → PathAwareSignal<FieldType> (escape-hatch к сигналу)
|
|
11
|
+
// model.arrayField → ModelArray<Item> (push/removeAt/insertAt/move/swap/clear/at/map/length)
|
|
12
|
+
|
|
13
|
+
// Форма (ноды поверх сигналов модели)
|
|
14
|
+
createForm<T>({ model, schema, behavior? }): FormProxy<T>
|
|
15
|
+
// form.<field> → FieldNode / GroupNode / FormArrayProxy
|
|
16
|
+
// form.<field>.setValue(v) / .value.value / .errors.value / .disabled.value
|
|
17
|
+
// form.<field>.enable() / .disable() / .reset() / .markAsTouched() / .setErrors([...])
|
|
18
|
+
// form.<field>.updateComponentProps({ ... })
|
|
19
|
+
|
|
20
|
+
// Валидация данных — ОТДЕЛЬНЫЙ контракт `@reformer/core/validation` (НЕ дерево createForm: то несёт
|
|
21
|
+
// только layout, без validators). Схема — функция над моделью; внешний раннер разносит ошибки по нодам
|
|
22
|
+
// формы (setErrors), warning не блокирует submit, устаревший прогон отменяется.
|
|
23
|
+
validateModel<T>(model: FormModel<T>, schema: ValidationSchema<T>): Promise<boolean> // из @reformer/core/validation
|
|
24
|
+
// schema = defineValidationSchema<T>(({ model }) => { validate(...); ... })
|
|
25
|
+
// ⚠️ form.submit() / form.validate() БОЛЬШЕ НЕ прогоняют schema-валидацию — гоняйте validateModel(model, schema) снаружи.
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### Validators
|
|
29
|
+
|
|
30
|
+
Валидаторы — **чистые фабрики** из `@reformer/core/validators`: возвращают правило `Rule<T>` =
|
|
31
|
+
`(value) => ValidationError | null` (принимают nullable value). Передаются оператору
|
|
32
|
+
`validate(sig, [required(), min(50000)])` внутри схемы валидации, а **не** в layout-дерево `createForm`.
|
|
33
|
+
|
|
34
|
+
```typescript
|
|
35
|
+
required(options?: { message?: string })
|
|
36
|
+
min(value: number, options?: { message?: string })
|
|
37
|
+
max(value: number, options?: { message?: string })
|
|
38
|
+
minLength(length: number, options?: { message?: string })
|
|
39
|
+
maxLength(length: number, options?: { message?: string })
|
|
40
|
+
email(options?: { message?: string })
|
|
41
|
+
pattern(regex: RegExp, options?: { message?: string })
|
|
42
|
+
url(options?: { message?: string; requireProtocol?: boolean })
|
|
43
|
+
phone(options?: { message?: string; format?: PhoneFormat })
|
|
44
|
+
// Number validator factories
|
|
45
|
+
isNumber(options?: { message?: string })
|
|
46
|
+
integer(options?: { message?: string })
|
|
47
|
+
multipleOf(divisor: number, options?: { message?: string })
|
|
48
|
+
nonNegative(options?: { message?: string }) // value >= 0
|
|
49
|
+
nonZero(options?: { message?: string }) // value !== 0
|
|
50
|
+
// Date validator factories
|
|
51
|
+
isDate(options?: { message?: string })
|
|
52
|
+
minDate(date: Date | string, options?: { message?: string })
|
|
53
|
+
maxDate(date: Date | string, options?: { message?: string })
|
|
54
|
+
pastDate(options?: { message?: string })
|
|
55
|
+
futureDate(options?: { message?: string })
|
|
56
|
+
minAge(years: number, options?: { message?: string })
|
|
57
|
+
maxAge(years: number, options?: { message?: string })
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Использование: layout-дерево `createForm` привязывает поля к сигналам, правила живут отдельной
|
|
61
|
+
`ValidationSchema` и гоняются раннером `validateModel` по требованию.
|
|
62
|
+
|
|
63
|
+
```typescript
|
|
64
|
+
import { createModel, createForm } from '@reformer/core';
|
|
65
|
+
import { defineValidationSchema, validate, validateModel } from '@reformer/core/validation';
|
|
66
|
+
import { required, min, max, email } from '@reformer/core/validators';
|
|
67
|
+
|
|
68
|
+
type Loan = { email: string; age: number; amount: number };
|
|
69
|
+
const model = createModel<Loan>({ email: '', age: 0, amount: 0 });
|
|
70
|
+
|
|
71
|
+
// layout-схема createForm НЕ несёт validators — только привязка поля к сигналу + компонент
|
|
72
|
+
const form = createForm({
|
|
73
|
+
model,
|
|
74
|
+
schema: {
|
|
75
|
+
email: { value: model.$.email, component: Input },
|
|
76
|
+
age: { value: model.$.age, component: Input },
|
|
77
|
+
amount: { value: model.$.amount, component: Input },
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// правила — в ОТДЕЛЬНОЙ схеме валидации (не в layout)
|
|
82
|
+
const loanValidation = defineValidationSchema<Loan>(({ model }) => {
|
|
83
|
+
validate(model.$.email, [required(), email()]);
|
|
84
|
+
validate(model.$.age, [required(), min(18)]);
|
|
85
|
+
validate(model.$.amount, [min(0), max(1000)]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const ok = await validateModel(model, loanValidation); // Promise<boolean>; ошибки сами доедут до form.<field>.errors
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Custom & cross-field validators
|
|
92
|
+
|
|
93
|
+
Контракт валидации — `@reformer/core/validation`. Схема (`ValidationSchema<T>`) — обычная функция над
|
|
94
|
+
(под)моделью; внутри вызываются свободные операторы, которые сами пишут ошибки в ноды текущего прогона
|
|
95
|
+
(`getNodeForSignal(sig).setErrors(...)` — автор коллектора не видит).
|
|
96
|
+
|
|
97
|
+
```typescript
|
|
98
|
+
type Rule<T> = (value: T, scope: never, root: never) => ValidationError | null; // scope/root = never (см. ниже)
|
|
99
|
+
type AsyncRule<T> = (value: T, ctx: { signal: AbortSignal }) => Promise<ValidationError | null>;
|
|
100
|
+
type ValidationSchema<T> = (ctx: { model: FormModel<T> }) => void;
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Операторы (ambient — валидны только внутри прогона `validateModel`):
|
|
104
|
+
|
|
105
|
+
| Оператор | Назначение |
|
|
106
|
+
|---|---|
|
|
107
|
+
| `validate(sig, rules: Rule<T>[])` | синхронные правила поля |
|
|
108
|
+
| `validateAsync(sig, rules: AsyncRule<T>[])` | асинхронные правила (раннер дожидается, прокидывает `AbortSignal`) |
|
|
109
|
+
| `validateWhen(cond: () => boolean, cb: () => void)` | условная ветка: правила внутри активны/гасятся по `cond` (не трогает enable — это поведение) |
|
|
110
|
+
| `cross(sig, fn: (form) => err \| null)` | cross-field; `fn` получает снапшот модели текущего scope (`model.get()`) |
|
|
111
|
+
| `each(arr, itemFn: (im: FormModel<U>) => void)` | per-item по элементам массива модели |
|
|
112
|
+
| `apply(...schemas: ValidationSchema<T>[])` | композиция под-схем над той же моделью |
|
|
113
|
+
| `defineValidationSchema<T>(fn): ValidationSchema<T>` | тонкая identity-обёртка (типизация/discoverability) |
|
|
114
|
+
| `validateModel<T>(model, schema): Promise<boolean>` | внешний раннер (warning не блокирует, устаревший прогон отменяется) |
|
|
115
|
+
|
|
116
|
+
> `Rule<T>` намеренно несёт `scope: never, root: never` — так value-only фабрики (`required()` = `(value, model, root)`)
|
|
117
|
+
> и inline `(value) => err` ОБА присваиваются в `Rule<T>[]`, сохраняя проверку типа поля
|
|
118
|
+
> (`validate(model.$.age, [email()])` подсветится). Ментальная модель автора — `(value) => error`.
|
|
119
|
+
|
|
120
|
+
Кастомные правила, cross-field и async — в схеме:
|
|
121
|
+
|
|
122
|
+
```typescript
|
|
123
|
+
import { type ValidationError } from '@reformer/core';
|
|
124
|
+
import {
|
|
125
|
+
defineValidationSchema, validate, validateAsync, cross,
|
|
126
|
+
type Rule, type AsyncRule,
|
|
127
|
+
} from '@reformer/core/validation';
|
|
128
|
+
import { required } from '@reformer/core/validators';
|
|
129
|
+
|
|
130
|
+
type Signup = { password: string; confirm: string; email: string };
|
|
131
|
+
|
|
132
|
+
// Value-only правило (Rule<T>)
|
|
133
|
+
const strongPassword: Rule<string> = (value) =>
|
|
134
|
+
!value || value.length < 8 ? { code: 'too-short', message: 'Минимум 8 символов' } : null;
|
|
135
|
+
|
|
136
|
+
// Cross-field: обычная функция над снапшотом модели (model.get()), вешается через cross(sig, fn)
|
|
137
|
+
const passwordsMatch = (f: Signup): ValidationError | null =>
|
|
138
|
+
f.confirm && f.password && f.confirm !== f.password
|
|
139
|
+
? { code: 'mismatch', message: 'Пароли не совпадают' }
|
|
140
|
+
: null;
|
|
141
|
+
|
|
142
|
+
// Async (AsyncRule<T>): получает { signal }; сетевой сбой → null (submit не блокируется)
|
|
143
|
+
const emailUnique: AsyncRule<string> = async (value, { signal }) => {
|
|
144
|
+
if (!value) return null;
|
|
145
|
+
try {
|
|
146
|
+
const res = await fetch(`/api/check-email?email=${encodeURIComponent(value)}`, { signal });
|
|
147
|
+
return (await res.json()).available ? null : { code: 'taken', message: 'Email уже зарегистрирован' };
|
|
148
|
+
} catch {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const signupValidation = defineValidationSchema<Signup>(({ model }) => {
|
|
154
|
+
validate(model.$.password, [required(), strongPassword]);
|
|
155
|
+
validate(model.$.confirm, [required()]);
|
|
156
|
+
cross(model.$.confirm, passwordsMatch); // ошибка вешается на confirm
|
|
157
|
+
validateAsync(model.$.email, [emailUnique]);
|
|
158
|
+
});
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### Conditional & array validation
|
|
162
|
+
|
|
163
|
+
Условные ветки и массивы — операторы `validateWhen` / `each` / `apply` внутри схемы (не узлы дерева):
|
|
164
|
+
|
|
165
|
+
- **условная ветка** `validateWhen(() => cond, () => { validate(...) })` — правила внутри активны только
|
|
166
|
+
при истинном условии; при ложном ошибки полей ветки гасятся (`setErrors([])`). Enable/сброс поля — дело поведения;
|
|
167
|
+
- **массив** `each(model.arr, (im) => { validate(im.$.field, [...]) })` — правила применяются к каждому
|
|
168
|
+
элементу; `im` — под-модель элемента. Для cross-field над элементом захватывайте снапшот в замыкание (`const item = im.get()`);
|
|
169
|
+
- **композиция** `apply(...schemas)` — объединяет под-схемы над той же моделью (напр. полную схему из per-step).
|
|
170
|
+
|
|
171
|
+
```typescript
|
|
172
|
+
import {
|
|
173
|
+
defineValidationSchema, validate, validateWhen, cross, each, apply, validateModel,
|
|
174
|
+
} from '@reformer/core/validation';
|
|
175
|
+
import { required, min } from '@reformer/core/validators';
|
|
176
|
+
|
|
177
|
+
// Условная валидация — ипотечная ветка активна только при loanType === 'mortgage'
|
|
178
|
+
const step1 = defineValidationSchema<LoanForm>(({ model }) => {
|
|
179
|
+
validate(model.$.loanType, [required()]);
|
|
180
|
+
validateWhen(
|
|
181
|
+
() => model.loanType === 'mortgage',
|
|
182
|
+
() => {
|
|
183
|
+
validate(model.$.propertyValue, [required(), min(1_000_000)]);
|
|
184
|
+
validate(model.$.initialPayment, [required()]);
|
|
185
|
+
},
|
|
186
|
+
);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// Per-item валидация элементов массива (each над ModelArray)
|
|
190
|
+
const step5 = defineValidationSchema<LoanForm>(({ model }) => {
|
|
191
|
+
each(model.coBorrowers, (im) => {
|
|
192
|
+
const item = im.get(); // снапшот элемента для cross
|
|
193
|
+
validate(im.$.income, [required(), min(10_000)]);
|
|
194
|
+
cross(im.$.income, () =>
|
|
195
|
+
item.income < item.loanShare ? { code: 'tooLow', message: 'Доход ниже доли' } : null);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// Полная схема формы = композиция шагов (apply над той же моделью)
|
|
200
|
+
const formValidation = defineValidationSchema<LoanForm>(() => apply(step1, step5));
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Wizard-конфиг (per-step + полная валидация) — поверх того же раннера `validateModel`:
|
|
204
|
+
|
|
205
|
+
```typescript
|
|
206
|
+
const STEP_SCHEMAS = [step1, /* step2, … */ step5] as const;
|
|
207
|
+
|
|
208
|
+
function makeValidationConfig(model: FormModel<LoanForm>) {
|
|
209
|
+
return {
|
|
210
|
+
validateStep: (n: number) => validateModel(model, STEP_SCHEMAS[n - 1]),
|
|
211
|
+
validateAll: () => validateModel(model, formValidation), // formValidation = apply(...STEP_SCHEMAS, extras)
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Операторы `validate`/`validateAsync`/`validateWhen`/`cross`/`each`/`apply` экспортируются напрямую из
|
|
217
|
+
`@reformer/core/validation` — это публичный API, а не локальный сахар примера.
|
|
218
|
+
|
|
219
|
+
### Behaviors
|
|
220
|
+
|
|
221
|
+
Два способа. **Примитивы из `@reformer/core`** (принимают сигналы, возвращают cleanup):
|
|
222
|
+
|
|
223
|
+
```typescript
|
|
224
|
+
computeFrom(sources: ReadonlySignal[], target: Signal, fn: (...vals) => R, options?: { when?: (...vals) => boolean }): () => void
|
|
225
|
+
copyFrom(source: ReadonlySignal, target: Signal, options?: { when?: () => boolean; transform?: (v) => v }): () => void
|
|
226
|
+
watchField(source: ReadonlySignal, cb: (value) => void, options?: { immediate?: boolean }): () => void
|
|
227
|
+
enableWhen(target: ReadonlySignal, condition: () => boolean, options?: { resetOnDisable?: boolean }): () => void
|
|
228
|
+
disableWhen(target: ReadonlySignal, condition: () => boolean, options?: { resetOnDisable?: boolean }): () => void
|
|
229
|
+
transformValue(target: Signal, transformer: (value) => value): () => void
|
|
230
|
+
resetWhen(target: Signal, condition: () => boolean, options?: { resetValue?: T }): () => void
|
|
231
|
+
syncFields(a: Signal, b: Signal, options?: { transform?: (v) => v }): () => void
|
|
232
|
+
revalidateWhen(deps: ReadonlySignal[], revalidate: () => void): () => void
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
```typescript
|
|
236
|
+
import { computeFrom, enableWhen, copyFrom } from '@reformer/core';
|
|
237
|
+
|
|
238
|
+
const cleanups = [
|
|
239
|
+
computeFrom([model.$.price, model.$.quantity], model.$.total, (p, q) => p * q),
|
|
240
|
+
enableWhen(model.$.city, () => Boolean(model.country), { resetOnDisable: true }),
|
|
241
|
+
copyFrom(model.$.email, model.$.emailAdditional, { when: () => model.sameEmail === true }),
|
|
242
|
+
];
|
|
243
|
+
// при teardown: cleanups.forEach((c) => c());
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
**Декларативный DSL из `@reformer/core/behaviors`** (регистрирует cleanup сам, передаётся в `createForm({ behavior })`):
|
|
247
|
+
|
|
248
|
+
```typescript
|
|
249
|
+
import { defineFormBehavior, compute, enableWhen, onChange } from '@reformer/core/behaviors';
|
|
250
|
+
|
|
251
|
+
const behavior = defineFormBehavior<MyForm>(({ model, form }) => {
|
|
252
|
+
compute(model.$.total, () => model.price * model.quantity);
|
|
253
|
+
enableWhen(model.$.city, () => Boolean(model.country), { resetOnDisable: true });
|
|
254
|
+
onChange(model.$.country, async (country) => {
|
|
255
|
+
form.city.updateComponentProps({ options: await loadCities(country) });
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
const form = createForm({ model, schema, behavior });
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
DSL-операторы: `compute` (auto-tracking, без явного списка источников), `computeFrom`, `copyFrom`,
|
|
263
|
+
`onChange` (реакция на изменение; `{ debounce, immediate }`, 2-й аргумент колбэка — `{ signal }` AbortSignal),
|
|
264
|
+
`enableWhen`/`disableWhen`, `transformValue`, `resetWhen`, `syncFields`, `revalidateWhen`,
|
|
265
|
+
`apply` (под-схема для группы), `applyEach` (per-item для массива), `exclusiveFlag`, `aggregateInto`.
|
|
266
|
+
См. `20-compute-vs-watch.md`.
|
|
267
|
+
|
|
268
|
+
Поведение **не владеет** валидацией — это отдельный слой. Мост «поведение инициирует валидацию» — через
|
|
269
|
+
`revalidateWhen`, который просто вызывает внешний раннер валидации:
|
|
270
|
+
|
|
271
|
+
```typescript
|
|
272
|
+
revalidateWhen([model.$.dep], () => void validateModel(model, schema));
|
|
273
|
+
```
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
## 3. COMMON PATTERNS
|
|
2
|
+
|
|
3
|
+
Все паттерны — на архитектуре M1: значения в модели (`model.$.field`), поведение (`defineFormBehavior`)
|
|
4
|
+
на живых сигналах, валидация — отдельной ambient-схемой (`defineValidationSchema`, прогон по требованию
|
|
5
|
+
через `validateModel`). Слои раздельны: layout НЕ несёт validators.
|
|
6
|
+
|
|
7
|
+
### Conditional Fields with Auto-Reset
|
|
8
|
+
|
|
9
|
+
```typescript
|
|
10
|
+
import { enableWhen } from '@reformer/core';
|
|
11
|
+
|
|
12
|
+
// поле включается по условию; при выключении — сброс к initial
|
|
13
|
+
enableWhen(model.$.propertyValue, () => model.loanType === 'mortgage', {
|
|
14
|
+
resetOnDisable: true,
|
|
15
|
+
});
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Или декларативно внутри `defineFormBehavior`:
|
|
19
|
+
|
|
20
|
+
```typescript
|
|
21
|
+
import { defineFormBehavior, enableWhen } from '@reformer/core/behaviors';
|
|
22
|
+
|
|
23
|
+
const behavior = defineFormBehavior<MyForm>(({ model }) => {
|
|
24
|
+
enableWhen(
|
|
25
|
+
[model.$.propertyValue, model.$.initialPayment],
|
|
26
|
+
() => model.loanType === 'mortgage',
|
|
27
|
+
{ resetOnDisable: true }
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### Computed Field (same or cross level)
|
|
33
|
+
|
|
34
|
+
`compute`/`computeFrom` пишут в целевой сигнал при изменении источников. Цель не входит
|
|
35
|
+
в источники → цикла нет. Кросс-уровневые вычисления работают так же — источники берутся
|
|
36
|
+
по сигналам из любого места модели.
|
|
37
|
+
|
|
38
|
+
```typescript
|
|
39
|
+
import { defineFormBehavior, compute, computeFrom } from '@reformer/core/behaviors';
|
|
40
|
+
|
|
41
|
+
const behavior = defineFormBehavior<MyForm>(({ model }) => {
|
|
42
|
+
// compute: auto-tracking — читаем что нужно прямо из value-модели
|
|
43
|
+
compute(model.$.total, () => (model.price ?? 0) * (model.quantity ?? 0));
|
|
44
|
+
|
|
45
|
+
// computeFrom: явный список источников
|
|
46
|
+
computeFrom([model.$.price, model.$.quantity], model.$.total, (price, qty) => price * qty);
|
|
47
|
+
|
|
48
|
+
// кросс-уровневое: fullName из вложенной группы
|
|
49
|
+
compute(model.$.fullName, () =>
|
|
50
|
+
[model.personalData.firstName, model.personalData.lastName].filter(Boolean).join(' ')
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Async reaction / dynamic options — `onChange`
|
|
56
|
+
|
|
57
|
+
Для async-реакции на изменение поля (загрузка справочников, зависимые селекты) используй
|
|
58
|
+
`onChange` из DSL. Колбэк выполняется вне effect-контекста (можно писать сигналы/ноды), а
|
|
59
|
+
2-й аргумент — `{ signal }` (AbortSignal) для отмены устаревших запросов.
|
|
60
|
+
|
|
61
|
+
```typescript
|
|
62
|
+
import { defineFormBehavior, onChange } from '@reformer/core/behaviors';
|
|
63
|
+
|
|
64
|
+
const behavior = defineFormBehavior<MyForm>(({ model, form }) => {
|
|
65
|
+
onChange(
|
|
66
|
+
model.$.region,
|
|
67
|
+
async (region, { signal }) => {
|
|
68
|
+
if (!region) {
|
|
69
|
+
form.city.updateComponentProps({ options: [] });
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const cities = await fetchCities(region, { signal });
|
|
73
|
+
form.city.updateComponentProps({ options: cities });
|
|
74
|
+
},
|
|
75
|
+
{ debounce: 300 }
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
> Низкоуровневый аналог — примитив `watchField(model.$.region, cb)` из `@reformer/core` (без debounce/AbortSignal;
|
|
81
|
+
> для сети предпочтителен `onChange`). См. `20-compute-vs-watch.md`, `32-async-options-loading.md`.
|
|
82
|
+
|
|
83
|
+
### Reading values
|
|
84
|
+
|
|
85
|
+
```typescript
|
|
86
|
+
// В React-компоненте — хуки
|
|
87
|
+
const { value, errors, disabled } = useFormControl(form.email);
|
|
88
|
+
const loanType = useFormControlValue(form.loanType); // значение напрямую
|
|
89
|
+
|
|
90
|
+
// Вне React — из модели
|
|
91
|
+
model.email; // value-доступ (реактивно внутри effect/computed)
|
|
92
|
+
model.$.email.value; // через сигнал
|
|
93
|
+
model.$.email.peek(); // нереактивный снимок
|
|
94
|
+
model.get(); // весь объект-снимок
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Submit + validation
|
|
98
|
+
|
|
99
|
+
Валидация — отдельная `ValidationSchema<T>` (`defineValidationSchema`), а не часть layout. Раннер
|
|
100
|
+
`validateModel(model, schema)` сам роутит ошибки в ноды формы и возвращает `Promise<boolean>`
|
|
101
|
+
(`false` = есть блокирующая ошибка; `severity: 'warning'` не блокирует).
|
|
102
|
+
|
|
103
|
+
```typescript
|
|
104
|
+
import { validateModel } from '@reformer/core/validation';
|
|
105
|
+
|
|
106
|
+
async function handleSubmit(e: React.FormEvent) {
|
|
107
|
+
e.preventDefault();
|
|
108
|
+
const ok = await validateModel(model, schema); // ошибки сами доезжают до нод формы (UI подсветит)
|
|
109
|
+
if (!ok) return; // false → есть блокирующая ошибка
|
|
110
|
+
await api.send(model.get());
|
|
111
|
+
model.reset(); // к initial-снимку
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Multi-step: держи отдельные под-схемы на шаг и вызывай `validateModel(model, stepSchema)`. Для wizard'а —
|
|
116
|
+
`makeValidationConfig(model)` → `{ validateStep, validateAll }` (`validateAll` прогоняет полную
|
|
117
|
+
`apply(...STEP_SCHEMAS, extras)`). См. `13-multi-step.md`, `28-submit-and-reset.md`.
|
|
118
|
+
|
|
119
|
+
### Cross-field validation — через `cross`
|
|
120
|
+
|
|
121
|
+
Cross-field правило — обычная функция над **снапшотом** модели (`fn` получает `model.get()`),
|
|
122
|
+
навешивается оператором `cross(sig, fn)` на поле-носитель ошибки внутри схемы:
|
|
123
|
+
|
|
124
|
+
```typescript
|
|
125
|
+
import { defineValidationSchema, validate, cross } from '@reformer/core/validation';
|
|
126
|
+
import { required, min } from '@reformer/core/validators';
|
|
127
|
+
import type { ValidationError } from '@reformer/core';
|
|
128
|
+
|
|
129
|
+
// снапшот формы читается напрямую — без каста, соседние поля доступны как поля объекта
|
|
130
|
+
const initialPaymentVsProperty = (f: MyForm): ValidationError | null =>
|
|
131
|
+
f.initialPayment && f.propertyValue && f.initialPayment > f.propertyValue
|
|
132
|
+
? { code: 'tooHigh', message: 'Взнос не может превышать стоимость' }
|
|
133
|
+
: null;
|
|
134
|
+
|
|
135
|
+
const schema = defineValidationSchema<MyForm>(({ model }) => {
|
|
136
|
+
validate(model.$.initialPayment, [required(), min(0)]);
|
|
137
|
+
cross(model.$.initialPayment, initialPaymentVsProperty); // ошибка сядет на initialPayment
|
|
138
|
+
});
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
> Для элемента массива / под-модели захвати нужный снапшот в замыкание (`const item = im.get();
|
|
142
|
+
> cross(im.$.x, () => rule(item))`) — `fn` всегда получает модель ТЕКУЩЕГО scope, а не под-модель.
|
|
143
|
+
|
|
144
|
+
### Conditional validation — `validateWhen`
|
|
145
|
+
|
|
146
|
+
Условная валидация — `validateWhen(() => cond, () => { … })`: правила внутри активны, только пока
|
|
147
|
+
условие истинно; при `false` ранее тронутые поля гасятся. Это не `enable` (то — поведение), а
|
|
148
|
+
включение/выключение самих проверок:
|
|
149
|
+
|
|
150
|
+
```typescript
|
|
151
|
+
import { validateWhen, validate, cross } from '@reformer/core/validation';
|
|
152
|
+
|
|
153
|
+
validateWhen(
|
|
154
|
+
() => model.loanType === 'mortgage',
|
|
155
|
+
() => {
|
|
156
|
+
validate(model.$.propertyValue, [required(), min(1000000)]);
|
|
157
|
+
cross(model.$.initialPayment, initialPaymentVsProperty);
|
|
158
|
+
}
|
|
159
|
+
);
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
### Перезапуск прогона — `revalidateWhen` (мост поведение → валидация)
|
|
163
|
+
|
|
164
|
+
Схема прогоняется по требованию (submit/шаг). Чтобы перезапустить её при изменении зависимости —
|
|
165
|
+
`revalidateWhen` из **поведения** (единственный мост поведение → валидация):
|
|
166
|
+
|
|
167
|
+
```typescript
|
|
168
|
+
import { defineFormBehavior, revalidateWhen } from '@reformer/core/behaviors';
|
|
169
|
+
import { validateModel } from '@reformer/core/validation';
|
|
170
|
+
|
|
171
|
+
const behavior = defineFormBehavior<MyForm>(({ model }) => {
|
|
172
|
+
revalidateWhen([model.$.propertyValue], () => void validateModel(model, schema));
|
|
173
|
+
});
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### Extracting named rules
|
|
177
|
+
|
|
178
|
+
Когда тело правила растёт — выноси в именованную функцию. Field-правило типизируй `Rule<T>`
|
|
179
|
+
(`(value) => ValidationError | null`), cross-field — обычной `(f: Root) => ValidationError | null`.
|
|
180
|
+
Схема остаётся плоской и читается как оглавление:
|
|
181
|
+
|
|
182
|
+
```typescript
|
|
183
|
+
import { defineValidationSchema, validate } from '@reformer/core/validation';
|
|
184
|
+
import { required } from '@reformer/core/validators';
|
|
185
|
+
import type { Rule } from '@reformer/core/validation';
|
|
186
|
+
|
|
187
|
+
// field-правило: (value) => error
|
|
188
|
+
const validateAdultAge: Rule<string> = (value) => {
|
|
189
|
+
if (!value) return null;
|
|
190
|
+
const age = new Date().getFullYear() - new Date(value).getFullYear();
|
|
191
|
+
return age < 18 ? { code: 'tooYoung', message: 'Минимум 18 лет' } : null;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const schema = defineValidationSchema<MyForm>(({ model }) => {
|
|
195
|
+
validate(model.$.birthDate, [required(), validateAdultAge]);
|
|
196
|
+
});
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
**Naming convention** (camelCase, семантика, не эхо оператора):
|
|
200
|
+
|
|
201
|
+
- Cross-field правило → инвариант: `initialPaymentVsPropertyValue`, `paymentToIncomeUnderHalf`.
|
|
202
|
+
- Field-правило → проверка: `validateAdultAge`, `passwordsMatch`.
|