@reformer/core 10.0.0-beta.3 → 10.0.0-beta.4
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/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/index.d.ts +1 -1
- package/dist/index.js +429 -617
- package/dist/runtime-token.d.ts +23 -0
- 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 +59 -0
- package/docs/llms/20-compute-vs-watch.md +138 -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 +144 -1557
- package/package.json +33 -10
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# copyFrom — Копирование значений между полями
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
`copyFrom` декларативно копирует значение одного поля (или группы) в другое при выполнении
|
|
6
|
+
условия `when`. Используется для UX-сценариев «совпадает с …»: «адрес проживания = адрес
|
|
7
|
+
регистрации», «email для уведомлений = основной email», «billing = shipping». Оператор сам
|
|
8
|
+
выходит из reactive-контекста (`runOutsideEffect`), поэтому не порождает «Cycle detected».
|
|
9
|
+
|
|
10
|
+
## API
|
|
11
|
+
|
|
12
|
+
Есть две формы. **Примитив из `@reformer/core`** (сигнал → сигнал):
|
|
13
|
+
|
|
14
|
+
```typescript
|
|
15
|
+
function copyFrom<T>(
|
|
16
|
+
source: ReadonlySignal<T>,
|
|
17
|
+
target: Signal<T>,
|
|
18
|
+
options?: { when?: () => boolean; transform?: (value: T) => T }
|
|
19
|
+
): () => void; // возвращает cleanup
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
**DSL-оператор из `@reformer/core/behaviors`** (сигнал ИЛИ группа):
|
|
23
|
+
|
|
24
|
+
```typescript
|
|
25
|
+
function copyFrom<T>(
|
|
26
|
+
source: ReadonlySignal<T> | object, // model.$.field или model.$.group
|
|
27
|
+
target: Signal<T> | object,
|
|
28
|
+
options?: { when?: () => boolean; transform?: (value: T) => T }
|
|
29
|
+
): void; // cleanup управляется формой
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`when` — реактивное условие (читает сигналы модели через `model.*`). `transform` применяется
|
|
33
|
+
к значению перед записью в target. Отдельной опции `fields`/`debounce` нет.
|
|
34
|
+
|
|
35
|
+
## Examples
|
|
36
|
+
|
|
37
|
+
### Базовый сценарий — синхронизация двух адресов
|
|
38
|
+
|
|
39
|
+
```typescript
|
|
40
|
+
import { defineFormBehavior, copyFrom } from '@reformer/core/behaviors';
|
|
41
|
+
|
|
42
|
+
type OrderForm = {
|
|
43
|
+
useShippingAsBilling: boolean;
|
|
44
|
+
shippingAddress: string;
|
|
45
|
+
billingAddress: string;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const orderBehavior = defineFormBehavior<OrderForm>(({ model }) => {
|
|
49
|
+
copyFrom(model.$.shippingAddress, model.$.billingAddress, {
|
|
50
|
+
when: () => model.useShippingAsBilling === true,
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Пример: `copyFrom(model.$.shippingAddress, model.$.billingAddress, { when: () => model.useShippingAsBilling })`.
|
|
56
|
+
|
|
57
|
+
### Копирование группы целиком
|
|
58
|
+
|
|
59
|
+
Когда source/target — группы (`model.$.registrationAddress`), копируется всё значение группы:
|
|
60
|
+
|
|
61
|
+
```typescript
|
|
62
|
+
import { defineFormBehavior, copyFrom } from '@reformer/core/behaviors';
|
|
63
|
+
|
|
64
|
+
type ProfileForm = {
|
|
65
|
+
sameAsRegistration: boolean;
|
|
66
|
+
registrationAddress: Address;
|
|
67
|
+
residenceAddress: Address;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export const profileBehavior = defineFormBehavior<ProfileForm>(({ model }) => {
|
|
71
|
+
copyFrom(model.$.registrationAddress, model.$.residenceAddress, {
|
|
72
|
+
when: () => model.sameAsRegistration === true,
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Copy + transform — нормализация при копировании
|
|
78
|
+
|
|
79
|
+
```typescript
|
|
80
|
+
import { defineFormBehavior, copyFrom } from '@reformer/core/behaviors';
|
|
81
|
+
|
|
82
|
+
type ContactForm = { sameEmail: boolean; email: string; emailAdditional: string };
|
|
83
|
+
|
|
84
|
+
export const contactBehavior = defineFormBehavior<ContactForm>(({ model }) => {
|
|
85
|
+
copyFrom(model.$.email, model.$.emailAdditional, {
|
|
86
|
+
when: () => model.sameEmail === true,
|
|
87
|
+
transform: (value) => (typeof value === 'string' ? value.trim().toLowerCase() : value),
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Как примитив (вне defineFormBehavior)
|
|
93
|
+
|
|
94
|
+
```typescript
|
|
95
|
+
import { copyFrom } from '@reformer/core';
|
|
96
|
+
|
|
97
|
+
const cleanups = [
|
|
98
|
+
copyFrom(model.$.email, model.$.emailAdditional, { when: () => model.sameEmail === true }),
|
|
99
|
+
];
|
|
100
|
+
// teardown: cleanups.forEach((c) => c());
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Anti-patterns
|
|
104
|
+
|
|
105
|
+
```typescript
|
|
106
|
+
// ❌ Двусторонняя связь через два copyFrom — конфликт направлений
|
|
107
|
+
copyFrom(model.$.a, model.$.b);
|
|
108
|
+
copyFrom(model.$.b, model.$.a);
|
|
109
|
+
|
|
110
|
+
// ✅ Двусторонняя синхронизация — это работа syncFields
|
|
111
|
+
syncFields(model.$.a, model.$.b);
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
```typescript
|
|
115
|
+
// ❌ when, читающий саму target — лишний триггер при перезаписи target
|
|
116
|
+
copyFrom(model.$.source, model.$.target, { when: () => model.target === '' });
|
|
117
|
+
|
|
118
|
+
// ✅ when опирается на независимый флаг
|
|
119
|
+
copyFrom(model.$.source, model.$.target, { when: () => model.copyEnabled === true });
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Troubleshooting
|
|
123
|
+
|
|
124
|
+
**Q: Скопированное значение не появляется в target.**
|
|
125
|
+
A: Проверьте, что (1) behavior передан в `createForm({ behavior })` (или примитив вызван и не
|
|
126
|
+
отписан); (2) `when` возвращает `true`; (3) типы source/target совместимы (`copyFrom` пишет как есть).
|
|
127
|
+
|
|
128
|
+
**Q: «Cycle detected» при копировании.**
|
|
129
|
+
A: Обычно `when` читает значение target (см. anti-pattern). Условие должно зависеть только от
|
|
130
|
+
source и независимых флагов.
|
|
131
|
+
|
|
132
|
+
**Q: Как откатить копию при снятии флага `when`?**
|
|
133
|
+
A: `copyFrom` при `when === false` просто не пишет (не сбрасывает). Для сброса используй параллельно
|
|
134
|
+
`resetWhen(model.$.target, () => !model.copyEnabled)` (см. `25-reset-when.md`).
|
|
135
|
+
|
|
136
|
+
## See also
|
|
137
|
+
|
|
138
|
+
- [24-sync-fields.md](./24-sync-fields.md) — двусторонняя синхронизация
|
|
139
|
+
- [25-reset-when.md](./25-reset-when.md) — сброс target при выключенном условии
|
|
140
|
+
- [20-compute-vs-watch.md](./20-compute-vs-watch.md) — `compute` для производных значений
|
|
141
|
+
- [22-cycle-detection.md](./22-cycle-detection.md) — почему `runOutsideEffect` защищает от циклов
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# syncFields — Двусторонняя синхронизация полей
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
`syncFields` создаёт двунаправленную связь между двумя полями: изменение любого из них
|
|
6
|
+
переписывает второе. Применяется для дублей одного значения в разных частях формы
|
|
7
|
+
(тех. поле + видимое представление, mirror-поля). Внутренний флаг + `runOutsideEffect`
|
|
8
|
+
исключают петли. Для одностороннего копирования — [`copyFrom`](./23-copy-from.md); для
|
|
9
|
+
расчётов — [`compute`](./20-compute-vs-watch.md).
|
|
10
|
+
|
|
11
|
+
## API
|
|
12
|
+
|
|
13
|
+
Одинаково в примитиве (`@reformer/core`) и DSL (`@reformer/core/behaviors`):
|
|
14
|
+
|
|
15
|
+
```typescript
|
|
16
|
+
// примитив: возвращает cleanup
|
|
17
|
+
function syncFields<T>(a: Signal<T>, b: Signal<T>, options?: { transform?: (value: T) => T }): () => void;
|
|
18
|
+
|
|
19
|
+
// DSL: cleanup управляется формой
|
|
20
|
+
function syncFields<T>(a: Signal<T>, b: Signal<T>, options?: { transform?: (value: T) => T }): void;
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`a`/`b` — сигналы (`model.$.field`) совместимого типа `T`. `transform` **асимметричен**:
|
|
24
|
+
применяется только при движении значения `a → b`. Опции `debounce`/`when` нет.
|
|
25
|
+
|
|
26
|
+
## Examples
|
|
27
|
+
|
|
28
|
+
### Базовый сценарий — отзеркаливание текста
|
|
29
|
+
|
|
30
|
+
```typescript
|
|
31
|
+
import { defineFormBehavior, syncFields } from '@reformer/core/behaviors';
|
|
32
|
+
|
|
33
|
+
type MirrorForm = { syncField1: string; syncField2: string };
|
|
34
|
+
|
|
35
|
+
export const mirrorBehavior = defineFormBehavior<MirrorForm>(({ model }) => {
|
|
36
|
+
syncFields(model.$.syncField1, model.$.syncField2);
|
|
37
|
+
});
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Пример: `syncFields(model.$.syncField1, model.$.syncField2)`.
|
|
41
|
+
|
|
42
|
+
### С трансформацией — нормализация при прямой записи
|
|
43
|
+
|
|
44
|
+
```typescript
|
|
45
|
+
import { defineFormBehavior, syncFields } from '@reformer/core/behaviors';
|
|
46
|
+
|
|
47
|
+
type DisplayForm = { internalCode: string; displayCode: string };
|
|
48
|
+
|
|
49
|
+
export const codeBehavior = defineFormBehavior<DisplayForm>(({ model }) => {
|
|
50
|
+
// internalCode → displayCode: uppercase; обратно значение пишется как есть
|
|
51
|
+
syncFields(model.$.internalCode, model.$.displayCode, {
|
|
52
|
+
transform: (value) => (typeof value === 'string' ? value.toUpperCase() : value),
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Как примитив (вне defineFormBehavior)
|
|
58
|
+
|
|
59
|
+
```typescript
|
|
60
|
+
import { syncFields } from '@reformer/core';
|
|
61
|
+
const stop = syncFields(model.$.syncField1, model.$.syncField2);
|
|
62
|
+
// stop() — отписаться
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Anti-patterns
|
|
66
|
+
|
|
67
|
+
```typescript
|
|
68
|
+
// ❌ Симметрично через два copyFrom — конфликт направлений
|
|
69
|
+
copyFrom(model.$.a, model.$.b);
|
|
70
|
+
copyFrom(model.$.b, model.$.a);
|
|
71
|
+
|
|
72
|
+
// ✅ syncFields умеет двустороннюю связь без петель
|
|
73
|
+
syncFields(model.$.a, model.$.b);
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
```typescript
|
|
77
|
+
// ❌ Ожидание, что transform применится в обе стороны
|
|
78
|
+
syncFields(model.$.a, model.$.b, { transform: (v) => v.trim() });
|
|
79
|
+
// при записи в b значение НЕ trim-ается
|
|
80
|
+
|
|
81
|
+
// ✅ Симметричные трансформы — syncFields + transformValue на обоих полях
|
|
82
|
+
syncFields(model.$.a, model.$.b);
|
|
83
|
+
transformValue(model.$.a, (v) => (typeof v === 'string' ? v.trim() : v));
|
|
84
|
+
transformValue(model.$.b, (v) => (typeof v === 'string' ? v.trim() : v));
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
```typescript
|
|
88
|
+
// ❌ Поля разного типа — рантайм-приведение и баги
|
|
89
|
+
syncFields(model.$.amountString, model.$.amountNumber); // string ↔ number
|
|
90
|
+
|
|
91
|
+
// ✅ Для конвертации — compute в обе стороны или один канонический формат + computed отображение
|
|
92
|
+
compute(model.$.amountNumber, () => Number(model.amountString));
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Troubleshooting
|
|
96
|
+
|
|
97
|
+
**Q: Поля «дёргаются», несколько перезаписей.**
|
|
98
|
+
A: Чаще всего на одном из полей висит `transformValue`/`compute`. Убедитесь, что transform
|
|
99
|
+
идемпотентен (`f(f(x)) === f(x)`).
|
|
100
|
+
|
|
101
|
+
**Q: «Cycle detected» при `syncFields`.**
|
|
102
|
+
A: Не вешайте дополнительно `onChange`/`watchField`, которые сами пишут в эти же поля.
|
|
103
|
+
`syncFields` уже занимает оба направления.
|
|
104
|
+
|
|
105
|
+
**Q: Как ограничить sync условием (как `when` у copyFrom)?**
|
|
106
|
+
A: У `syncFields` нет `when`. Эмулируй через два `copyFrom(a→b, { when })` / `copyFrom(b→a, { when })`
|
|
107
|
+
с флагами, разрешающими только одну активную сторону, либо через `apply` под условием.
|
|
108
|
+
|
|
109
|
+
## See also
|
|
110
|
+
|
|
111
|
+
- [23-copy-from.md](./23-copy-from.md) — однонаправленное копирование с `when`
|
|
112
|
+
- [26-transform-value.md](./26-transform-value.md) — нормализация значений на месте
|
|
113
|
+
- [22-cycle-detection.md](./22-cycle-detection.md) — почему симметричный copy ломается
|
|
114
|
+
- [20-compute-vs-watch.md](./20-compute-vs-watch.md) — `compute` для производных значений
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# resetWhen — Условный сброс полей
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
`resetWhen` сбрасывает значение поля к `resetValue`, когда `condition` истинно. Это
|
|
6
|
+
альтернатива `enableWhen({ resetOnDisable: true })`, когда поле остаётся **enabled**, но
|
|
7
|
+
содержимое нужно очистить (например, переключение способа оплаты обнуляет «номер карты», но
|
|
8
|
+
поле по-прежнему доступно). По умолчанию пишет `null`; `resetValue` задаёт произвольное значение.
|
|
9
|
+
|
|
10
|
+
## API
|
|
11
|
+
|
|
12
|
+
Одинаково в примитиве (`@reformer/core`) и DSL (`@reformer/core/behaviors`):
|
|
13
|
+
|
|
14
|
+
```typescript
|
|
15
|
+
// примитив: возвращает cleanup
|
|
16
|
+
function resetWhen<T>(target: Signal<T>, condition: () => boolean, options?: { resetValue?: T }): () => void;
|
|
17
|
+
|
|
18
|
+
// DSL: cleanup управляется формой
|
|
19
|
+
function resetWhen<T>(target: Signal<T>, condition: () => boolean, options?: { resetValue?: T }): void;
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
`target` — сигнал (`model.$.field`). `condition` — реактивное условие (читает `model.*`).
|
|
23
|
+
`resetValue` по умолчанию `null`. Опций `onlyIfDirty`/`debounce` нет; флаги dirty/touched
|
|
24
|
+
поля не трогаются оператором (значения принадлежат модели).
|
|
25
|
+
|
|
26
|
+
## Examples
|
|
27
|
+
|
|
28
|
+
### Базовый сценарий — сброс номера карты при смене способа оплаты
|
|
29
|
+
|
|
30
|
+
```typescript
|
|
31
|
+
import { defineFormBehavior, resetWhen } from '@reformer/core/behaviors';
|
|
32
|
+
|
|
33
|
+
type CheckoutForm = { paymentType: 'card' | 'cash'; cardNumber: string };
|
|
34
|
+
|
|
35
|
+
export const checkoutBehavior = defineFormBehavior<CheckoutForm>(({ model }) => {
|
|
36
|
+
resetWhen(model.$.cardNumber, () => model.paymentType !== 'card', { resetValue: '' });
|
|
37
|
+
});
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Пример: `resetWhen(model.$.cardNumber, () => model.paymentType !== 'card', { resetValue: '' })`.
|
|
41
|
+
|
|
42
|
+
### resetValue для числовых полей
|
|
43
|
+
|
|
44
|
+
```typescript
|
|
45
|
+
import { defineFormBehavior, resetWhen } from '@reformer/core/behaviors';
|
|
46
|
+
|
|
47
|
+
type MortgageForm = { propertyValue: number | null; initialPayment: number };
|
|
48
|
+
|
|
49
|
+
export const mortgageBehavior = defineFormBehavior<MortgageForm>(({ model }) => {
|
|
50
|
+
// initialPayment теряет смысл без propertyValue — сбрасываем в 0
|
|
51
|
+
resetWhen(model.$.initialPayment, () => !model.propertyValue, { resetValue: 0 });
|
|
52
|
+
});
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Как примитив (вне defineFormBehavior)
|
|
56
|
+
|
|
57
|
+
```typescript
|
|
58
|
+
import { resetWhen } from '@reformer/core';
|
|
59
|
+
const stop = resetWhen(model.$.cardNumber, () => model.paymentType !== 'card', { resetValue: '' });
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Anti-patterns
|
|
63
|
+
|
|
64
|
+
```typescript
|
|
65
|
+
// ❌ resetWhen вместо enableWhen для disable-сценария
|
|
66
|
+
resetWhen(model.$.field, () => !model.show);
|
|
67
|
+
// поле останется enabled и валидируемым — ошибка required всё равно прилетит
|
|
68
|
+
|
|
69
|
+
// ✅ Если поле должно «исчезнуть» — блокируем + сбрасываем
|
|
70
|
+
enableWhen(model.$.field, () => model.show, { resetOnDisable: true });
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
```typescript
|
|
74
|
+
// ❌ resetValue с типом, отличным от поля
|
|
75
|
+
resetWhen(model.$.amount, () => model.skipPayment, { resetValue: 'none' }); // amount: number ← string
|
|
76
|
+
|
|
77
|
+
// ✅ resetValue совместим с типом поля
|
|
78
|
+
resetWhen(model.$.amount, () => model.skipPayment, { resetValue: 0 });
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
```typescript
|
|
82
|
+
// ❌ Для строкового поля без resetValue прилетит null, а Input ждёт string
|
|
83
|
+
resetWhen(model.$.cardNumber, () => model.paymentType !== 'card');
|
|
84
|
+
|
|
85
|
+
// ✅ Явный resetValue для строк
|
|
86
|
+
resetWhen(model.$.cardNumber, () => model.paymentType !== 'card', { resetValue: '' });
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
```typescript
|
|
90
|
+
// ❌ condition читает саму цель — самотриггер (см. 22-cycle-detection.md)
|
|
91
|
+
resetWhen(model.$.cardNumber, () => model.cardNumber !== '');
|
|
92
|
+
|
|
93
|
+
// ✅ condition зависит только от независимого поля
|
|
94
|
+
resetWhen(model.$.cardNumber, () => model.paymentType !== 'card', { resetValue: '' });
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Troubleshooting
|
|
98
|
+
|
|
99
|
+
**Q: Сброс не срабатывает, хотя `condition` возвращает true.**
|
|
100
|
+
A: Проверьте, что behavior зарегистрирован (`createForm({ behavior })`) или примитив не отписан,
|
|
101
|
+
и что `condition` читает реактивные поля (`model.*`), а не снимок `model.get()`.
|
|
102
|
+
|
|
103
|
+
**Q: Реактивная цепочка resetWhen → compute → resetWhen ломается.**
|
|
104
|
+
A: Убедитесь, что `condition` не зависит от значения самого поля, иначе после сброса попадёте
|
|
105
|
+
в новый триггер.
|
|
106
|
+
|
|
107
|
+
**Q: Сбросить вложенную группу целиком?**
|
|
108
|
+
A: `resetWhen` рассчитан на скалярные сигналы. Для группы используй `enableWhen({ resetOnDisable: true })`
|
|
109
|
+
(проходит по поддереву) или `model.reset()` для сброса всей формы к initial-снимку.
|
|
110
|
+
|
|
111
|
+
## See also
|
|
112
|
+
|
|
113
|
+
- [04-common-patterns.md](./04-common-patterns.md) — `enableWhen({ resetOnDisable: true })` как альтернатива
|
|
114
|
+
- [23-copy-from.md](./23-copy-from.md) — копирование, у которого нет встроенного отката
|
|
115
|
+
- [22-cycle-detection.md](./22-cycle-detection.md) — почему `condition` не должен читать целевое поле
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# transformValue — Автоматическая трансформация значений
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
`transformValue` подписывается на изменение поля и переписывает его трансформированной
|
|
6
|
+
версией: uppercase для кодов, trim+toLowerCase для email, округление для чисел. Применяется
|
|
7
|
+
единообразно ко всем источникам изменения (пользователь, `model.field = …`, `model.set/patch`,
|
|
8
|
+
`copyFrom`). **Идемпотентность (`f(f(x)) === f(x)`) обязательна** — иначе бесконечный цикл.
|
|
9
|
+
Оператор откладывает запись вне effect-контекста (`runOutsideEffect`) и не пишет, если
|
|
10
|
+
`transformer(value) === value` — базовый guard от циклов.
|
|
11
|
+
|
|
12
|
+
## API
|
|
13
|
+
|
|
14
|
+
Одинаково в примитиве (`@reformer/core`) и DSL (`@reformer/core/behaviors`):
|
|
15
|
+
|
|
16
|
+
```typescript
|
|
17
|
+
// примитив: возвращает cleanup
|
|
18
|
+
function transformValue<T>(target: Signal<T>, transformer: (value: T) => T): () => void;
|
|
19
|
+
|
|
20
|
+
// DSL: cleanup управляется формой
|
|
21
|
+
function transformValue<T>(target: Signal<T>, transformer: (value: T) => T): void;
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`target` — сигнал (`model.$.field`). `transformer` — чистая идемпотентная функция значения.
|
|
25
|
+
Дополнительных опций (`debounce`, `onUserChangeOnly`, `emitEvent`) и готового набора
|
|
26
|
+
`transformers`/`createTransformer` НЕТ — трансформер пишется как обычная функция.
|
|
27
|
+
|
|
28
|
+
## Examples
|
|
29
|
+
|
|
30
|
+
### Базовый сценарий — uppercase для кода
|
|
31
|
+
|
|
32
|
+
```typescript
|
|
33
|
+
import { defineFormBehavior, transformValue } from '@reformer/core/behaviors';
|
|
34
|
+
|
|
35
|
+
type PromoForm = { uppercaseField: string };
|
|
36
|
+
|
|
37
|
+
export const promoBehavior = defineFormBehavior<PromoForm>(({ model }) => {
|
|
38
|
+
transformValue(model.$.uppercaseField, (value) => (value ?? '').toUpperCase());
|
|
39
|
+
});
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Пример: `transformValue(model.$.uppercaseField, (v) => (v ?? '').toUpperCase())`.
|
|
43
|
+
|
|
44
|
+
### Несколько трансформаций
|
|
45
|
+
|
|
46
|
+
```typescript
|
|
47
|
+
import { defineFormBehavior, transformValue } from '@reformer/core/behaviors';
|
|
48
|
+
|
|
49
|
+
type ContactForm = { email: string; phone: string; amount: number };
|
|
50
|
+
|
|
51
|
+
export const contactBehavior = defineFormBehavior<ContactForm>(({ model }) => {
|
|
52
|
+
// email: trim + lowercase
|
|
53
|
+
transformValue(model.$.email, (value) => (value ?? '').trim().toLowerCase());
|
|
54
|
+
|
|
55
|
+
// телефон: только цифры → формат
|
|
56
|
+
transformValue(model.$.phone, (value) => {
|
|
57
|
+
if (!value) return value;
|
|
58
|
+
const digits = value.replace(/\D/g, '');
|
|
59
|
+
if (digits.length === 11) {
|
|
60
|
+
return `+7 (${digits.slice(1, 4)}) ${digits.slice(4, 7)}-${digits.slice(7, 9)}-${digits.slice(9)}`;
|
|
61
|
+
}
|
|
62
|
+
return value;
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// округление до целого
|
|
66
|
+
transformValue(model.$.amount, (value) => (typeof value === 'number' ? Math.round(value) : value));
|
|
67
|
+
});
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Как примитив (вне defineFormBehavior)
|
|
71
|
+
|
|
72
|
+
```typescript
|
|
73
|
+
import { transformValue } from '@reformer/core';
|
|
74
|
+
const stop = transformValue(model.$.promoCode, (v) => (v ?? '').toUpperCase());
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Переиспользуемые трансформеры — обычные функции
|
|
78
|
+
|
|
79
|
+
Готового набора нет, но легко собрать свои и применять их к любому сигналу:
|
|
80
|
+
|
|
81
|
+
```typescript
|
|
82
|
+
const toUpper = (target: Signal<string>) => transformValue(target, (v) => (v ?? '').toUpperCase());
|
|
83
|
+
const trim = (target: Signal<string>) => transformValue(target, (v) => (v ?? '').trim());
|
|
84
|
+
|
|
85
|
+
// в схеме поведения:
|
|
86
|
+
toUpper(model.$.promoCode);
|
|
87
|
+
trim(model.$.username);
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Anti-patterns
|
|
91
|
+
|
|
92
|
+
```typescript
|
|
93
|
+
// ❌ Неидемпотентный transformer — бесконечный цикл
|
|
94
|
+
transformValue(model.$.field, (v) => `prefix-${v}`); // f(f(x)) ≠ f(x)
|
|
95
|
+
|
|
96
|
+
// ✅ Guard внутри transformer
|
|
97
|
+
transformValue(model.$.field, (v) => (v?.startsWith('prefix-') ? v : `prefix-${v}`));
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
```typescript
|
|
101
|
+
// ❌ transformValue ОДНОВРЕМЕННО с syncFields на том же поле
|
|
102
|
+
syncFields(model.$.a, model.$.b);
|
|
103
|
+
transformValue(model.$.b, (v) => v.toUpperCase()); // взаимные перезаписи
|
|
104
|
+
|
|
105
|
+
// ✅ Трансформируй источник ДО синхронизации
|
|
106
|
+
transformValue(model.$.a, (v) => v.toUpperCase());
|
|
107
|
+
syncFields(model.$.a, model.$.b);
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
```typescript
|
|
111
|
+
// ❌ transformValue для производных полей (нет доступа к другим полям)
|
|
112
|
+
transformValue(model.$.fullName, () => `${model.firstName} ${model.lastName}`);
|
|
113
|
+
|
|
114
|
+
// ✅ Для зависимостей от других полей — compute
|
|
115
|
+
compute(model.$.fullName, () => `${model.firstName} ${model.lastName}`);
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Troubleshooting
|
|
119
|
+
|
|
120
|
+
**Q: «Cycle detected» при transformValue.**
|
|
121
|
+
A: 99% — неидемпотентный transformer. Проверь `transformer(transformer(x)) === transformer(x)`.
|
|
122
|
+
|
|
123
|
+
**Q: Трансформация не применяется.**
|
|
124
|
+
A: Проверь, что behavior зарегистрирован (`createForm({ behavior })`) / примитив не отписан,
|
|
125
|
+
и что форма не пересоздаётся на каждый рендер (используй `useMemo`).
|
|
126
|
+
|
|
127
|
+
**Q: Каретка input прыгает при наборе.**
|
|
128
|
+
A: Симптом частых записей. Форматирование с динамическими разделителями (телефон, кредитка)
|
|
129
|
+
лучше делать через `<InputMask>` из `@reformer/ui-kit`, а не `transformValue`.
|
|
130
|
+
|
|
131
|
+
## See also
|
|
132
|
+
|
|
133
|
+
- [24-sync-fields.md](./24-sync-fields.md) — порядок применения с syncFields
|
|
134
|
+
- [23-copy-from.md](./23-copy-from.md) — `transform`-опция при копировании
|
|
135
|
+
- [22-cycle-detection.md](./22-cycle-detection.md) — про идемпотентность
|
|
136
|
+
- [16-ui-components.md](./16-ui-components.md) — `<InputMask>` для тяжёлого форматирования
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# revalidateWhen — Перевалидация по триггерам
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
`revalidateWhen` вызывает переданный колбэк ревалидации, когда изменяется любая из
|
|
6
|
+
зависимостей. Применяется, когда правило зависит от значений других полей
|
|
7
|
+
(`amount <= maxAmount`, `confirmPassword === password`, `initialPayment >= propertyValue * 0.2`).
|
|
8
|
+
Валидация — отдельная функция-схема (`@reformer/core/validation`), прогоняемая по требованию через
|
|
9
|
+
`validateModel(model, schema)`. Поэтому ревалидация выражается явным колбэком, а не автоматической
|
|
10
|
+
привязкой к полю: поведение (`revalidateWhen`) лишь _инициирует_ прогон схемы валидации при изменении зависимости.
|
|
11
|
+
|
|
12
|
+
## API
|
|
13
|
+
|
|
14
|
+
`revalidateWhen` — оператор поведения (`@reformer/core/behaviors`, либо примитив `@reformer/core`).
|
|
15
|
+
Его сигнатура НЕ зависит от контракта валидации:
|
|
16
|
+
|
|
17
|
+
```typescript
|
|
18
|
+
// примитив: возвращает cleanup
|
|
19
|
+
function revalidateWhen(deps: ReadonlySignal<unknown>[], revalidate: () => void): () => void;
|
|
20
|
+
|
|
21
|
+
// DSL: cleanup управляется формой
|
|
22
|
+
function revalidateWhen(deps: ReadonlySignal<unknown>[], revalidate: () => void): void;
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
`deps` — массив сигналов-триггеров (`model.$.field`). `revalidate` — колбэк (обычно
|
|
26
|
+
`() => void validateModel(model, schema)`, где `schema` — `defineValidationSchema<T>(({ model }) => …)`).
|
|
27
|
+
Вызывается при изменении любой зависимости (НЕ на инициализации), вне effect-контекста.
|
|
28
|
+
|
|
29
|
+
## Examples
|
|
30
|
+
|
|
31
|
+
### Базовый сценарий — перевалидация amount при смене maxAmount
|
|
32
|
+
|
|
33
|
+
```typescript
|
|
34
|
+
import { defineFormBehavior, revalidateWhen } from '@reformer/core/behaviors';
|
|
35
|
+
import { validateModel } from '@reformer/core/validation';
|
|
36
|
+
|
|
37
|
+
export const paymentBehavior = defineFormBehavior<PaymentForm>(({ model }) => {
|
|
38
|
+
revalidateWhen([model.$.maxAmount], () => {
|
|
39
|
+
void validateModel(model, paymentSchema);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Пример: `revalidateWhen([model.$.maxAmount], () => void validateModel(model, paymentSchema))`.
|
|
45
|
+
Схема `paymentSchema` — стабильный module-level `const` (важно для отмены устаревших прогонов
|
|
46
|
+
в `validateModel`), где правило amount навешано через `cross(model.$.amount, amountVsMax)`.
|
|
47
|
+
|
|
48
|
+
### Несколько триггеров
|
|
49
|
+
|
|
50
|
+
```typescript
|
|
51
|
+
import { defineFormBehavior, revalidateWhen } from '@reformer/core/behaviors';
|
|
52
|
+
import { validateModel } from '@reformer/core/validation';
|
|
53
|
+
|
|
54
|
+
export const mortgageBehavior = defineFormBehavior<MortgageForm>(({ model }) => {
|
|
55
|
+
// initialPayment зависит и от propertyValue, и от loanAmount
|
|
56
|
+
revalidateWhen([model.$.propertyValue, model.$.loanAmount], () => {
|
|
57
|
+
void validateModel(model, mortgageSchema);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Парная перевалидация — confirmPassword
|
|
63
|
+
|
|
64
|
+
```typescript
|
|
65
|
+
import { defineFormBehavior, revalidateWhen } from '@reformer/core/behaviors';
|
|
66
|
+
import { validateModel } from '@reformer/core/validation';
|
|
67
|
+
|
|
68
|
+
// правило совпадения — cross-field в схеме: cross(model.$.confirmPassword, passwordsMatch),
|
|
69
|
+
// где passwordsMatch читает снапшот формы (см. 03-api-signatures.md)
|
|
70
|
+
export const registrationBehavior = defineFormBehavior<RegistrationForm>(({ model }) => {
|
|
71
|
+
// при смене password перевалидируем схему (confirm перепроверится)
|
|
72
|
+
revalidateWhen([model.$.password], () => {
|
|
73
|
+
void validateModel(model, registrationSchema);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Как примитив (вне defineFormBehavior)
|
|
79
|
+
|
|
80
|
+
```typescript
|
|
81
|
+
import { revalidateWhen } from '@reformer/core';
|
|
82
|
+
import { validateModel } from '@reformer/core/validation';
|
|
83
|
+
|
|
84
|
+
const stop = revalidateWhen([model.$.maxAmount], () => void validateModel(model, schema));
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Anti-patterns
|
|
88
|
+
|
|
89
|
+
```typescript
|
|
90
|
+
// ❌ Триггер == поле, которое и так меняется само
|
|
91
|
+
revalidateWhen([model.$.amount], () => validateModel(model, schema));
|
|
92
|
+
// amount и так валидируется при собственном изменении в общем прогоне
|
|
93
|
+
|
|
94
|
+
// ✅ Триггеры — ДРУГИЕ поля, от которых зависит правило amount
|
|
95
|
+
revalidateWhen([model.$.maxAmount, model.$.discount], () => validateModel(model, schema));
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
```typescript
|
|
99
|
+
// ❌ Правило только через revalidateWhen, но в схеме нет зависимости от триггера
|
|
100
|
+
revalidateWhen([model.$.maxAmount], () => validateModel(model, schema));
|
|
101
|
+
// а валидатор amount — статичный max(1000), не читает maxAmount
|
|
102
|
+
|
|
103
|
+
// ✅ Сначала cross-field правило (читает снапшот формы) в схеме валидации, потом revalidateWhen
|
|
104
|
+
const amountVsMax = (f: Form): ValidationError | null =>
|
|
105
|
+
f.amount != null && f.maxAmount != null && f.amount > f.maxAmount
|
|
106
|
+
? { code: 'tooBig', message: '...' }
|
|
107
|
+
: null;
|
|
108
|
+
|
|
109
|
+
const schema = defineValidationSchema<Form>(({ model }) => {
|
|
110
|
+
cross(model.$.amount, amountVsMax); // навешиваем правило на поле amount
|
|
111
|
+
});
|
|
112
|
+
revalidateWhen([model.$.maxAmount], () => void validateModel(model, schema));
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Troubleshooting
|
|
116
|
+
|
|
117
|
+
**Q: Ошибка target не пропадает после изменения триггера.**
|
|
118
|
+
A: Проверьте, что (1) правило реально читает значение триггера — cross-field `cross(sig, fn)`,
|
|
119
|
+
где `fn` берёт снапшот формы (`model.get()` / `f`); (2) в `deps` передан именно сигнал (`model.$.trigger`);
|
|
120
|
+
(3) колбэк вызывает `validateModel` (роутит ошибки в ноды через `getNodeForSignal(sig).setErrors`).
|
|
121
|
+
|
|
122
|
+
**Q: Как перевалидировать только одно поле, а не всю схему?**
|
|
123
|
+
A: Схема — обычная функция, поэтому опиши стабильную под-схему только для этого поля и прогони её:
|
|
124
|
+
`validateModel(model, oneFieldSchema)`, где
|
|
125
|
+
`const oneFieldSchema = defineValidationSchema<Form>(({ model }) => cross(model.$.amount, amountVsMax))`.
|
|
126
|
+
Раннер выставит/погасит ошибки только затронутых полей (гашение накапливается на пару `(model, schema)`).
|
|
127
|
+
|
|
128
|
+
## See also
|
|
129
|
+
|
|
130
|
+
- [03-api-signatures.md](./03-api-signatures.md) — cross-field `cross(sig, fn)` и раннер `validateModel`
|
|
131
|
+
- [28-submit-and-reset.md](./28-submit-and-reset.md) — полная валидация формы
|
|
132
|
+
- [20-compute-vs-watch.md](./20-compute-vs-watch.md) — реактивные производные значения
|