@reformer/core 10.2.0 → 11.0.0-beta.1

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.
Files changed (54) hide show
  1. package/dist/form/form-submitter.d.ts +0 -4
  2. package/dist/form/hooks/types.d.ts +3 -3
  3. package/dist/form/index.d.ts +0 -3
  4. package/dist/form/nodes/array-node.d.ts +0 -84
  5. package/dist/form/nodes/form-node.d.ts +0 -14
  6. package/dist/form/nodes/group-node.d.ts +0 -4
  7. package/dist/form/status-machine.d.ts +0 -38
  8. package/dist/form/types/contracts.d.ts +0 -6
  9. package/dist/form/types/deep-schema.d.ts +0 -9
  10. package/dist/form/types/index.d.ts +4 -38
  11. package/dist/form/types/schema-node.d.ts +13 -16
  12. package/dist/form/types/validation-schema.d.ts +0 -25
  13. package/dist/form/unique-id.d.ts +3 -11
  14. package/dist/form/validators.d.ts +8 -3
  15. package/dist/index.d.ts +1 -1
  16. package/dist/index.js +429 -617
  17. package/dist/runtime-token.d.ts +23 -0
  18. package/dist/validators.d.ts +0 -4
  19. package/dist/validators.js +88 -122
  20. package/docs/llms/01-api-reference.md +63 -0
  21. package/docs/llms/02-quick-start.md +143 -0
  22. package/docs/llms/03-api-signatures.md +273 -0
  23. package/docs/llms/04-common-patterns.md +202 -0
  24. package/docs/llms/05-common-mistakes.md +167 -0
  25. package/docs/llms/06-troubleshooting.md +11 -0
  26. package/docs/llms/07-complete-import.md +99 -0
  27. package/docs/llms/08-form-types.md +47 -0
  28. package/docs/llms/09-formschema.md +143 -0
  29. package/docs/llms/10-arrays.md +161 -0
  30. package/docs/llms/11-async-watchfield.md +53 -0
  31. package/docs/llms/12-array-cleanup.md +31 -0
  32. package/docs/llms/13-multi-step.md +91 -0
  33. package/docs/llms/14-extended-mistakes.md +98 -0
  34. package/docs/llms/15-project-structure.md +125 -0
  35. package/docs/llms/16-ui-components.md +189 -0
  36. package/docs/llms/17-nonexistent-api.md +93 -0
  37. package/docs/llms/18-conditional-fields.md +183 -0
  38. package/docs/llms/19-reading-values.md +59 -0
  39. package/docs/llms/20-compute-vs-watch.md +138 -0
  40. package/docs/llms/21-array-operations.md +113 -0
  41. package/docs/llms/22-cycle-detection.md +86 -0
  42. package/docs/llms/23-copy-from.md +141 -0
  43. package/docs/llms/24-sync-fields.md +114 -0
  44. package/docs/llms/25-reset-when.md +115 -0
  45. package/docs/llms/26-transform-value.md +136 -0
  46. package/docs/llms/27-revalidate-when.md +132 -0
  47. package/docs/llms/28-submit-and-reset.md +259 -0
  48. package/docs/llms/29-async-preload.md +193 -0
  49. package/docs/llms/30-type-safety-recipes.md +159 -0
  50. package/docs/llms/31-async-validator-debounce.md +135 -0
  51. package/docs/llms/32-async-options-loading.md +108 -0
  52. package/docs/llms/33-validation-strategy.md +193 -0
  53. package/llms.txt +144 -1557
  54. package/package.json +33 -10
@@ -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`.
@@ -0,0 +1,167 @@
1
+ ## 4. COMMON MISTAKES
2
+
3
+ ### Imports rule (#1 cause of cascading errors — read first)
4
+
5
+ - Модель/форма/хуки/типы (`FormModel`, `ValidationError`)/**примитивы behaviors** — из `@reformer/core`.
6
+ - Схема валидации (`defineValidationSchema` + операторы `validate`/`validateAsync`/`validateWhen`/`cross`/`each`/`apply`) и раннер `validateModel` — из `@reformer/core/validation`.
7
+ - Чистые фабрики валидаторов (`required`/`min`/`email`/…) — из `@reformer/core/validators`.
8
+ - Декларативный DSL поведения (`defineFormBehavior` + операторы) — из `@reformer/core/behaviors`.
9
+
10
+ ```typescript
11
+ // ✅ CORRECT
12
+ import {
13
+ createModel,
14
+ createForm,
15
+ useFormControl,
16
+ useFormControlValue,
17
+ type FormProxy,
18
+ type FieldConfig,
19
+ type FormModel,
20
+ type ValidationError,
21
+ } from '@reformer/core';
22
+ // схема валидации + раннер — отдельный слой:
23
+ import {
24
+ defineValidationSchema,
25
+ validate,
26
+ validateAsync,
27
+ validateWhen,
28
+ cross,
29
+ each,
30
+ apply,
31
+ validateModel,
32
+ type Rule,
33
+ type AsyncRule,
34
+ type ValidationSchema,
35
+ } from '@reformer/core/validation';
36
+ import { required, min, max, email } from '@reformer/core/validators';
37
+ // либо примитивы behaviors из основного пакета:
38
+ import { computeFrom, enableWhen, copyFrom } from '@reformer/core';
39
+ // либо декларативный DSL поведения:
40
+ import { defineFormBehavior, compute, onChange } from '@reformer/core/behaviors';
41
+ ```
42
+
43
+ > **watchField живёт в `@reformer/core`** (низкоуровневый примитив), НЕ в `@reformer/core/behaviors`.
44
+ > В DSL для реакции на изменения используется `onChange`.
45
+
46
+ ### Значения — в модели, не в форме
47
+
48
+ Под M1 источник истины значений — модель. Форма (ноды) отражает их.
49
+
50
+ ```typescript
51
+ // ✅ читаем/пишем значение
52
+ model.email = 'a@b.c'; // value-доступ
53
+ model.$.email.value; // сигнал
54
+ model.get(); // весь снимок для submit
55
+
56
+ // В компоненте — реактивно через хуки
57
+ const email = useFormControlValue(form.email);
58
+ ```
59
+
60
+ ### useFormControlValue (CRITICAL)
61
+
62
+ ```typescript
63
+ // WRONG - useFormControlValue returns T directly, NOT { value: T }
64
+ const { value: loanType } = useFormControlValue(control.loanType);
65
+ // Result: loanType is ALWAYS undefined!
66
+
67
+ // CORRECT
68
+ const loanType = useFormControlValue(control.loanType);
69
+
70
+ // ALSO CORRECT - useFormControl returns object
71
+ const { value, errors } = useFormControl(control.loanType);
72
+ ```
73
+
74
+ ### Behaviors принимают СИГНАЛЫ, а не пути/форму
75
+
76
+ ```typescript
77
+ // ❌ WRONG - строковые пути и (form) => ... это старый API, удалён
78
+ enableWhen(path.city, (form) => Boolean(form.country));
79
+ computeFrom(['price', 'quantity'], 'total', (values) => values.price * values.quantity);
80
+
81
+ // ✅ CORRECT - сигналы модели, условие читает model напрямую
82
+ enableWhen(model.$.city, () => Boolean(model.country), { resetOnDisable: true });
83
+ computeFrom([model.$.price, model.$.quantity], model.$.total, (price, qty) => price * qty);
84
+ ```
85
+
86
+ ### Валидаторы — оператор `validate`, а не layout-нода
87
+
88
+ Валидация — **отдельный слой** (`@reformer/core/validation`), а не поле layout-ноды. Layout
89
+ (схема `createForm` / JSON-DSL) больше **не несёт** `validators`: правила живут в `ValidationSchema`,
90
+ а прогоняет их внешний раннер `validateModel`. ⚠️ `form.validate()`/`submit()` schema-валидацию
91
+ **НЕ запускают** — прогон только через `validateModel(model, schema)`.
92
+
93
+ ```typescript
94
+ // ❌ WRONG - дерево { value, validators } и позиционная строка удалены
95
+ const schema = {
96
+ email: { value: model.$.email, component: Input, validators: [required(), email()] },
97
+ };
98
+ validate(path.email, required(), 'Email is required'); // старая сигнатура validate() — удалена
99
+
100
+ // ✅ CORRECT - операторы validate/validateAsync внутри defineValidationSchema; фабрики с options
101
+ const emailSchema = defineValidationSchema<MyForm>(({ model }) => {
102
+ validate(model.$.email, [required({ message: 'Email is required' }), email()]);
103
+ validateAsync(model.$.email, [
104
+ async (value, { signal }) => {
105
+ const res = await fetch(`/api/free?email=${value}`, { signal });
106
+ return (await res.json()).free ? null : { code: 'taken', message: 'Email занят' };
107
+ },
108
+ ]);
109
+ });
110
+
111
+ // прогон по требованию (submit/шаг): ошибки сами доезжают до нод формы, warnings не блокируют
112
+ const ok = await validateModel(model, emailSchema);
113
+ ```
114
+
115
+ ### Cross-field — оператор `cross`, а не `ctx.form`
116
+
117
+ ```typescript
118
+ // ❌ WRONG - ctx.form / ctx.setFieldValue / value.value — старый API; ModelValidator(value,scope,root) удалён
119
+ watchField(path.amount, (amount, ctx) => {
120
+ const rate = ctx.form.rate.value.value;
121
+ ctx.setFieldValue('total', amount * rate);
122
+ });
123
+
124
+ // ✅ CORRECT - compute (поведение) на сигналах; cross-field (валидация) через оператор cross
125
+ compute(model.$.total, () => (model.amount ?? 0) * (model.rate ?? 0));
126
+
127
+ // cross-field правило — обычная функция над снапшотом Root (`model.get()`), навешивается через cross(sig, fn):
128
+ const amountVsMax = (f: MyForm): ValidationError | null =>
129
+ f.amount != null && f.maxAmount != null && f.amount > f.maxAmount
130
+ ? { code: 'tooBig', message: 'Превышает лимит' }
131
+ : null;
132
+
133
+ const amountSchema = defineValidationSchema<MyForm>(({ model }) => {
134
+ cross(model.$.amount, amountVsMax); // fn получает model.get(), вешает ошибку на model.$.amount
135
+ });
136
+ ```
137
+
138
+ ### Form-shape types — `type` over `interface`
139
+
140
+ ```typescript
141
+ // ❌ interface лишён неявной index signature; конструкции ArrayNode<T> его отвергают
142
+ export interface PropertyItem {
143
+ type: PropertyType;
144
+ description: string;
145
+ }
146
+
147
+ // ✅ type alias структурно совместим с Record<string, FormValue>
148
+ export type PropertyItem = {
149
+ type: PropertyType;
150
+ description: string;
151
+ };
152
+ ```
153
+
154
+ `number | null` — конвенциональный тип для очищенного поля; встроенные валидаторы
155
+ (`min`, `max`, `minLength`, `maxLength`, `minDate`, `maxDate`, `minAge`, `maxAge`) пропускают
156
+ пустые значения внутри.
157
+
158
+ ### Proxy не проходит instanceof
159
+
160
+ ```typescript
161
+ // ❌ instanceof на Proxy не работает
162
+ if (node instanceof FieldNode) { ... }
163
+
164
+ // ✅ type guards
165
+ import { isFieldNode, isGroupNode, isArrayNode } from '@reformer/core';
166
+ if (isFieldNode(node)) { ... }
167
+ ```
@@ -0,0 +1,11 @@
1
+ ## 5. TROUBLESHOOTING
2
+
3
+ | Error | Cause | Solution |
4
+ | ------------------------------------------------------ | -------------------------------------------------------- | ------------------------------------------------- |
5
+ | `'string' is not assignable to '{ message?: string }'` | Валидатору передали строку вместо options | Используй `required({ message: 'text' })` |
6
+ | `Module has no exported member` | Неверный источник импорта | `watchField`/примитивы — из `@reformer/core`; DSL — из `@reformer/core/behaviors`; фабрики — из `@reformer/core/validators` |
7
+ | `undefined` из `useFormControlValue` | Деструктурировали хук | `const v = useFormControlValue(...)` — без деструктуризации |
8
+ | `enableWhen`/`disableWhen` не срабатывает | Поле не материализовано в форме (элемент массива) | Убедись, что поле есть в схеме `createForm`; для per-item — `applyEach` |
9
+ | `Cycle detected` | Взаимные `compute`/`computeFrom` без стабилизации | Разорви цикл через условие `when` или `peek`; см. 22-cycle-detection.md |
10
+ | Значение поля не пишется | Пишем в форму вместо модели | Значения принадлежат модели: `model.field = ...` / `model.$.field.value = ...` |
11
+ | Ошибки не появляются после submit | Не вызвали `validateModel` (`form.submit()` НЕ гоняет schema-валидацию) | `await validateModel(model, schema)` из `@reformer/core/validation` — он роутит ошибки в ноды |