@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,93 @@
|
|
|
1
|
+
## 15. NON-EXISTENT API (DO NOT USE)
|
|
2
|
+
|
|
3
|
+
**Следующего API НЕТ в @reformer/core** (наследие старой path-based архитектуры,
|
|
4
|
+
удалено при переходе на M1 и при разделении валидации/поведения):
|
|
5
|
+
|
|
6
|
+
> ⚠️ Операторы валидации `validate`/`validateAsync`/`validateWhen`/`cross`/`each`/`apply`
|
|
7
|
+
> **существуют** — но живут в сабпути `@reformer/core/validation` (ambient-схема
|
|
8
|
+
> `defineValidationSchema(({ model }) => …)`, раннер `validateModel`), а НЕ в корне и НЕ
|
|
9
|
+
> в `@reformer/core/validators`. Ниже перечислено то, чего действительно нет.
|
|
10
|
+
|
|
11
|
+
| Wrong | Correct | Notes |
|
|
12
|
+
| -------------------------------- | ------------------------------------------------ | ---------------------------------------------- |
|
|
13
|
+
| `useForm` | `createModel` + `createForm` | Хука useForm нет |
|
|
14
|
+
| `validateForm`, `validateFormModel` | `validateModel(model, schema)` из `@reformer/core/validation` | Legacy-движок дерева `{ value, validators }` удалён; внешний раннер — `validateModel` |
|
|
15
|
+
| `validateModelSync` | `await validateModel(model, schema)` | Синхронного раннера нет — прогон асинхронный (`Promise<boolean>`) |
|
|
16
|
+
| `applyWhen` | `validateWhen(() => cond, () => { … })` | Условная валидация — оператором `validateWhen`, не узлом `{ when, children }` |
|
|
17
|
+
| `validateItems`, `validateGroup`, `validateTree` | `each(model.arr, (im) => { … })` | Per-item — оператором `each`; под-схема — прямой вызов `sub({ model: model.child })` |
|
|
18
|
+
| `ValidationSchemaFn`, `BehaviorSchemaFn` | `ValidationSchema<T>` (`defineValidationSchema`) / `defineFormBehavior` | Типы path-схем удалены |
|
|
19
|
+
| `equalTo`, `custom`, `notEmpty` (validators) | inline `Rule<T>` `(value) => err \| null` в `validate(sig, [...])`; сравнение полей — `cross(sig, fn)` | Таких фабрик нет; кастомное правило — обычная функция значения, cross-field — `cross` |
|
|
20
|
+
| `form.submit()` / `form.validate()` прогоняют схему | `await validateModel(model, schema)` перед submit/шагом | submit/validate НЕ запускают ambient-схему валидации |
|
|
21
|
+
| `FieldPath`, `FieldPathNode` | `model.$.field` (`PathAwareSignal`) | Пути заменены сигналами |
|
|
22
|
+
| `ctx.form.x.value.value` | `model.x` / `model.$.x.value` | В behaviors читаем модель напрямую |
|
|
23
|
+
| `ctx.setFieldValue(name, value)` | `model.x = value` / `compute(...)` | Не существует |
|
|
24
|
+
| `transformers`, `createTransformer` | `transformValue(signal, fn)` | Готового набора трансформеров нет |
|
|
25
|
+
| `useHiddenCondition` | `useFormControlValue` + условный рендер в JSX | Хука нет |
|
|
26
|
+
| `FormProvider`, `control` prop, `register()` | `<Component form={form} />`, `useFormControl(form.field)` | Форма передаётся через props |
|
|
27
|
+
| `getFieldValue()` | `model.field` / `useFormControlValue(form.field)`| Не существует |
|
|
28
|
+
|
|
29
|
+
### Common Import Errors
|
|
30
|
+
|
|
31
|
+
```typescript
|
|
32
|
+
// WRONG - этих символов / путей НЕ существует
|
|
33
|
+
import { useForm, validateForm, validateFormModel } from '@reformer/core'; // NO!
|
|
34
|
+
import { validate, applyWhen, equalTo } from '@reformer/core/validators'; // NO! операторов тут нет
|
|
35
|
+
import { transformers } from '@reformer/core/behaviors'; // NO!
|
|
36
|
+
import type { FieldPath, ValidationSchemaFn } from '@reformer/core'; // NO!
|
|
37
|
+
|
|
38
|
+
// CORRECT
|
|
39
|
+
import { createModel, createForm, useFormControl } from '@reformer/core';
|
|
40
|
+
import type { FormModel, ValidationError, FieldConfig } from '@reformer/core';
|
|
41
|
+
// Операторы валидации + раннер + defineValidationSchema — отдельный сабпуть:
|
|
42
|
+
import {
|
|
43
|
+
validate, validateAsync, validateWhen, cross, each, apply,
|
|
44
|
+
defineValidationSchema, validateModel,
|
|
45
|
+
type Rule, type AsyncRule, type ValidationSchema,
|
|
46
|
+
} from '@reformer/core/validation';
|
|
47
|
+
// Фабрики-валидаторы (value-only, принимают nullable):
|
|
48
|
+
import { required, email, min, minLength, pattern } from '@reformer/core/validators';
|
|
49
|
+
// Поведение (отдельный слой, контракт не менялся):
|
|
50
|
+
import { defineFormBehavior, compute, onChange, revalidateWhen } from '@reformer/core/behaviors';
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Schema Common Mistakes
|
|
54
|
+
|
|
55
|
+
Валидация и layout — **разные** контракты. Layout-узел привязывает поле к сигналу и
|
|
56
|
+
НЕ несёт `validators`; правила живут в отдельной `defineValidationSchema`.
|
|
57
|
+
|
|
58
|
+
```typescript
|
|
59
|
+
// WRONG - примитив/литерал вместо сигнала, и validators прямо в layout-узле
|
|
60
|
+
const schema = {
|
|
61
|
+
name: '', // нет привязки к сигналу
|
|
62
|
+
email: { value: '', validators: [required()] }, // value — литерал, а validators в layout больше нет
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// CORRECT - layout только связывает поле с сигналом модели...
|
|
66
|
+
const layout = {
|
|
67
|
+
name: { value: model.$.name, component: Input, componentProps: { label: 'Name' } },
|
|
68
|
+
email: { value: model.$.email, component: Input, componentProps: { label: 'Email' } },
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// ...а правила — отдельная ambient-схема (прогоняется раннером validateModel):
|
|
72
|
+
const validation = defineValidationSchema<Form>(({ model }) => {
|
|
73
|
+
validate(model.$.email, [required({ message: 'Email обязателен' }), email()]);
|
|
74
|
+
});
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Behaviors Common Mistakes
|
|
78
|
+
|
|
79
|
+
```typescript
|
|
80
|
+
// WRONG - строковые пути и (form) => ... — старый API
|
|
81
|
+
enableWhen(path.city, (form) => Boolean(form.country));
|
|
82
|
+
|
|
83
|
+
// CORRECT - сигналы модели, условие читает model
|
|
84
|
+
enableWhen(model.$.city, () => Boolean(model.country), { resetOnDisable: true });
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Поведение **не владеет** валидацией. Чтобы поведение инициировало прогон схемы — мост
|
|
88
|
+
`revalidateWhen` (validate/submit сами схему не запускают):
|
|
89
|
+
|
|
90
|
+
```typescript
|
|
91
|
+
// CORRECT - поведение дёргает внешний раннер валидации при изменении зависимости
|
|
92
|
+
revalidateWhen([model.$.password], () => void validateModel(model, validation));
|
|
93
|
+
```
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# Условные поля — видимость, доступность и валидация
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
«Показать поле X только если Y» под M1 решается тремя независимыми механизмами — выбирай по
|
|
6
|
+
тому, что именно должно быть условным:
|
|
7
|
+
|
|
8
|
+
1. **Видимость / доступность** (поле остаётся в модели, но выключается/включается и не валидируется)
|
|
9
|
+
— behaviors: `enableWhen`, а для условной записи значения — `compute` / `copyFrom` с опцией `{ when }`.
|
|
10
|
+
2. **Условная валидация** (правила применяются только в истинной ветке) — оператор
|
|
11
|
+
`validateWhen(() => cond, () => {…})` внутри схемы `defineValidationSchema`, прогоняется раннером `validateModel`.
|
|
12
|
+
3. **Скрытие из разметки** (узел вообще не рендерится) — `useFormControlValue` + условный рендер в JSX.
|
|
13
|
+
|
|
14
|
+
> Условная **валидация** — оператор `validateWhen` из `@reformer/core/validation`; старого branch-узла
|
|
15
|
+
> `{ when, children }` и хелпера `applyWhen` больше нет — см.
|
|
16
|
+
> [Anti-patterns удалённого контракта валидации](#anti-patterns-удалённого-контракта-валидации) и `17-nonexistent-api.md`.
|
|
17
|
+
|
|
18
|
+
## Видимость и доступность
|
|
19
|
+
|
|
20
|
+
`enableWhen(target, () => condition, { resetOnDisable })` из `@reformer/core/behaviors`
|
|
21
|
+
включает/выключает поле реактивно. При `disabled` поле не участвует в валидации; с
|
|
22
|
+
`resetOnDisable: true` его значение сбрасывается при выключении (иначе — сохраняется).
|
|
23
|
+
|
|
24
|
+
```typescript
|
|
25
|
+
import { defineFormBehavior, enableWhen, compute, copyFrom } from '@reformer/core/behaviors';
|
|
26
|
+
|
|
27
|
+
type CreditForm = {
|
|
28
|
+
loanType: 'mortgage' | 'car' | 'consumer';
|
|
29
|
+
propertyValue: number | null;
|
|
30
|
+
initialPayment: number | null;
|
|
31
|
+
sameEmail: boolean;
|
|
32
|
+
email: string;
|
|
33
|
+
emailAdditional: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const creditBehavior = defineFormBehavior<CreditForm>(({ model }) => {
|
|
37
|
+
// один или несколько таргетов; условие читает model.* реактивно
|
|
38
|
+
enableWhen(
|
|
39
|
+
[model.$.propertyValue, model.$.initialPayment],
|
|
40
|
+
() => model.loanType === 'mortgage',
|
|
41
|
+
{ resetOnDisable: true },
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
// условная ЗАПИСЬ производного значения — compute с { when }
|
|
45
|
+
compute(model.$.initialPayment, () => Math.round((model.propertyValue ?? 0) * 0.2), {
|
|
46
|
+
when: () => model.loanType === 'mortgage',
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// условное КОПИРОВАНИЕ из другого поля — copyFrom с { when }
|
|
50
|
+
copyFrom(model.$.email, model.$.emailAdditional, { when: () => model.sameEmail === true });
|
|
51
|
+
});
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Требования:
|
|
55
|
+
|
|
56
|
+
- **Все поля должны быть материализованы в модели** (`createModel`), даже те, что показываются
|
|
57
|
+
только под условием. `enableWhen`/`compute`/`copyFrom` принимают сигналы `model.$.field`, а не
|
|
58
|
+
строковые пути; сигнала для несуществующего поля нет.
|
|
59
|
+
- Поведение подключается к форме через `createForm({ model, schema, behavior })` — форма владеет
|
|
60
|
+
жизненным циклом, DSL-операторы регистрируют свой cleanup сами (ручной массив cleanup'ов не нужен).
|
|
61
|
+
- Таргетом может быть и **группа**: `enableWhen(model.$.residenceAddress, () => model.sameAsRegistration === false)`
|
|
62
|
+
проходит по поддереву; без `resetOnDisable` значение группы сохраняется (удобно, когда оно копируется
|
|
63
|
+
из другого источника).
|
|
64
|
+
|
|
65
|
+
> **Групповой таргет работает только у DSL-`enableWhen` из `@reformer/core/behaviors`**, не у
|
|
66
|
+
> одноимённого корневого `enableWhen` из `@reformer/core` (тот резолвит только leaf-сигналы через
|
|
67
|
+
> реестр → на group-сигнале это тихий no-op). `get_symbol_docs("enableWhen")` возвращает именно
|
|
68
|
+
> корневой, не group-capable вариант.
|
|
69
|
+
|
|
70
|
+
## Условная валидация
|
|
71
|
+
|
|
72
|
+
Правила, действующие только при выполнении условия, оборачиваются оператором
|
|
73
|
+
`validateWhen(() => cond, () => {…})` внутри схемы `defineValidationSchema<T>(({ model }) => …)`.
|
|
74
|
+
Условие `cond` читает модель напрямую (`model.field` — снимок на момент прогона): при истинном условии
|
|
75
|
+
раннер применяет вложенные `validate`/`cross`/…, при ложном — правила ветки **гасятся** (поля,
|
|
76
|
+
затронутые ранее, получают `setErrors([])`). Прогон — внешним раннером
|
|
77
|
+
`validateModel(model, schema): Promise<boolean>` (ошибки сами роутятся в ноды формы).
|
|
78
|
+
|
|
79
|
+
```typescript
|
|
80
|
+
import {
|
|
81
|
+
validate,
|
|
82
|
+
validateWhen,
|
|
83
|
+
defineValidationSchema,
|
|
84
|
+
validateModel,
|
|
85
|
+
} from '@reformer/core/validation';
|
|
86
|
+
import { required, min } from '@reformer/core/validators';
|
|
87
|
+
|
|
88
|
+
const schema = defineValidationSchema<CreditForm>(({ model }) => {
|
|
89
|
+
validate(model.$.loanType, [required()]);
|
|
90
|
+
|
|
91
|
+
// ветка применяется только для ипотеки
|
|
92
|
+
validateWhen(
|
|
93
|
+
() => model.loanType === 'mortgage',
|
|
94
|
+
() => {
|
|
95
|
+
validate(model.$.propertyValue, [required(), min(1_000_000)]);
|
|
96
|
+
validate(model.$.initialPayment, [required()]);
|
|
97
|
+
},
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const ok: boolean = await validateModel(model, schema);
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Переключение `loanType` с `mortgage` на другой тип автоматически снимает ошибки `required` с
|
|
105
|
+
`propertyValue`/`initialPayment` — при ложном условии ветка `validateWhen` гасит их (`setErrors([])`).
|
|
106
|
+
`validateWhen` управляет только **валидацией**; чтобы поля ещё и выпадали из состояния (сброс
|
|
107
|
+
значения), держи их под `enableWhen` из раздела выше — слои независимы.
|
|
108
|
+
|
|
109
|
+
## Скрытие в JSX
|
|
110
|
+
|
|
111
|
+
Чтобы условный блок вообще не рендерился, читай значение-триггер хуком `useFormControlValue(form.field)`
|
|
112
|
+
и делай ранний `return null`. Хук возвращает **только значение** (без деструктуризации `{ value }`).
|
|
113
|
+
|
|
114
|
+
```tsx
|
|
115
|
+
import { useFormControlValue } from '@reformer/core';
|
|
116
|
+
|
|
117
|
+
function MortgageFields({ form }: { form: CreditForm }) {
|
|
118
|
+
const loanType = useFormControlValue(form.loanType);
|
|
119
|
+
if (loanType !== 'mortgage') return null;
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<>
|
|
123
|
+
<PropertyValueField form={form} />
|
|
124
|
+
<InitialPaymentField form={form} />
|
|
125
|
+
</>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Скрытие в JSX — чисто визуальное: если поле должно ещё и **выпадать из валидации/состояния**,
|
|
131
|
+
сочетай его с `enableWhen({ resetOnDisable: true })` (доступность) или `validateWhen` (валидация).
|
|
132
|
+
|
|
133
|
+
## Anti-patterns удалённого контракта валидации
|
|
134
|
+
|
|
135
|
+
Условную валидацию раньше собирали иначе; эти формы удалены при переходе на `@reformer/core/validation`.
|
|
136
|
+
Ниже — **как НЕ надо** (чтобы не регрессировать); рабочий вариант — `validateWhen` из раздела выше.
|
|
137
|
+
|
|
138
|
+
- **Нет branch-узла `{ when, children }` в дереве схемы.** Старая схема была деревом
|
|
139
|
+
`{ value, validators: [...] }` с ветками `{ when: (scope, root) => boolean, children: [...] }`,
|
|
140
|
+
которое обходил `validateFormModel`. Теперь схема — функция `({ model }) => void`, а условие —
|
|
141
|
+
оператор `validateWhen(() => cond, () => {…})`; отдельного объекта-узла нет.
|
|
142
|
+
|
|
143
|
+
```typescript
|
|
144
|
+
// УДАЛЕНО — так больше нельзя
|
|
145
|
+
const schema = {
|
|
146
|
+
children: [
|
|
147
|
+
{ value: model.$.loanType, validators: [required()] },
|
|
148
|
+
{ when: (_s, root) => root.loanType === 'mortgage', children: [ /* … */ ] },
|
|
149
|
+
],
|
|
150
|
+
};
|
|
151
|
+
await validateFormModel(model, schema); // символа нет
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
- **`applyWhen` не существует** — ни как экспорт, ни как локальный сахар: он эмитил branch-узел,
|
|
155
|
+
которого больше нет. Оборачивай условные правила оператором `validateWhen`.
|
|
156
|
+
|
|
157
|
+
- **`ValidationSchemaFn` удалён** (тип старой path-схемы). Тип схемы — `ValidationSchema<T>`
|
|
158
|
+
(`(ctx: { model: FormModel<T> }) => void`), фабрика-обёртка — `defineValidationSchema<T>(fn)`.
|
|
159
|
+
|
|
160
|
+
- **Cross-field — обычная функция над снапшотом, а не `ModelValidator (value, scope, root)`.**
|
|
161
|
+
Соседние поля читаются из снимка модели (`f: Root`, эквивалент `model.get()`), а правило навешивается
|
|
162
|
+
оператором `cross(sig, fn)`. Каноничное использование — `complex-multy-step-form/schemas/validation.ts`.
|
|
163
|
+
|
|
164
|
+
```typescript
|
|
165
|
+
import { cross } from '@reformer/core/validation';
|
|
166
|
+
import type { ValidationError } from '@reformer/core';
|
|
167
|
+
|
|
168
|
+
// (f: Root) => error — сравнение с соседним полем, без scope/root-параметров
|
|
169
|
+
const passwordsMatch = (f: { password: string; confirmPassword: string }): ValidationError | null =>
|
|
170
|
+
f.confirmPassword && f.password && f.confirmPassword !== f.password
|
|
171
|
+
? { code: 'mismatch', message: 'Пароли не совпадают' }
|
|
172
|
+
: null;
|
|
173
|
+
|
|
174
|
+
// внутри defineValidationSchema — fn получает снапшот текущего scope (model.get()):
|
|
175
|
+
cross(model.$.confirmPassword, passwordsMatch);
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
## See also
|
|
179
|
+
|
|
180
|
+
- [03-api-signatures.md](./03-api-signatures.md) — сигнатуры `enableWhen`/`compute`/`copyFrom` (поведение) и `validate`/`validateWhen`/`cross`/`validateModel` (валидация)
|
|
181
|
+
- [17-nonexistent-api.md](./17-nonexistent-api.md) — полный список удалённого API (`applyWhen`, `ValidationSchemaFn`, `validateFormModel`, …)
|
|
182
|
+
- [25-reset-when.md](./25-reset-when.md) — `resetWhen` как альтернатива, когда поле остаётся enabled
|
|
183
|
+
- [19-reading-values.md](./19-reading-values.md) — `useFormControlValue` и чтение значений в React
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
## 16. READING FIELD VALUES (CRITICALLY IMPORTANT)
|
|
2
|
+
|
|
3
|
+
Под M1 значения живут в модели. Есть три контекста чтения: value-доступ модели, сигналы, и React-хуки.
|
|
4
|
+
|
|
5
|
+
### В React-компоненте — хуки
|
|
6
|
+
|
|
7
|
+
```typescript
|
|
8
|
+
// Полное состояние поля (объект)
|
|
9
|
+
const { value, errors, disabled, touched, shouldShowError } = useFormControl(control.email);
|
|
10
|
+
|
|
11
|
+
// Только значение (напрямую, БЕЗ деструктуризации!)
|
|
12
|
+
const email = useFormControlValue(control.email);
|
|
13
|
+
|
|
14
|
+
// Реактивная длина массива
|
|
15
|
+
const count = useArrayLength(control.items);
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
### Вне React — модель
|
|
19
|
+
|
|
20
|
+
```typescript
|
|
21
|
+
// value-доступ (реактивно внутри effect/computed, запись присваиванием)
|
|
22
|
+
model.email; // читать
|
|
23
|
+
model.email = 'a@b.c'; // писать
|
|
24
|
+
model.address.city; // вложенное поле (model.address — под-модель FormModel<Address>)
|
|
25
|
+
|
|
26
|
+
// через сигнал (escape-hatch)
|
|
27
|
+
model.$.email.value; // реактивное чтение/запись
|
|
28
|
+
model.$.email.peek(); // нереактивный снимок
|
|
29
|
+
model.$.address.city.value; // сигнал вложенного поля (≡ model.address.$.city у под-модели)
|
|
30
|
+
|
|
31
|
+
// весь объект
|
|
32
|
+
model.get(); // НЕреактивный снимок { email, address: { city }, ... } — для submit
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
> ⚠️ `model.get()` и `model.isDirty()` читают через `peek()` — внутри `effect`/`computed` они НЕ
|
|
36
|
+
> создают зависимостей. `computed(() => model.get())` никогда не пересчитается. Для реактивного
|
|
37
|
+
> чтения всего объекта — узел дерева `$` (ниже).
|
|
38
|
+
|
|
39
|
+
### Подписка на группу и на модель целиком
|
|
40
|
+
|
|
41
|
+
Каждый узел дерева `$` — сигнал: лист отдаёт `PathAwareSignal`, а корень, вложенные объекты-группы
|
|
42
|
+
и массивы — `ReadonlySignal` агрегированного значения поддерева. Поэтому подписаться можно на любом
|
|
43
|
+
уровне, а не только на конкретном поле.
|
|
44
|
+
|
|
45
|
+
```typescript
|
|
46
|
+
model.$.subscribe((all) => autosave(all)); // любое изменение модели; all: T целиком
|
|
47
|
+
model.$.address.subscribe((addr) => ...); // только поддерево address
|
|
48
|
+
model.$.address.value; // реактивный снимок группы
|
|
49
|
+
model.$.address.peek(); // нереактивный снимок группы
|
|
50
|
+
model.$.items.subscribe((rows) => ...); // массив: и правка элемента, и push/removeAt/move
|
|
51
|
+
|
|
52
|
+
// узел — обычный ReadonlySignal, поэтому принимается операциями слоя данных
|
|
53
|
+
watchField(model.$.address, (addr) => geocode(addr));
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Дети узла доступны как раньше — `model.$.address.city` по-прежнему сигнал поля. Подписчик вызывается
|
|
57
|
+
сразу с текущим значением (семантика `Signal.subscribe`), а `model.set(...)`/`model.reset()`
|
|
58
|
+
уведомляют один раз, а не по разу на поле.
|
|
59
|
+
|
|
60
|
+
> ⚠️ Доступ к полю выигрывает у свойства сигнала: если в форме есть поле с именем `value`, `peek`,
|
|
61
|
+
> `subscribe`, `valueOf`, `toString`, `toJSON` или `brand`, то `model.$.<группа>.<это имя>` вернёт
|
|
62
|
+
> сигнал поля. `subscribe` при этом продолжает работать.
|
|
63
|
+
|
|
64
|
+
### В behaviors — читаем model напрямую
|
|
65
|
+
|
|
66
|
+
`compute`/`onChange`/условия `when` читают значения из value-модели (`model.field`) —
|
|
67
|
+
подписка на сигналы происходит автоматически внутри реактивного эффекта.
|
|
68
|
+
|
|
69
|
+
```typescript
|
|
70
|
+
import { defineFormBehavior, compute, onChange } from '@reformer/core/behaviors';
|
|
71
|
+
|
|
72
|
+
const behavior = defineFormBehavior<MyForm>(({ model, form }) => {
|
|
73
|
+
// читаем несколько полей — compute сам подпишется на прочитанные сигналы
|
|
74
|
+
compute(model.$.fullName, () => `${model.firstName} ${model.lastName}`);
|
|
75
|
+
|
|
76
|
+
// onChange: 1-й аргумент — новое значение; остальные поля берём из model
|
|
77
|
+
onChange(model.$.loanAmount, (amount) => {
|
|
78
|
+
const term = model.loanTerm;
|
|
79
|
+
if (amount && term) {
|
|
80
|
+
form.monthlyPayment.updateComponentProps({ hint: `≈ ${amount / term}` });
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
> Cross-field ЗАПИСЬ производного значения делай через `compute` (цель не входит в источники →
|
|
87
|
+
> цикла нет). Для side-эффектов (загрузка опций, обновление componentProps) — `onChange`.
|
|
88
|
+
> `computeFrom`/`copyFrom`/`enableWhen` принимают сигналы (`model.$.x`), НЕ строковые пути.
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
## 17. COMPUTE vs ONCHANGE
|
|
2
|
+
|
|
3
|
+
Под M1 behaviors работают на сигналах модели. Есть два способа их писать:
|
|
4
|
+
|
|
5
|
+
- **примитивы из `@reformer/core`** — принимают сигналы, возвращают cleanup, вызываются
|
|
6
|
+
императивно (например, в `useEffect`), cleanup складывается в массив;
|
|
7
|
+
- **декларативный DSL из `@reformer/core/behaviors`** — `defineFormBehavior(...)` + операторы,
|
|
8
|
+
cleanup управляется формой, передаётся в `createForm({ behavior })`.
|
|
9
|
+
|
|
10
|
+
Для производных значений — `compute`/`computeFrom`. Для side-эффектов на изменение (async,
|
|
11
|
+
обновление componentProps) — `onChange` (DSL) или примитив `watchField`.
|
|
12
|
+
|
|
13
|
+
### compute — auto-tracking (DSL)
|
|
14
|
+
|
|
15
|
+
`compute(target, read)` подписывается на сигналы, прочитанные внутри `read()`, и пишет
|
|
16
|
+
результат в `target`. Цель не входит в источники → цикла нет; запись идемпотентна (peek-guard).
|
|
17
|
+
Кросс-уровневые вычисления работают так же — читай любые поля модели.
|
|
18
|
+
|
|
19
|
+
```typescript
|
|
20
|
+
import { defineFormBehavior, compute } from '@reformer/core/behaviors';
|
|
21
|
+
|
|
22
|
+
const behavior = defineFormBehavior<MyForm>(({ model }) => {
|
|
23
|
+
// same-level
|
|
24
|
+
compute(model.$.total, () => (model.price ?? 0) * (model.quantity ?? 0));
|
|
25
|
+
|
|
26
|
+
// nested-to-nested / cross-level — просто читаем нужные поля
|
|
27
|
+
compute(model.$.fullName, () =>
|
|
28
|
+
[model.personalData.firstName, model.personalData.lastName].filter(Boolean).join(' ')
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
// условный пересчёт
|
|
32
|
+
compute(model.$.initialPayment, () => model.propertyValue * 0.2, {
|
|
33
|
+
when: () => model.loanType === 'mortgage',
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
> **Не читай `model.get()` внутри `compute`/`when`** — это нереактивный снимок, зависимость не
|
|
39
|
+
> отследится и пересчёта не будет. Читай поля по отдельности (`model.field` или `model.$.field.value`).
|
|
40
|
+
> `model.get()` — только вне реактивного контекста (в `onChange`, обработчиках событий).
|
|
41
|
+
|
|
42
|
+
### Computed sum over a FormArray
|
|
43
|
+
|
|
44
|
+
Агрегат по массиву (сумма доходов созаёмщиков и т.п.) считается **реактивно** через value-proxy
|
|
45
|
+
массива — `model.<array>.map(...)`:
|
|
46
|
+
|
|
47
|
+
```typescript
|
|
48
|
+
compute(model.$.coBorrowersIncome, () =>
|
|
49
|
+
model.coBorrowers.map((cb) => cb.monthlyIncome ?? 0).reduce((s, v) => s + v, 0)
|
|
50
|
+
);
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Почему реактивно: `.map` читает сигнал самого массива (трекает `push`/`removeAt`/reorder) **и**
|
|
54
|
+
внутри колбэка читает каждый `cb.<field>` (трекает правки элементов) → пересчёт срабатывает на оба
|
|
55
|
+
вида изменений.
|
|
56
|
+
|
|
57
|
+
Ключевой нюанс — **на каком proxy** живёт `.map`/`.reduce`(-via-`.map`):
|
|
58
|
+
|
|
59
|
+
- **value-proxy `model.coBorrowers`** — есть `.map`/`.forEach`/`.at`/индексы (обходит элементы как
|
|
60
|
+
значения). Именно его читай в `compute`.
|
|
61
|
+
- **signals-proxy `model.$.coBorrowers`** — только индексный доступ и `.length`, без `.map`. Для
|
|
62
|
+
агрегата не подходит.
|
|
63
|
+
|
|
64
|
+
Если нужен только **счётчик** (пересчёт лишь на изменение длины, без чтения полей элементов) —
|
|
65
|
+
`model.<array>.map(() => null)` (длина трекается, значения — нет):
|
|
66
|
+
|
|
67
|
+
```typescript
|
|
68
|
+
// зависит только от КОЛИЧЕСТВА элементов, не от их полей
|
|
69
|
+
compute(model.$.interestRate, () => computeRate(model.properties.map(() => null).length));
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
> **Не агрегируй через `model.get()` / `model.<array>.peek()`** в `compute` — это нереактивный
|
|
73
|
+
> снапшот массива, зависимость не отследится и сумма не пересчитается. Читай массив только через
|
|
74
|
+
> value-proxy `model.<array>.map(...)`.
|
|
75
|
+
|
|
76
|
+
### computeFrom — явный список источников
|
|
77
|
+
|
|
78
|
+
Когда нужен явный контроль зависимостей — `computeFrom(sources, target, fn)`. Значения
|
|
79
|
+
источников приходят в `fn` позиционно.
|
|
80
|
+
|
|
81
|
+
```typescript
|
|
82
|
+
import { computeFrom } from '@reformer/core/behaviors'; // или из '@reformer/core' как примитив
|
|
83
|
+
|
|
84
|
+
computeFrom(
|
|
85
|
+
[model.$.loanAmount, model.$.loanTerm, model.$.interestRate],
|
|
86
|
+
model.$.monthlyPayment,
|
|
87
|
+
(amount, term, rate) => annuityMonthly(amount ?? 0, term ?? 0, rate ?? 0)
|
|
88
|
+
);
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
> Примитив `computeFrom` из `@reformer/core` имеет ту же сигнатуру и возвращает cleanup-функцию.
|
|
92
|
+
|
|
93
|
+
### onChange — реакция на изменение (async, side-effects)
|
|
94
|
+
|
|
95
|
+
`onChange(source, cb, { debounce, immediate })` вызывает `cb(value, { signal })` при изменении.
|
|
96
|
+
Колбэк выполняется ВНЕ effect-контекста — можно писать сигналы/ноды. `signal` (AbortSignal)
|
|
97
|
+
аннулируется при следующей смене значения.
|
|
98
|
+
|
|
99
|
+
```typescript
|
|
100
|
+
import { defineFormBehavior, onChange } from '@reformer/core/behaviors';
|
|
101
|
+
|
|
102
|
+
const behavior = defineFormBehavior<MyForm>(({ model, form }) => {
|
|
103
|
+
onChange(
|
|
104
|
+
model.$.country,
|
|
105
|
+
async (country, { signal }) => {
|
|
106
|
+
const cities = await fetchCities(country, { signal });
|
|
107
|
+
form.city.updateComponentProps({ options: cities });
|
|
108
|
+
},
|
|
109
|
+
{ debounce: 300 }
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Примитив watchField
|
|
115
|
+
|
|
116
|
+
Низкоуровневая подписка из `@reformer/core` (без debounce/AbortSignal). `onChange` построен
|
|
117
|
+
поверх неё. Для простых синхронных реакций:
|
|
118
|
+
|
|
119
|
+
```typescript
|
|
120
|
+
import { watchField } from '@reformer/core';
|
|
121
|
+
const stop = watchField(model.$.country, () => { model.city = ''; });
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Источником может быть не только поле: узлы-контейнеры дерева `$` (корень, группы, массивы) — тоже
|
|
125
|
+
`ReadonlySignal`, поэтому за поддеревом целиком следят без перечисления полей.
|
|
126
|
+
|
|
127
|
+
```typescript
|
|
128
|
+
watchField(model.$.address, (addr) => geocode(addr)); // любое поле address
|
|
129
|
+
watchField(model.$, (all) => autosave(all)); // любое поле формы
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### Rule of Thumb
|
|
133
|
+
|
|
134
|
+
| Scenario | Use |
|
|
135
|
+
|----------|-----|
|
|
136
|
+
| Производное значение (любой уровень) | `compute` (auto-tracking) |
|
|
137
|
+
| Производное с явными зависимостями | `computeFrom` |
|
|
138
|
+
| Async-реакция, обновление componentProps | `onChange` (debounce + AbortSignal) |
|
|
139
|
+
| Простая синхронная реакция (примитив) | `watchField` |
|
|
140
|
+
| Реакция на любое изменение группы/формы | `watchField(model.$.<группа>)` или `.subscribe` |
|
|
141
|
+
|
|
142
|
+
### Chained computeds
|
|
143
|
+
|
|
144
|
+
Если несколько вычислений зависят друг от друга (`interestRate` → `monthlyPayment` →
|
|
145
|
+
`paymentToIncomeRatio`), объяви каждое отдельным `compute` — они выстроятся в правильном
|
|
146
|
+
порядке через реактивный граф (цель одного = источник другого). Расходящиеся взаимные
|
|
147
|
+
`compute` без стабилизации бросят понятную ошибку (см. `22-cycle-detection.md`).
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
## 18. ARRAY OPERATIONS
|
|
2
|
+
|
|
3
|
+
Массивы объектов — model-owned. Мутации делаются через `ModelArray` (`model.arrayField`);
|
|
4
|
+
рендер — через ноду формы (`form.items.map` / `.at`).
|
|
5
|
+
|
|
6
|
+
### Array Access
|
|
7
|
+
|
|
8
|
+
```typescript
|
|
9
|
+
// Через ModelArray (данные)
|
|
10
|
+
model.items.at(0); // FormModel<Item> | undefined (под-модель элемента)
|
|
11
|
+
model.items.map((item, i) => item.name); // item — FormModel<Item>
|
|
12
|
+
model.items.length; // реактивная длина
|
|
13
|
+
model.items.toArray(); // снимок значений
|
|
14
|
+
|
|
15
|
+
// Через ноду формы (рендер / доступ к нодам полей)
|
|
16
|
+
form.items.at(0); // FormProxy<Item> | undefined
|
|
17
|
+
form.items.map((item, i) => …); // item — FormProxy<Item>
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
### Array Methods (на модели)
|
|
21
|
+
|
|
22
|
+
```typescript
|
|
23
|
+
model.items.push({ name: '', price: 0 }); // добавить в конец (ПЛОСКИЕ значения!)
|
|
24
|
+
model.items.insertAt(0, { name: '', price: 0 }); // вставить по индексу
|
|
25
|
+
model.items.removeAt(index); // удалить по индексу
|
|
26
|
+
model.items.move(fromIndex, toIndex); // переместить
|
|
27
|
+
model.items.swap(a, b); // поменять местами
|
|
28
|
+
model.items.clear(); // очистить
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
> **push принимает ПЛОСКИЕ значения** (`{ name, price }`), а НЕ FieldConfig-шаблон
|
|
32
|
+
> (`{ value, component }`). Component/componentProps берутся из `item`-фабрики схемы.
|
|
33
|
+
> Передача FieldConfig-объектов сломает рендер (`[object Object]` в инпутах).
|
|
34
|
+
|
|
35
|
+
### Rendering Arrays
|
|
36
|
+
|
|
37
|
+
```tsx
|
|
38
|
+
import { useArrayLength } from '@reformer/core';
|
|
39
|
+
|
|
40
|
+
function ItemsList({ form }: { form: FormProxy<MyForm> }) {
|
|
41
|
+
const length = useArrayLength(form.items);
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<div>
|
|
45
|
+
{form.items.map((item, index) => (
|
|
46
|
+
<div key={index}>
|
|
47
|
+
<FormField control={item.name} />
|
|
48
|
+
<FormField control={item.price} />
|
|
49
|
+
<button onClick={() => model.items.removeAt(index)}>Remove</button>
|
|
50
|
+
</div>
|
|
51
|
+
))}
|
|
52
|
+
{length === 0 && <p>No items yet</p>}
|
|
53
|
+
<button onClick={() => model.items.push({ name: '', price: 0 })}>Add Item</button>
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
> В монорепо используется `FormArraySection` из `@reformer/ui-kit`: `control={form.items}`,
|
|
60
|
+
> `itemComponent`, `initialValue`, add/remove/reorder из коробки. См. `find_recipe(topic="form-array")`.
|
|
61
|
+
|
|
62
|
+
### Per-item behavior — applyEach
|
|
63
|
+
|
|
64
|
+
Чтобы применить поведение к КАЖДОМУ элементу (реагируя на add/remove), используй `applyEach`:
|
|
65
|
+
|
|
66
|
+
```typescript
|
|
67
|
+
import { defineFormBehavior, applyEach, compute } from '@reformer/core/behaviors';
|
|
68
|
+
|
|
69
|
+
const behavior = defineFormBehavior<MyForm>(({ model }) => {
|
|
70
|
+
applyEach(
|
|
71
|
+
model.$.items,
|
|
72
|
+
defineFormBehavior<Item>(({ model: row }) => {
|
|
73
|
+
compute(row.$.lineTotal, () => row.qty * row.price); // per-row value-op
|
|
74
|
+
})
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Aggregate write — aggregateInto
|
|
80
|
+
|
|
81
|
+
Агрегатная запись в строки (например, «последняя строка = 100 − Σ остальных»):
|
|
82
|
+
|
|
83
|
+
```typescript
|
|
84
|
+
import { aggregateInto } from '@reformer/core/behaviors';
|
|
85
|
+
|
|
86
|
+
aggregateInto(model.$.rows, (rows) => {
|
|
87
|
+
const n = rows.length;
|
|
88
|
+
if (n === 0) return [];
|
|
89
|
+
const others = rows.slice(0, n - 1).reduce((s, r) => s + r.percent, 0);
|
|
90
|
+
return [{ index: n - 1, patch: { percent: 100 - others } }]; // derive должна сходиться
|
|
91
|
+
});
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### Array Cross-Validation
|
|
95
|
+
|
|
96
|
+
Whole-array правило — оператор `cross(sig, (f) => ...)` из `@reformer/core/validation`:
|
|
97
|
+
`f` — снапшот `model.get()`, ошибка вешается на скалярное поле-носитель `sig`. Per-item
|
|
98
|
+
правила — `each(model.<array>, (im) => ...)`.
|
|
99
|
+
|
|
100
|
+
```typescript
|
|
101
|
+
import { cross } from '@reformer/core/validation';
|
|
102
|
+
import type { ValidationError } from '@reformer/core';
|
|
103
|
+
|
|
104
|
+
const percentagesSumTo100 = (f: MyForm): ValidationError | null => {
|
|
105
|
+
const total = f.items.reduce((sum, i) => sum + (i.percentage || 0), 0);
|
|
106
|
+
return Math.abs(total - 100) > 0.01
|
|
107
|
+
? { code: 'invalid_total', message: 'Percentages must sum to 100%' }
|
|
108
|
+
: null;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// внутри defineValidationSchema<MyForm>(({ model }) => { ... })
|
|
112
|
+
cross(model.$.totalPercent, percentagesSumTo100); // носитель — скалярное поле формы
|
|
113
|
+
```
|