@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,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) — реактивные производные значения
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
# Submit и Reset — Жизненный цикл отправки формы
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
Канонический submit-флоу под M1: «запустить полную валидацию данных (`validateModel`) →
|
|
6
|
+
проверить `boolean`-результат → достать снимок (`model.get()`) → сделать запрос → `model.reset()`».
|
|
7
|
+
Валидация — **отдельный слой** (`@reformer/core/validation`, схема через `defineValidationSchema`), она
|
|
8
|
+
НЕ входит в layout-дерево, которое получает `createForm`. Раннер `validateModel(model, schema)` сам роутит
|
|
9
|
+
ошибки в ноды формы, поэтому UI подсветит проблемные поля, а наружу вернёт лишь `boolean`
|
|
10
|
+
(false = есть блокирующая ошибка; `severity:'warning'` показывается, но submit не блокирует).
|
|
11
|
+
`model.reset()` возвращает значения к initial-снимку.
|
|
12
|
+
|
|
13
|
+
## API
|
|
14
|
+
|
|
15
|
+
```typescript
|
|
16
|
+
// Модель (источник истины значений):
|
|
17
|
+
interface ModelApi<T> {
|
|
18
|
+
get(): T; // снимок значений — для submit
|
|
19
|
+
set(value: T): void; // полная установка (все ключи T)
|
|
20
|
+
patch(value: Partial<T>): void; // частичное слияние
|
|
21
|
+
isDirty(): boolean; // отличаются ли значения от initial-снимка
|
|
22
|
+
reset(): void; // вернуть к initial-снимку
|
|
23
|
+
captureInitial(): void; // зафиксировать текущие как новый initial
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Валидация данных — headless-раннер: находит ноды по сигналам модели, роутит в них ошибки,
|
|
27
|
+
// отменяет устаревшие прогоны той же (model, schema). Возвращает ТОЛЬКО boolean:
|
|
28
|
+
validateModel<T>(model: FormModel<T>, schema: ValidationSchema<T>): Promise<boolean>
|
|
29
|
+
// true = нет блокирующих ошибок (severity:'warning' не блокирует, но показывается).
|
|
30
|
+
// false = есть блокирующая ошибка; она уже проставлена в ноду → UI подсветит поле.
|
|
31
|
+
|
|
32
|
+
// Схема валидации — отдельный слой над моделью (не смешивается с layout):
|
|
33
|
+
defineValidationSchema<T>(({ model }) => {
|
|
34
|
+
validate(model.$.field, [rules]); // синхронные правила поля
|
|
35
|
+
// validateAsync / validateWhen / cross / each / apply — см. contract-spec
|
|
36
|
+
}): ValidationSchema<T>
|
|
37
|
+
|
|
38
|
+
// Нода поля/формы (для точечной работы с UI-состоянием):
|
|
39
|
+
form.markAsTouched(); // тронуть все поля (показать ошибки)
|
|
40
|
+
form.clearErrors(); // снять ошибки со всех нод
|
|
41
|
+
form.<field>.setErrors([{ code, message }]); // серверная/бизнес-ошибка в конкретное поле
|
|
42
|
+
form.<field>.clearErrors();
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Examples
|
|
46
|
+
|
|
47
|
+
### Базовый submit-handler
|
|
48
|
+
|
|
49
|
+
```tsx
|
|
50
|
+
import { useMemo } from 'react';
|
|
51
|
+
import { createModel, createForm } from '@reformer/core';
|
|
52
|
+
import { defineValidationSchema, validate, validateModel } from '@reformer/core/validation';
|
|
53
|
+
import { required, email, minLength } from '@reformer/core/validators';
|
|
54
|
+
|
|
55
|
+
type RegistrationFormData = { username: string; email: string; password: string };
|
|
56
|
+
|
|
57
|
+
// Валидация — отдельная схема над моделью. НЕ входит в layout, который получает createForm.
|
|
58
|
+
// Стабильная module-level `const`-ссылка — чтобы validateModel мог отменять устаревшие прогоны.
|
|
59
|
+
const registrationSchema = defineValidationSchema<RegistrationFormData>(({ model }) => {
|
|
60
|
+
validate(model.$.username, [required({ message: 'Имя обязательно' }), minLength(3)]);
|
|
61
|
+
validate(model.$.email, [required({ message: 'Email обязателен' }), email()]);
|
|
62
|
+
validate(model.$.password, [required({ message: 'Пароль обязателен' }), minLength(8)]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
function RegistrationForm() {
|
|
66
|
+
const { model, form } = useMemo(() => {
|
|
67
|
+
const m = createModel<RegistrationFormData>({ username: '', email: '', password: '' });
|
|
68
|
+
// schema здесь — layout-дерево нод (компоненты/раскладка), без валидаторов.
|
|
69
|
+
return { model: m, form: createForm({ model: m, schema: buildLayout(m) }) };
|
|
70
|
+
}, []);
|
|
71
|
+
|
|
72
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
73
|
+
e.preventDefault();
|
|
74
|
+
form.markAsTouched(); // показать ошибки на всех полях
|
|
75
|
+
|
|
76
|
+
// Шаг 1: прогон валидации данных. Ошибки автоматически проставятся в ноды → UI подсветит.
|
|
77
|
+
const valid = await validateModel(model, registrationSchema);
|
|
78
|
+
if (!valid) return;
|
|
79
|
+
|
|
80
|
+
// Шаг 2: достать чистые данные и отправить
|
|
81
|
+
const payload = model.get();
|
|
82
|
+
const response = await fetch('/api/v1/auth/register', {
|
|
83
|
+
method: 'POST',
|
|
84
|
+
headers: { 'Content-Type': 'application/json' },
|
|
85
|
+
body: JSON.stringify(payload),
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// Шаг 3: чистый старт после успеха
|
|
89
|
+
if (response.ok) model.reset();
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<form onSubmit={handleSubmit}>
|
|
94
|
+
<FormField control={form.username} testId="username" />
|
|
95
|
+
<FormField control={form.email} testId="email" />
|
|
96
|
+
<FormField control={form.password} testId="password" />
|
|
97
|
+
<button type="submit">Отправить</button>
|
|
98
|
+
</form>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Submit с error-handling и серверными ошибками
|
|
104
|
+
|
|
105
|
+
```tsx
|
|
106
|
+
async function handleSubmit(e: React.FormEvent) {
|
|
107
|
+
e.preventDefault();
|
|
108
|
+
form.markAsTouched();
|
|
109
|
+
|
|
110
|
+
const valid = await validateModel(model, registrationSchema);
|
|
111
|
+
if (!valid) return;
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
const response = await api.register(model.get());
|
|
115
|
+
if (response.success) {
|
|
116
|
+
model.reset();
|
|
117
|
+
navigate('/welcome');
|
|
118
|
+
} else {
|
|
119
|
+
// Серверная бизнес-ошибка — НЕ сбрасываем форму, кладём ошибку в поле
|
|
120
|
+
form.username.setErrors([{ code: 'taken', message: response.message }]);
|
|
121
|
+
}
|
|
122
|
+
} catch (error) {
|
|
123
|
+
// Сеть/неожиданная ошибка — оставляем значения
|
|
124
|
+
showToast(`Ошибка сети: ${(error as Error).message}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### Reset с подтверждением
|
|
130
|
+
|
|
131
|
+
```tsx
|
|
132
|
+
function ActionButtons({ model }: { model: FormModel<RegistrationFormData> }) {
|
|
133
|
+
const handleReset = () => {
|
|
134
|
+
if (!model.isDirty()) return; // нечего сбрасывать
|
|
135
|
+
if (confirm('Очистить форму? Несохранённые изменения будут потеряны.')) {
|
|
136
|
+
model.reset();
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const clearJustPassword = () => {
|
|
141
|
+
model.password = ''; // сброс одного поля — прямая запись в модель
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
return (
|
|
145
|
+
<>
|
|
146
|
+
<button type="button" onClick={handleReset}>Очистить</button>
|
|
147
|
+
<button type="button" onClick={clearJustPassword}>Очистить только пароль</button>
|
|
148
|
+
</>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### Использование вне React (server action / Node)
|
|
154
|
+
|
|
155
|
+
Раннер headless — работает без UI-компонентов, только с моделью и схемой:
|
|
156
|
+
|
|
157
|
+
```typescript
|
|
158
|
+
import { createModel } from '@reformer/core';
|
|
159
|
+
import { validateModel } from '@reformer/core/validation';
|
|
160
|
+
|
|
161
|
+
async function processFormPayload(rawData: Partial<MyForm>) {
|
|
162
|
+
const model = createModel<MyForm>(initialValues);
|
|
163
|
+
model.patch(rawData); // частичный load данных
|
|
164
|
+
|
|
165
|
+
// validateModel находит ноды по сигналам модели и роутит в них ошибки, возвращая boolean.
|
|
166
|
+
const valid = await validateModel(model, myFormSchema);
|
|
167
|
+
if (!valid) return { ok: false as const };
|
|
168
|
+
return { ok: true as const, data: model.get() };
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## Anti-patterns
|
|
173
|
+
|
|
174
|
+
```typescript
|
|
175
|
+
// ❌ Обращение к .valid: раннер возвращает boolean, а не { valid, errors }
|
|
176
|
+
const result = await validateModel(model, schema);
|
|
177
|
+
if (!result.valid) return; // result — boolean, .valid === undefined → guard никогда не сработает
|
|
178
|
+
|
|
179
|
+
// ✅ Результат — сам boolean
|
|
180
|
+
const valid = await validateModel(model, schema);
|
|
181
|
+
if (!valid) return;
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
```typescript
|
|
185
|
+
// ❌ Чтение результата валидации без await — сабмитим по неполному вердикту
|
|
186
|
+
const handleSubmit = (e) => {
|
|
187
|
+
e.preventDefault();
|
|
188
|
+
validateModel(model, schema); // Promise проигнорирован, async-валидаторы не дождались
|
|
189
|
+
submit(model.get());
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
// ✅ Дождаться и проверить boolean
|
|
193
|
+
const handleSubmit = async (e) => {
|
|
194
|
+
e.preventDefault();
|
|
195
|
+
if (await validateModel(model, schema)) submit(model.get());
|
|
196
|
+
};
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
```typescript
|
|
200
|
+
// ❌ Валидаторы в layout-дереве, отдаваемом в createForm — layout не несёт правил
|
|
201
|
+
createForm({ model, schema: { username: { value: '', validators: [required()] } } });
|
|
202
|
+
|
|
203
|
+
// ✅ Валидация — отдельная схема, прогоняется раннером
|
|
204
|
+
const schema = defineValidationSchema<T>(({ model }) => {
|
|
205
|
+
validate(model.$.username, [required()]);
|
|
206
|
+
});
|
|
207
|
+
await validateModel(model, schema);
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
```typescript
|
|
211
|
+
// ❌ model.reset() до ответа сервера — потеря данных при ошибке
|
|
212
|
+
await api.send(model.get());
|
|
213
|
+
model.reset();
|
|
214
|
+
|
|
215
|
+
// ✅ Reset только после успеха
|
|
216
|
+
try {
|
|
217
|
+
await api.send(model.get());
|
|
218
|
+
model.reset();
|
|
219
|
+
} catch (err) { showError(err); }
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
```typescript
|
|
223
|
+
// ❌ Сброс полей по очереди — многословно
|
|
224
|
+
model.username = '';
|
|
225
|
+
model.email = '';
|
|
226
|
+
model.password = '';
|
|
227
|
+
|
|
228
|
+
// ✅ model.reset() — к initial-снимку одним вызовом
|
|
229
|
+
model.reset();
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
## Troubleshooting
|
|
233
|
+
|
|
234
|
+
**Q: После `reset()` в UI остались старые ошибки.**
|
|
235
|
+
A: `model.reset()` меняет значения; ошибки в нодах чистит валидация. Перезапусти
|
|
236
|
+
`validateModel(model, schema)` после reset (валидные поля погаснут сами), либо очисти напрямую —
|
|
237
|
+
`form.clearErrors()` / `form.<field>.clearErrors()`.
|
|
238
|
+
|
|
239
|
+
**Q: `validateModel` вернул `true`, но в поле висит ошибка.**
|
|
240
|
+
A: Это правило с `severity: 'warning'` — оно показывается, но submit не блокирует, поэтому раннер
|
|
241
|
+
и вернул `true`. Блокируют только ошибки без `severity` (default). Чтобы не пускать submit при
|
|
242
|
+
warning — проверяй его отдельно, вне `validateModel`.
|
|
243
|
+
|
|
244
|
+
**Q: `reset()` не возвращает данные, загруженные с сервера.**
|
|
245
|
+
A: `reset()` возвращает к initial-снимку (значения на момент `createModel`). `set/patch` НЕ
|
|
246
|
+
меняют initial. Чтобы сделать загруженные данные новой «точкой отсчёта» — вызови
|
|
247
|
+
`model.captureInitial()` после загрузки.
|
|
248
|
+
|
|
249
|
+
**Q: Disabled-поля участвуют в submit-данных?**
|
|
250
|
+
A: `model.get()` возвращает значения всех полей. Фильтруй вручную после `get()` или используй
|
|
251
|
+
`enableWhen({ resetOnDisable: true })`, чтобы при disable поле возвращалось к initial.
|
|
252
|
+
|
|
253
|
+
## See also
|
|
254
|
+
|
|
255
|
+
- [29-async-preload.md](./29-async-preload.md) — initial values и preload через `set`/`patch`
|
|
256
|
+
- [13-multi-step.md](./13-multi-step.md) — пошаговая валидация через `validateModel` (`validateStep`/`validateAll`)
|
|
257
|
+
- [27-revalidate-when.md](./27-revalidate-when.md) — мост «поведение → валидация»: `revalidateWhen([deps], () => void validateModel(model, schema))`
|
|
258
|
+
- [03-api-signatures.md](./03-api-signatures.md) — сигнатуры модели и `validateModel`
|
|
259
|
+
- [05-common-mistakes.md](./05-common-mistakes.md) — типичные ошибки
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# Async Preload — Загрузка начальных значений и справочников
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
Загрузка формы данными с сервера под M1: (1) **initial values** в `createModel(...)`; (2)
|
|
6
|
+
**`model.set` / `model.patch`** для загрузки/обновления значений; (3) **external React-hook**
|
|
7
|
+
для full-blown async preload (параллельный fetch заявки + справочников, обработка ошибок,
|
|
8
|
+
race-guard через deps). Динамические `componentProps` (опции селектов) обновляются через
|
|
9
|
+
`form.field.updateComponentProps({ options })` в `queueMicrotask`, чтобы не пересечься с
|
|
10
|
+
реактивными эффектами от `set`/`patch`.
|
|
11
|
+
|
|
12
|
+
## API
|
|
13
|
+
|
|
14
|
+
```typescript
|
|
15
|
+
// Модель:
|
|
16
|
+
model.set(value: T): void; // полная установка значений (load полного DTO). Не меняет initial.
|
|
17
|
+
model.patch(value: Partial<T>): void; // частичное слияние (только переданные ключи). Не меняет initial.
|
|
18
|
+
model.reset(): void; // вернуть к initial-снимку
|
|
19
|
+
model.captureInitial(): void; // сделать текущие значения новым initial-снимком
|
|
20
|
+
|
|
21
|
+
// Нода поля/группы:
|
|
22
|
+
form.field.updateComponentProps(props: Record<string, unknown>): void; // динамические опции и т.п.
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Initial values задаются в `createModel(initial)`; `reset()` возвращает к ним. Чтобы загруженные
|
|
26
|
+
данные стали новой «точкой отсчёта» для `reset()` — вызови `model.captureInitial()` после load.
|
|
27
|
+
|
|
28
|
+
## Examples
|
|
29
|
+
|
|
30
|
+
### Initial values в модели
|
|
31
|
+
|
|
32
|
+
```typescript
|
|
33
|
+
import { createModel, createForm } from '@reformer/core';
|
|
34
|
+
import { Input, Select } from '@reformer/ui-kit';
|
|
35
|
+
|
|
36
|
+
type ProfileForm = { username: string; language: 'ru' | 'en'; marketing: boolean };
|
|
37
|
+
|
|
38
|
+
const model = createModel<ProfileForm>({ username: '', language: 'ru', marketing: true });
|
|
39
|
+
const schema = {
|
|
40
|
+
username: { value: model.$.username, component: Input, componentProps: { label: 'Username' } },
|
|
41
|
+
language: {
|
|
42
|
+
value: model.$.language,
|
|
43
|
+
component: Select,
|
|
44
|
+
componentProps: {
|
|
45
|
+
label: 'Язык',
|
|
46
|
+
options: [
|
|
47
|
+
{ value: 'ru', label: 'Русский' },
|
|
48
|
+
{ value: 'en', label: 'English' },
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
marketing: { value: model.$.marketing, component: Input, componentProps: { type: 'checkbox' } },
|
|
53
|
+
};
|
|
54
|
+
const form = createForm({ model, schema });
|
|
55
|
+
// model.get() === { username: '', language: 'ru', marketing: true }
|
|
56
|
+
// после правок: model.reset() возвращает к этим значениям
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Async preload через external hook + model.set
|
|
60
|
+
|
|
61
|
+
```tsx
|
|
62
|
+
import { useEffect, useState } from 'react';
|
|
63
|
+
import type { FormModel, FormProxy } from '@reformer/core';
|
|
64
|
+
|
|
65
|
+
interface LoadingState { isLoading: boolean; error: string | null }
|
|
66
|
+
|
|
67
|
+
export function useLoadCreditApplication(
|
|
68
|
+
model: FormModel<CreditApplicationForm>,
|
|
69
|
+
form: FormProxy<CreditApplicationForm>,
|
|
70
|
+
applicationId: string | null
|
|
71
|
+
): LoadingState {
|
|
72
|
+
const [state, setState] = useState<LoadingState>({ isLoading: !!applicationId, error: null });
|
|
73
|
+
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (!applicationId) { setState({ isLoading: false, error: null }); return; }
|
|
76
|
+
let cancelled = false;
|
|
77
|
+
|
|
78
|
+
(async () => {
|
|
79
|
+
setState({ isLoading: true, error: null });
|
|
80
|
+
try {
|
|
81
|
+
const [appResp, dictsResp] = await Promise.all([
|
|
82
|
+
fetchCreditApplication(applicationId),
|
|
83
|
+
fetchDictionaries(),
|
|
84
|
+
]);
|
|
85
|
+
if (cancelled) return; // race-guard: сменили applicationId / unmount
|
|
86
|
+
if (appResp.status !== 200 || dictsResp.status !== 200) throw new Error('Сервер вернул ошибку');
|
|
87
|
+
|
|
88
|
+
// Загрузка значений в модель
|
|
89
|
+
model.set(appResp.data);
|
|
90
|
+
|
|
91
|
+
// Динамические componentProps — через queueMicrotask (после реактивных эффектов от set)
|
|
92
|
+
queueMicrotask(() => {
|
|
93
|
+
if (cancelled) return;
|
|
94
|
+
form.registrationAddress.city.updateComponentProps({ options: dictsResp.data.cities });
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
setState({ isLoading: false, error: null });
|
|
98
|
+
} catch (err) {
|
|
99
|
+
if (cancelled) return;
|
|
100
|
+
setState({ isLoading: false, error: err instanceof Error ? err.message : 'Ошибка' });
|
|
101
|
+
}
|
|
102
|
+
})();
|
|
103
|
+
|
|
104
|
+
return () => { cancelled = true; };
|
|
105
|
+
}, [applicationId]); // model/form стабильны (создан через useMemo)
|
|
106
|
+
|
|
107
|
+
return state;
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### Preload через behavior — onChange на поле
|
|
112
|
+
|
|
113
|
+
Загрузка справочника при выборе другого поля (после preload) — через `onChange`:
|
|
114
|
+
|
|
115
|
+
```typescript
|
|
116
|
+
import { defineFormBehavior, onChange } from '@reformer/core/behaviors';
|
|
117
|
+
|
|
118
|
+
export const addressBehavior = defineFormBehavior<AddressForm>(({ model, form }) => {
|
|
119
|
+
onChange(
|
|
120
|
+
model.$.region,
|
|
121
|
+
async (region, { signal }) => {
|
|
122
|
+
if (!region) { form.city.updateComponentProps({ options: [] }); return; }
|
|
123
|
+
try {
|
|
124
|
+
const cities = await fetchCities(region, { signal });
|
|
125
|
+
form.city.updateComponentProps({ options: cities });
|
|
126
|
+
} catch {
|
|
127
|
+
form.city.updateComponentProps({ options: [] });
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
{ debounce: 300 }
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Anti-patterns
|
|
136
|
+
|
|
137
|
+
```typescript
|
|
138
|
+
// ❌ model/form пересоздаются на каждый рендер → preload запускается каждый раз
|
|
139
|
+
function MyForm() {
|
|
140
|
+
const model = createModel<T>(initial); // КАЖДЫЙ рендер!
|
|
141
|
+
const form = createForm({ model, schema });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ✅ Стабильные ссылки через useMemo
|
|
145
|
+
function MyForm() {
|
|
146
|
+
const { model, form } = useMemo(() => {
|
|
147
|
+
const m = createModel<T>(initial);
|
|
148
|
+
return { model: m, form: createForm({ model: m, schema: buildSchema(m) }) };
|
|
149
|
+
}, []);
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
```typescript
|
|
154
|
+
// ❌ updateComponentProps синхронно после set → возможный конфликт с реактивными эффектами
|
|
155
|
+
model.set(data);
|
|
156
|
+
form.region.updateComponentProps({ options: [...] });
|
|
157
|
+
|
|
158
|
+
// ✅ queueMicrotask, чтобы реактивные эффекты от set завершились
|
|
159
|
+
model.set(data);
|
|
160
|
+
queueMicrotask(() => form.region.updateComponentProps({ options: [...] }));
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
```typescript
|
|
164
|
+
// ❌ Нет race-guard в async useEffect
|
|
165
|
+
useEffect(() => { fetchData(id).then((d) => model.set(d)); }, [id]);
|
|
166
|
+
|
|
167
|
+
// ✅ Cleanup-флаг
|
|
168
|
+
useEffect(() => {
|
|
169
|
+
let cancelled = false;
|
|
170
|
+
fetchData(id).then((d) => { if (!cancelled) model.set(d); });
|
|
171
|
+
return () => { cancelled = true; };
|
|
172
|
+
}, [id]);
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
## Troubleshooting
|
|
176
|
+
|
|
177
|
+
**Q: После `set`/`patch` не показываются ошибки.**
|
|
178
|
+
A: `set`/`patch` не запускают валидацию. После загрузки вызови `await validateModel(model, schema)` (внешний раннер из `@reformer/core/validation`).
|
|
179
|
+
|
|
180
|
+
**Q: Опции в `<Select>` не появляются после `updateComponentProps`.**
|
|
181
|
+
A: (1) Оберни вызов в `queueMicrotask`; (2) убедись, что компонент подписан через `useFormControl`;
|
|
182
|
+
(3) для массивов — пройдись по элементам (`form.items.map`) и обнови каждый.
|
|
183
|
+
|
|
184
|
+
**Q: При `reset()` теряются данные с сервера.**
|
|
185
|
+
A: `reset()` возвращает к initial-снимку из `createModel`, не к последнему `set`. Чтобы «сбросить
|
|
186
|
+
к данным с сервера» — после `set` вызови `model.captureInitial()` (новая точка отсчёта).
|
|
187
|
+
|
|
188
|
+
## See also
|
|
189
|
+
|
|
190
|
+
- [28-submit-and-reset.md](./28-submit-and-reset.md) — обратная сторона жизненного цикла
|
|
191
|
+
- [11-async-watchfield.md](./11-async-watchfield.md) — `onChange` для динамики после preload
|
|
192
|
+
- [22-cycle-detection.md](./22-cycle-detection.md) — почему `queueMicrotask` нужен
|
|
193
|
+
- [16-ui-components.md](./16-ui-components.md) — `updateComponentProps` для динамических опций
|