@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,159 @@
|
|
|
1
|
+
## 30. TYPE-SAFETY RECIPES
|
|
2
|
+
|
|
3
|
+
Идиоматичные паттерны, которые держат сгенерированный код без `any` и `as`-кастов под M1.
|
|
4
|
+
|
|
5
|
+
### Recipe 1 — Imports (root cause prevention)
|
|
6
|
+
|
|
7
|
+
- Модель/форма/хуки/типы/**примитивы behaviors** — из `@reformer/core`.
|
|
8
|
+
- Схема валидации (операторы + раннер `validateModel`) — из `@reformer/core/validation`.
|
|
9
|
+
- Чистые фабрики валидаторов — из `@reformer/core/validators`.
|
|
10
|
+
- Декларативный DSL (`defineFormBehavior` + операторы) — из `@reformer/core/behaviors`.
|
|
11
|
+
|
|
12
|
+
```typescript
|
|
13
|
+
import {
|
|
14
|
+
createModel,
|
|
15
|
+
createForm,
|
|
16
|
+
type FormModel,
|
|
17
|
+
type FormProxy,
|
|
18
|
+
type ModelSignals,
|
|
19
|
+
} from '@reformer/core';
|
|
20
|
+
import {
|
|
21
|
+
defineValidationSchema,
|
|
22
|
+
validate,
|
|
23
|
+
cross,
|
|
24
|
+
validateModel,
|
|
25
|
+
type Rule,
|
|
26
|
+
} from '@reformer/core/validation';
|
|
27
|
+
import { required, min, max, email } from '@reformer/core/validators';
|
|
28
|
+
import { defineFormBehavior, compute, enableWhen, onChange } from '@reformer/core/behaviors';
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
> **watchField — из `@reformer/core`** (примитив), НЕ из `@reformer/core/behaviors` (там `onChange`).
|
|
32
|
+
|
|
33
|
+
### Recipe 2 — Form-shape types as `type`, not `interface`
|
|
34
|
+
|
|
35
|
+
`Record<string, FormValue>` требует index signature. У `interface` её нет неявно; у `type` —
|
|
36
|
+
структурно. Объявляй через `type`-alias всё, что попадает в `FormProxy<T>`/`ArrayNode<T>`:
|
|
37
|
+
корневую форму, вложенные группы, типы элементов массива.
|
|
38
|
+
|
|
39
|
+
```typescript
|
|
40
|
+
export type PropertyItem = {
|
|
41
|
+
type: 'apartment' | 'house' | 'car';
|
|
42
|
+
description: string;
|
|
43
|
+
estimatedValue: number;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type CreditApplicationForm = {
|
|
47
|
+
loanAmount: number | null;
|
|
48
|
+
properties: PropertyItem[];
|
|
49
|
+
// ...
|
|
50
|
+
};
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Recipe 3 — Схема привязана к сигналам модели
|
|
54
|
+
|
|
55
|
+
`value` поля — это сигнал модели (`model.$.field`), а не литерал. Тип поля выводится из сигнала.
|
|
56
|
+
Layout-схема БЕЗ валидаторов — правила живут в отдельной validation-схеме.
|
|
57
|
+
|
|
58
|
+
```typescript
|
|
59
|
+
const model = createModel<CreditApplicationForm>(initial);
|
|
60
|
+
|
|
61
|
+
const schema = {
|
|
62
|
+
loanAmount: { value: model.$.loanAmount, component: Input },
|
|
63
|
+
// вложенная группа — builder, принимающий ModelSignals<Sub>
|
|
64
|
+
personalData: personalDataNodes(model.$.personalData),
|
|
65
|
+
// массив — { array, item }
|
|
66
|
+
properties: { array: model.properties, item: propertyItem },
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// правила — отдельно; типы полей выводятся из сигналов
|
|
70
|
+
const validation = defineValidationSchema<CreditApplicationForm>(({ model }) => {
|
|
71
|
+
validate(model.$.loanAmount, [required(), min(50000)]);
|
|
72
|
+
});
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Recipe 4 — Cross-field валидаторы: `cross` над типизированным снапшотом
|
|
76
|
+
|
|
77
|
+
Правило — обычная функция `(f: Root) => ValidationError | null` над снапшотом `model.get()`.
|
|
78
|
+
Соседние поля читаются без `as`; ошибка вешается на поле-носитель `sig`:
|
|
79
|
+
|
|
80
|
+
```typescript
|
|
81
|
+
import type { ValidationError } from '@reformer/core';
|
|
82
|
+
|
|
83
|
+
const initialPaymentVsProperty = (f: CreditApplicationForm): ValidationError | null =>
|
|
84
|
+
f.initialPayment && f.propertyValue && f.initialPayment > f.propertyValue
|
|
85
|
+
? { code: 'tooHigh', message: 'Взнос не может превышать стоимость' }
|
|
86
|
+
: null;
|
|
87
|
+
|
|
88
|
+
// внутри defineValidationSchema<CreditApplicationForm>(({ model }) => { ... }):
|
|
89
|
+
cross(model.$.initialPayment, initialPaymentVsProperty);
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Recipe 5 — `compute` читает модель напрямую (без аннотаций)
|
|
93
|
+
|
|
94
|
+
`compute(target, () => …)` читает value-модель (`model.field`) — типы полей выводятся из типа
|
|
95
|
+
модели, `as`-касты не нужны:
|
|
96
|
+
|
|
97
|
+
```typescript
|
|
98
|
+
compute(model.$.monthlyPayment, () =>
|
|
99
|
+
annuityMonthly(model.loanAmount ?? 0, model.loanTerm ?? 0, model.interestRate ?? 0)
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
// nested reads — тоже напрямую
|
|
103
|
+
compute(model.$.fullName, () =>
|
|
104
|
+
[model.personalData.firstName, model.personalData.lastName].filter(Boolean).join(' ')
|
|
105
|
+
);
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Recipe 6 — `null` vs `undefined` для опциональных полей
|
|
109
|
+
|
|
110
|
+
Оба работают. `null` — конвенция «пользователь очистил поле». Встроенные валидаторы
|
|
111
|
+
(`min`, `max`, `minLength`, `maxLength`, `minDate`, `maxDate`, `minAge`, `maxAge`) пропускают
|
|
112
|
+
пустые значения — guard `if (value != null)` не нужен.
|
|
113
|
+
|
|
114
|
+
```typescript
|
|
115
|
+
export type CreditForm = {
|
|
116
|
+
loanAmount: number | null; // min(model.$.loanAmount, 50000) — ок
|
|
117
|
+
loanPurpose: string | null; // minLength — ок
|
|
118
|
+
birthDate: string | null; // minAge — ок
|
|
119
|
+
};
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Recipe 7 — Нода поля для кастомных компонентов
|
|
123
|
+
|
|
124
|
+
`useFormControl(control)` типизируется по `FieldNode<T>`. В props компонента используй `FieldNode<T>`:
|
|
125
|
+
|
|
126
|
+
```typescript
|
|
127
|
+
import type { FieldNode } from '@reformer/core';
|
|
128
|
+
|
|
129
|
+
type MyFieldProps<T> = { control: FieldNode<T> };
|
|
130
|
+
function MyField<T>({ control }: MyFieldProps<T>) {
|
|
131
|
+
const { value, errors, disabled } = useFormControl(control);
|
|
132
|
+
// ...
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### Recipe 8 — Вынос правил в именованные функции
|
|
137
|
+
|
|
138
|
+
Field-правила — именованные `Rule<T>`, validation-схема остаётся плоской:
|
|
139
|
+
|
|
140
|
+
```typescript
|
|
141
|
+
import type { Rule } from '@reformer/core/validation';
|
|
142
|
+
|
|
143
|
+
const validateAdultAge: Rule<string | null> = (value) => {
|
|
144
|
+
if (!value) return null;
|
|
145
|
+
const age = new Date().getFullYear() - new Date(value).getFullYear();
|
|
146
|
+
return age < 18 ? { code: 'tooYoung', message: 'Минимум 18 лет' } : null;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
// внутри defineValidationSchema<MyForm>(({ model }) => { ... }):
|
|
150
|
+
validate(model.$.birthDate, [validateAdultAge]);
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### Anti-patterns to avoid
|
|
154
|
+
|
|
155
|
+
- `import { computeFrom } from '@reformer/core/behaviors'` для примитива, вызываемого вне
|
|
156
|
+
`defineFormBehavior` → примитив живёт в `@reformer/core` (возвращает cleanup).
|
|
157
|
+
- `interface MyForm { ... }` для form-shape → см. Recipe 2.
|
|
158
|
+
- `as`-касты значений полей внутри `compute` → читай `model.field` напрямую (Recipe 5).
|
|
159
|
+
- строковые пути / `(form) => ...` в behaviors → это удалённый API, используй сигналы (`model.$.x`).
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
## 31. ASYNC VALIDATOR
|
|
2
|
+
|
|
3
|
+
Для проверок типа «уникальность email», «валидация ИНН через API», «проверка адреса» —
|
|
4
|
+
async-правило это `AsyncRule<T>` = `(value, { signal }) => Promise<ValidationError | null>`.
|
|
5
|
+
Оно регистрируется оператором `validateAsync(sig, [asyncRules])` внутри схемы валидации
|
|
6
|
+
(`@reformer/core/validation`) и исполняется внешним раннером `validateModel` (async-правила
|
|
7
|
+
прогоняются параллельно через `Promise.all`, раннер их дожидается).
|
|
8
|
+
|
|
9
|
+
Слои разделены: layout (`createForm`-схема / JSON) НЕ несёт валидаторов — правила живут в
|
|
10
|
+
отдельной функции-схеме `defineValidationSchema<T>(({ model }) => …)`.
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
import { createModel } from '@reformer/core';
|
|
14
|
+
import {
|
|
15
|
+
validate,
|
|
16
|
+
validateAsync,
|
|
17
|
+
defineValidationSchema,
|
|
18
|
+
validateModel,
|
|
19
|
+
type AsyncRule,
|
|
20
|
+
} from '@reformer/core/validation';
|
|
21
|
+
import { required, email } from '@reformer/core/validators';
|
|
22
|
+
|
|
23
|
+
// async-правило: (value, { signal }) => Promise<ValidationError | null>.
|
|
24
|
+
// `signal` — AbortSignal устаревшего прогона: прокинь его в fetch, чтобы отменить in-flight.
|
|
25
|
+
const checkEmailUnique: AsyncRule<string> = async (value, { signal }) => {
|
|
26
|
+
if (!value) return null; // пусто = валидно (sync `required` отдельно)
|
|
27
|
+
try {
|
|
28
|
+
const res = await fetch(`/api/check-email?email=${encodeURIComponent(value)}`, { signal });
|
|
29
|
+
const { available } = (await res.json()) as { available: boolean };
|
|
30
|
+
return available ? null : { code: 'email-taken', message: 'Email уже зарегистрирован' };
|
|
31
|
+
} catch {
|
|
32
|
+
return null; // сетевой сбой/отмена НЕ блокирует submit — возвращаем null, а не ошибку
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const model = createModel<{ email: string }>({ email: '' });
|
|
37
|
+
|
|
38
|
+
// Схема — обычная функция над моделью; sync и async — разными операторами на одном сигнале.
|
|
39
|
+
const schema = defineValidationSchema<{ email: string }>(({ model }) => {
|
|
40
|
+
validate(model.$.email, [required(), email()]); // sync-фабрики
|
|
41
|
+
validateAsync(model.$.email, [checkEmailUnique]); // async-правило
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Прогон по требованию (submit / шаг):
|
|
45
|
+
const ok = await validateModel(model, schema); // Promise<boolean>
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Как это исполняется
|
|
49
|
+
|
|
50
|
+
1. `validateModel(model, schema)` открывает ambient-окно и **синхронно** прогоняет схему:
|
|
51
|
+
`validate`/`validateAsync`/`cross` регистрируют правила своих полей.
|
|
52
|
+
2. Sync-правила выполняются сразу; async-правила из `validateAsync` собираются и после закрытия
|
|
53
|
+
ambient-окна дожидаются параллельно (`Promise.all`) с прокинутым `AbortSignal`.
|
|
54
|
+
3. Ошибки роутятся в ноды формы (`getNodeForSignal(sig).setErrors(...)`), UI подсвечивает поле;
|
|
55
|
+
поля, ставшие валидными, гасятся (`setErrors([])`).
|
|
56
|
+
4. Возвращает `Promise<boolean>` — `true`, если нет блокирующих ошибок (`severity:'warning'`
|
|
57
|
+
не блокирует). Устаревший (отменённый) прогон возвращает `false` — ему нельзя доверять для submit.
|
|
58
|
+
|
|
59
|
+
Синхронного варианта у нового контракта нет: раннер один — `validateModel`, и он всегда `async`.
|
|
60
|
+
|
|
61
|
+
### Sync и async — два оператора
|
|
62
|
+
|
|
63
|
+
Разделение sync/async делается не полем схемы, а разными операторами на одном сигнале:
|
|
64
|
+
`validate(sig, [syncRules])` и `validateAsync(sig, [asyncRules])`. Раннер прогоняет sync-правила
|
|
65
|
+
инлайн, а async — дожидается. Держи async-правило дешёвым: ранний `if (!value) return null`
|
|
66
|
+
пропускает сетевой вызов, пока `required()`/формат ещё не пройдены.
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
validate(model.$.inn, [required(), pattern(/^\d{12}$/)]); // формат — sync, мгновенно
|
|
70
|
+
validateAsync(model.$.inn, [checkInnInRegistry]); // обращение к API — отдельным оператором
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### UI integration
|
|
74
|
+
|
|
75
|
+
`validateModel(...)` возвращает `Promise<boolean>` — индикатор проверки держи вокруг этого
|
|
76
|
+
`await`. Раннер схемы **не** выставляет per-field `pending` на ноде (он роутит только ошибки через
|
|
77
|
+
`setErrors`), поэтому спиннер async-валидации — это собственный флаг (обычно form-level, как
|
|
78
|
+
`ui.pending` в submit-флоу, или локальный state компонента):
|
|
79
|
+
|
|
80
|
+
```tsx
|
|
81
|
+
const [checking, setChecking] = useState(false);
|
|
82
|
+
|
|
83
|
+
const runValidation = async (): Promise<boolean> => {
|
|
84
|
+
setChecking(true);
|
|
85
|
+
try {
|
|
86
|
+
return await validateModel(model, schema);
|
|
87
|
+
} finally {
|
|
88
|
+
setChecking(false);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
return checking ? <Spinner /> : errors.length ? <Error errors={errors} /> : null;
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Debounce и отмена
|
|
96
|
+
|
|
97
|
+
- **Отмена устаревших — раннером, бесплатно.** Быстрый повторный `validateModel(model, schema)`
|
|
98
|
+
той же пары `(model, schema)` отменяет предыдущий in-flight прогон через `AbortController`;
|
|
99
|
+
его `AbortSignal` прокинут в `validateAsync`-правила, поэтому `fetch(url, { signal })` рвётся
|
|
100
|
+
сам. Ручной debounce для КОРРЕКТНОСТИ не нужен — держи схему стабильным `const`
|
|
101
|
+
(`defineValidationSchema`), иначе отмена не сматчит прогоны по идентичности.
|
|
102
|
+
- Валидация запускается on-demand (submit / шаг / через `revalidateWhen`), а не на каждый
|
|
103
|
+
keystroke — отдельный `debounce` в правиле обычно не нужен.
|
|
104
|
+
- Если нужно дебаунсить дорогой async-валидатор относительно частых изменений — триггерь прогон
|
|
105
|
+
из поведения через `onChange`, который даёт debounce из коробки:
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
import { defineFormBehavior, onChange } from '@reformer/core/behaviors';
|
|
109
|
+
import { validateModel } from '@reformer/core/validation';
|
|
110
|
+
|
|
111
|
+
export const behavior = defineFormBehavior<{ username: string }>(({ model }) => {
|
|
112
|
+
onChange(model.$.username, () => void validateModel(model, schema), { debounce: 300 });
|
|
113
|
+
});
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Либо оборачивай в свой debounce колбэк `revalidateWhen([...], () => void validateModel(...))`.
|
|
117
|
+
- **Async cross-field** — инлайн `validateAsync`-правило, замыкающее `model` и читающее снапшот
|
|
118
|
+
соседей `model.get()` до первого `await` (у `AsyncRule` нет `root`, только `value` + `signal`;
|
|
119
|
+
`cross(...)` — синхронный):
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
validateAsync(model.$.email, [
|
|
123
|
+
async (email, { signal }) => {
|
|
124
|
+
const { username } = model.get(); // снапшот соседних полей до await
|
|
125
|
+
const res = await fetch(`/api/check?email=${email}&user=${username}`, { signal });
|
|
126
|
+
return (await res.json()).ok ? null : { code: 'conflict', message: 'Пара занята' };
|
|
127
|
+
},
|
|
128
|
+
]);
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### See also
|
|
132
|
+
|
|
133
|
+
- [27-revalidate-when.md](27-revalidate-when.md) — перезапуск `validateModel` по триггерам
|
|
134
|
+
- [29-async-preload.md](29-async-preload.md) — async preload данных при init формы
|
|
135
|
+
- [32-async-options-loading.md](32-async-options-loading.md) — `onChange` с debounce + AbortSignal
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
## 32. ASYNC OPTIONS LOADING
|
|
2
|
+
|
|
3
|
+
Для динамической подгрузки опций dropdown'а по значению другого поля (`region` → `city options`,
|
|
4
|
+
`carBrand` → `carModel options`) — используй `onChange` из `@reformer/core/behaviors` +
|
|
5
|
+
`form.field.updateComponentProps({ options })`. `onChange` даёт debounce и AbortSignal из коробки.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { defineFormBehavior, onChange } from '@reformer/core/behaviors';
|
|
9
|
+
|
|
10
|
+
type CityOption = { value: string; label: string };
|
|
11
|
+
|
|
12
|
+
async function fetchCitiesByRegion(region: string, opts?: { signal?: AbortSignal }): Promise<CityOption[]> {
|
|
13
|
+
const res = await fetch(`/api/cities?region=${encodeURIComponent(region)}`, { signal: opts?.signal });
|
|
14
|
+
return res.json();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const behavior = defineFormBehavior<MyForm>(({ model, form }) => {
|
|
18
|
+
onChange(
|
|
19
|
+
model.$.registrationAddress.region,
|
|
20
|
+
async (region, { signal }) => {
|
|
21
|
+
// сбросить зависимое поле при смене source
|
|
22
|
+
model.registrationAddress.city = '';
|
|
23
|
+
|
|
24
|
+
if (!region) {
|
|
25
|
+
form.registrationAddress.city.updateComponentProps({ options: [] });
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// (опционально) loading-состояние
|
|
30
|
+
form.registrationAddress.city.updateComponentProps({ loading: true, options: [] });
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const options = await fetchCitiesByRegion(region, { signal }); // отмена устаревших
|
|
34
|
+
form.registrationAddress.city.updateComponentProps({ loading: false, options });
|
|
35
|
+
} catch (e) {
|
|
36
|
+
if ((e as Error).name === 'AbortError') return; // устаревший запрос — молча выходим
|
|
37
|
+
form.registrationAddress.city.updateComponentProps({ loading: false, options: [] });
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
{ debounce: 300 }
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Lifecycle
|
|
46
|
+
|
|
47
|
+
1. `onChange` подписан на изменения `model.$.region`.
|
|
48
|
+
2. Debounce 300 мс (не fetch на каждое нажатие клавиши).
|
|
49
|
+
3. Async `fetchCitiesByRegion(region, { signal })` — `signal` отменяет устаревший запрос при
|
|
50
|
+
следующей смене значения (защита от race).
|
|
51
|
+
4. Результат — `updateComponentProps({ options })` на target-поле.
|
|
52
|
+
5. UI (Select / Combobox) обновляется через сигнал `componentProps`.
|
|
53
|
+
|
|
54
|
+
### Переиспользуемый оператор
|
|
55
|
+
|
|
56
|
+
Как в монорепо — собираешь свой оператор поверх `onChange`:
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
import { onChange } from '@reformer/core/behaviors';
|
|
60
|
+
import type { ReadonlySignal } from '@reformer/core/behaviors';
|
|
61
|
+
|
|
62
|
+
function loadOptionsOn<TValue, TOption>(
|
|
63
|
+
source: ReadonlySignal<TValue>,
|
|
64
|
+
target: { updateComponentProps(p: Record<string, unknown>): void; reset?: () => void },
|
|
65
|
+
fetcher: (value: TValue, opts?: { signal?: AbortSignal }) => Promise<TOption[]>,
|
|
66
|
+
options: { debounce?: number; resetTarget?: boolean } = {}
|
|
67
|
+
): void {
|
|
68
|
+
const { debounce = 300, resetTarget = false } = options;
|
|
69
|
+
onChange(
|
|
70
|
+
source,
|
|
71
|
+
async (value, { signal }) => {
|
|
72
|
+
if (resetTarget) target.reset?.();
|
|
73
|
+
if (!value) { target.updateComponentProps({ options: [] }); return; }
|
|
74
|
+
try {
|
|
75
|
+
const data = await fetcher(value, { signal });
|
|
76
|
+
target.updateComponentProps({ options: data });
|
|
77
|
+
} catch {
|
|
78
|
+
target.updateComponentProps({ options: [] });
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
{ debounce }
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Usage:
|
|
86
|
+
loadOptionsOn(model.$.carBrand, form.carModel, fetchCarModels, { resetTarget: true });
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Common patterns
|
|
90
|
+
|
|
91
|
+
- **Debounce 300–500 мс** — баланс UX и rate-limit.
|
|
92
|
+
- **Reset target value** — при смене source очисти зависимое поле (`model.city = ''`), чтобы не
|
|
93
|
+
остался устаревший выбор.
|
|
94
|
+
- **Loading state** — `updateComponentProps({ loading: true })` пока идёт fetch (компонент должен поддержать).
|
|
95
|
+
- **Cancellation** — `onChange` даёт `{ signal }` (AbortSignal); передавай его в `fetch` и
|
|
96
|
+
игнорируй `AbortError`.
|
|
97
|
+
|
|
98
|
+
### Initial load (preload at form mount)
|
|
99
|
+
|
|
100
|
+
Для загрузки опций при инициализации формы — external hook + `model.set` + `updateComponentProps`
|
|
101
|
+
(см. [29-async-preload.md](29-async-preload.md)). `onChange` не срабатывает на init (по умолчанию
|
|
102
|
+
`immediate: false`); для запуска сразу передай `{ immediate: true }`.
|
|
103
|
+
|
|
104
|
+
### See also
|
|
105
|
+
|
|
106
|
+
- [11-async-watchfield.md](11-async-watchfield.md) — общий паттерн `onChange`
|
|
107
|
+
- [29-async-preload.md](29-async-preload.md) — preload данных при инициализации
|
|
108
|
+
- [31-async-validator-debounce.md](31-async-validator-debounce.md) — async **валидация** (не options)
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# useFormValidation — Единый выбор стратегии валидации
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
Одна декларативная точка выбора «когда прогонять схему валидации» вместо ручной разводки
|
|
6
|
+
`markAsTouched` + `validateModel` + `revalidateWhen` + `useState(pending)`. Аддитивный слой над
|
|
7
|
+
функциональной схемой (`@reformer/core/validation`): переиспользует `validateModel` как
|
|
8
|
+
ЕДИНСТВЕННЫЙ движок (роутинг ошибок в ноды, отмена устаревших прогонов, дедуп по `(model, schema)`),
|
|
9
|
+
второго движка не заводит. Node-level `updateOn` / `debounce` на самой ноде поля (legacy-триггеры)
|
|
10
|
+
этот API НЕ трогает.
|
|
11
|
+
|
|
12
|
+
Стратегия определяет только момент запуска и раскрытие ошибок — сами правила остаются в отдельной
|
|
13
|
+
`defineValidationSchema<T>(({ model }) => …)`.
|
|
14
|
+
|
|
15
|
+
## API
|
|
16
|
+
|
|
17
|
+
Основной способ — React-хук `useFormValidation` из `@reformer/core` (рядом с `useFormControl`).
|
|
18
|
+
Под ним — headless-фабрика `createFormValidation` из `@reformer/core/validation` (для SSR / тестов /
|
|
19
|
+
не-React потребителей).
|
|
20
|
+
|
|
21
|
+
```typescript
|
|
22
|
+
// React (@reformer/core)
|
|
23
|
+
function useFormValidation<T>(args: {
|
|
24
|
+
model: FormModel<T>;
|
|
25
|
+
schema: ValidationSchema<T>; // СТАБИЛЬНАЯ ссылка (module-level const / useMemo)
|
|
26
|
+
strategy?: ValidationStrategyKind; // default 'submit'
|
|
27
|
+
debounce?: number; // мс, для live-фаз (change / live-часть afterFirstSubmit)
|
|
28
|
+
liveAfterSubmit?: 'change' | 'blur'; // default 'change'
|
|
29
|
+
}): { submit: () => Promise<boolean>; validate: () => Promise<boolean>; isValidating: boolean };
|
|
30
|
+
|
|
31
|
+
// headless (@reformer/core/validation)
|
|
32
|
+
type ValidationStrategyKind = 'submit' | 'blur' | 'change' | 'afterFirstSubmit';
|
|
33
|
+
interface ValidationStrategyOptions {
|
|
34
|
+
strategy?: ValidationStrategyKind;
|
|
35
|
+
debounce?: number;
|
|
36
|
+
liveAfterSubmit?: 'change' | 'blur';
|
|
37
|
+
}
|
|
38
|
+
interface FormValidationController {
|
|
39
|
+
validate(): Promise<boolean>; // полный прогон, touch:true (раскрыть ВСЕ ошибки); переводит afterFirstSubmit в live-фазу
|
|
40
|
+
start(): () => void; // армит реактивные подписки (ТОЛЬКО на клиенте) → dispose
|
|
41
|
+
dispose(): void;
|
|
42
|
+
readonly isValidating: boolean;
|
|
43
|
+
readonly validating: ReadonlySignal<boolean>;
|
|
44
|
+
}
|
|
45
|
+
function createFormValidation<T>(
|
|
46
|
+
model: FormModel<T>,
|
|
47
|
+
schema: ValidationSchema<T>,
|
|
48
|
+
options?: ValidationStrategyOptions,
|
|
49
|
+
): FormValidationController;
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
- `useFormValidation` мемоизирует контроллер по `[model, schema, strategy, debounce, liveAfterSubmit]`,
|
|
53
|
+
армит стратегию в `useEffect` (SSR-safe), а `submit()` = `controller.validate()`.
|
|
54
|
+
- `createFormValidation` ЧИСТА до `start()` (никаких подписок) → SSR-safe; `start()` возвращает
|
|
55
|
+
`dispose` и арминует триггеры только на клиенте.
|
|
56
|
+
|
|
57
|
+
### Стратегии
|
|
58
|
+
|
|
59
|
+
| strategy | когда прогон | touch | раскрытие ошибок |
|
|
60
|
+
| --- | --- | --- | --- |
|
|
61
|
+
| `submit` (default) | только `validate()` / `submit()` | `true` | всё на submit |
|
|
62
|
+
| `blur` | смена `touched` поля (потеря фокуса) | `false` | только сблюренные (по `touched`) |
|
|
63
|
+
| `change` | на каждый ввод (+ `debounce`) | `false` | только редактированные (по `dirty`) |
|
|
64
|
+
| `afterFirstSubmit` | тихо до 1-го submit, затем live (`liveAfterSubmit: 'change'` \| `'blur'`, default `'change'`) | `true` один раз на 1-м submit, дальше `false` | тихо → раскрыть всё на submit → далее live |
|
|
65
|
+
|
|
66
|
+
## Examples
|
|
67
|
+
|
|
68
|
+
### Простая форма — afterFirstSubmit + debounce
|
|
69
|
+
|
|
70
|
+
Тихо до первой отправки, `submit()` раскрывает все ошибки и переводит форму в живую фазу
|
|
71
|
+
(дальше проверка на ввод с задержкой 400 мс). `submit()` сам метит `touched` — ручной
|
|
72
|
+
`markAsTouched` не нужен.
|
|
73
|
+
|
|
74
|
+
```tsx
|
|
75
|
+
import { createModel, createForm, useFormValidation } from '@reformer/core';
|
|
76
|
+
import { defineValidationSchema, validate } from '@reformer/core/validation';
|
|
77
|
+
import { required, email, minLength } from '@reformer/core/validators';
|
|
78
|
+
|
|
79
|
+
type RegistrationData = { username: string; email: string; password: string };
|
|
80
|
+
|
|
81
|
+
// Схема — стабильный module-level const (иначе ломается дедуп раннера).
|
|
82
|
+
const registrationValidation = defineValidationSchema<RegistrationData>(({ model }) => {
|
|
83
|
+
validate(model.$.username, [required(), minLength(3)]);
|
|
84
|
+
validate(model.$.email, [required(), email()]);
|
|
85
|
+
validate(model.$.password, [required(), minLength(8)]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
function RegistrationForm() {
|
|
89
|
+
const model = useMemo(() => createModel<RegistrationData>({ username: '', email: '', password: '' }), []);
|
|
90
|
+
const form = useMemo(() => createForm({ model }), [model]);
|
|
91
|
+
|
|
92
|
+
const { submit, isValidating } = useFormValidation({
|
|
93
|
+
model,
|
|
94
|
+
schema: registrationValidation,
|
|
95
|
+
strategy: 'afterFirstSubmit',
|
|
96
|
+
debounce: 400,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const onSubmit = async (e: React.FormEvent) => {
|
|
100
|
+
e.preventDefault();
|
|
101
|
+
if (await submit()) await api.save(model.get()); // true = нет блокирующих ошибок
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<form onSubmit={onSubmit}>
|
|
106
|
+
<FormField control={form.username} />
|
|
107
|
+
<FormField control={form.email} />
|
|
108
|
+
<FormField control={form.password} />
|
|
109
|
+
<button type="submit" disabled={isValidating}>Отправить</button>
|
|
110
|
+
</form>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### Wizard — live-стратегия внутри активного шага
|
|
116
|
+
|
|
117
|
+
`defineSteps(..., { strategy })` задаёт ЖИВУЮ стратегию ВНУТРИ шага; `useWizardStepValidation`
|
|
118
|
+
арминует её под текущий шаг (`currentStep` из `FormWizardContext`) и снимает при смене шага / unmount.
|
|
119
|
+
Per-step gate (`validateStep` на «Далее») и `validateAll` (submit) при этом НЕ меняются — стратегия
|
|
120
|
+
добавляет живой слой ПОВЕРХ них. No-op, если `strategy` не задана / `'submit'` / у шага нет правил.
|
|
121
|
+
|
|
122
|
+
```tsx
|
|
123
|
+
import { defineSteps, useWizardStepValidation, FormWizard } from '@reformer/cdk/form-wizard';
|
|
124
|
+
|
|
125
|
+
const config = defineSteps<'loan' | 'applicant' | 'confirm', CreditForm>(model, {
|
|
126
|
+
steps: { loan: loanStep, applicant: applicantStep, confirm: null }, // confirm — без правил, ЯВНО
|
|
127
|
+
extras: crossFieldRules, // применяются только в validateAll (submit)
|
|
128
|
+
strategy: 'blur', // живая валидация полей текущего шага при потере фокуса
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
function LoanStepBody() {
|
|
132
|
+
useWizardStepValidation(config); // армит 'blur' под активный шаг, снимает при переходе
|
|
133
|
+
return <>{/* поля шага */}</>;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
<FormWizard form={form} config={config}>
|
|
137
|
+
<LoanStepBody />
|
|
138
|
+
</FormWizard>;
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### Headless / SSR / тесты — createFormValidation
|
|
142
|
+
|
|
143
|
+
```typescript
|
|
144
|
+
import { createFormValidation } from '@reformer/core/validation';
|
|
145
|
+
|
|
146
|
+
const ctrl = createFormValidation(model, registrationValidation, {
|
|
147
|
+
strategy: 'afterFirstSubmit',
|
|
148
|
+
debounce: 300,
|
|
149
|
+
});
|
|
150
|
+
const dispose = ctrl.start(); // ТОЛЬКО на клиенте (в React — из useEffect)
|
|
151
|
+
const ok = await ctrl.validate(); // submit → раскрыть все ошибки
|
|
152
|
+
dispose();
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## Anti-patterns
|
|
156
|
+
|
|
157
|
+
```typescript
|
|
158
|
+
// ❌ schema пересоздаётся каждый рендер → контроллер и дедуп раннера ломаются
|
|
159
|
+
function MyForm() {
|
|
160
|
+
const schema = defineValidationSchema<Form>(({ model }) => { … }); // новая ссылка на каждый рендер
|
|
161
|
+
useFormValidation({ model, schema, strategy: 'change' });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ✅ Стабильная ссылка: module-level const (или useMemo с пустыми deps)
|
|
165
|
+
const formValidation = defineValidationSchema<Form>(({ model }) => { … });
|
|
166
|
+
function MyForm() {
|
|
167
|
+
useFormValidation({ model, schema: formValidation, strategy: 'change' });
|
|
168
|
+
}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
```typescript
|
|
172
|
+
// ❌ Node-level updateOn (Слой B) И активная schema-стратегия на ОДНОМ поле → оба пишут ошибки в ноду → мерцание
|
|
173
|
+
// узел поля: { updateOn: 'blur', … } + useFormValidation({ strategy: 'blur', schema: включает это же поле })
|
|
174
|
+
|
|
175
|
+
// ✅ Одно поле обслуживает ОДИН слой: либо node-level updateOn, либо schema-стратегия — не оба сразу
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
```typescript
|
|
179
|
+
// ❌ start() на сервере — арминует реактивные подписки при SSR
|
|
180
|
+
const ctrl = createFormValidation(model, schema, { strategy: 'change' });
|
|
181
|
+
ctrl.start(); // на сервере подписок быть не должно
|
|
182
|
+
|
|
183
|
+
// ✅ Фабрика чиста до start(); армить только на клиенте (useFormValidation делает это в useEffect)
|
|
184
|
+
const ctrl = createFormValidation(model, schema, { strategy: 'change' });
|
|
185
|
+
if (typeof window !== 'undefined') ctrl.start();
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## See also
|
|
189
|
+
|
|
190
|
+
- [28-submit-and-reset.md](./28-submit-and-reset.md) — submit-флоу и `validateModel` (движок под стратегией)
|
|
191
|
+
- [27-revalidate-when.md](./27-revalidate-when.md) — ручные триггеры перевалидации (низкоуровневый примитив)
|
|
192
|
+
- [31-async-validator-debounce.md](./31-async-validator-debounce.md) — async-правила и отмена устаревших прогонов
|
|
193
|
+
- [13-multi-step.md](./13-multi-step.md) — per-step / полная валидация wizard (`validateStep` / `validateAll`)
|