@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,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` для динамических опций
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
## 30. TYPE-SAFETY RECIPES
|
|
2
|
+
|
|
3
|
+
Идиоматичные паттерны, которые держат сгенерированный код без `any` и `as`-кастов под M1.
|
|
4
|
+
|
|
5
|
+
### Recipe 1 — Imports (root cause prevention)
|
|
6
|
+
|
|
7
|
+
- Модель/форма/хуки/типы/**примитивы behaviors** — из `@reformer/core`.
|
|
8
|
+
- Схема валидации (операторы + раннер `validateModel`) — из `@reformer/core/validation`.
|
|
9
|
+
- Чистые фабрики валидаторов — из `@reformer/core/validators`.
|
|
10
|
+
- Декларативный DSL (`defineFormBehavior` + операторы) — из `@reformer/core/behaviors`.
|
|
11
|
+
|
|
12
|
+
```typescript
|
|
13
|
+
import {
|
|
14
|
+
createModel,
|
|
15
|
+
createForm,
|
|
16
|
+
type FormModel,
|
|
17
|
+
type FormProxy,
|
|
18
|
+
type ModelSignals,
|
|
19
|
+
} from '@reformer/core';
|
|
20
|
+
import {
|
|
21
|
+
defineValidationSchema,
|
|
22
|
+
validate,
|
|
23
|
+
cross,
|
|
24
|
+
validateModel,
|
|
25
|
+
type Rule,
|
|
26
|
+
} from '@reformer/core/validation';
|
|
27
|
+
import { required, min, max, email } from '@reformer/core/validators';
|
|
28
|
+
import { defineFormBehavior, compute, enableWhen, onChange } from '@reformer/core/behaviors';
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
> **watchField — из `@reformer/core`** (примитив), НЕ из `@reformer/core/behaviors` (там `onChange`).
|
|
32
|
+
|
|
33
|
+
### Recipe 2 — Form-shape types as `type`, not `interface`
|
|
34
|
+
|
|
35
|
+
`Record<string, FormValue>` требует index signature. У `interface` её нет неявно; у `type` —
|
|
36
|
+
структурно. Объявляй через `type`-alias всё, что попадает в `FormProxy<T>`/`ArrayNode<T>`:
|
|
37
|
+
корневую форму, вложенные группы, типы элементов массива.
|
|
38
|
+
|
|
39
|
+
```typescript
|
|
40
|
+
export type PropertyItem = {
|
|
41
|
+
type: 'apartment' | 'house' | 'car';
|
|
42
|
+
description: string;
|
|
43
|
+
estimatedValue: number;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type CreditApplicationForm = {
|
|
47
|
+
loanAmount: number | null;
|
|
48
|
+
properties: PropertyItem[];
|
|
49
|
+
// ...
|
|
50
|
+
};
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Recipe 3 — Схема привязана к сигналам модели
|
|
54
|
+
|
|
55
|
+
`value` поля — это сигнал модели (`model.$.field`), а не литерал. Тип поля выводится из сигнала.
|
|
56
|
+
Layout-схема БЕЗ валидаторов — правила живут в отдельной validation-схеме.
|
|
57
|
+
|
|
58
|
+
```typescript
|
|
59
|
+
const model = createModel<CreditApplicationForm>(initial);
|
|
60
|
+
|
|
61
|
+
const schema = {
|
|
62
|
+
loanAmount: { value: model.$.loanAmount, component: Input },
|
|
63
|
+
// вложенная группа — builder, принимающий ModelSignals<Sub>
|
|
64
|
+
personalData: personalDataNodes(model.$.personalData),
|
|
65
|
+
// массив — { array, item }
|
|
66
|
+
properties: { array: model.properties, item: propertyItem },
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// правила — отдельно; типы полей выводятся из сигналов
|
|
70
|
+
const validation = defineValidationSchema<CreditApplicationForm>(({ model }) => {
|
|
71
|
+
validate(model.$.loanAmount, [required(), min(50000)]);
|
|
72
|
+
});
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Recipe 4 — Cross-field валидаторы: `cross` над типизированным снапшотом
|
|
76
|
+
|
|
77
|
+
Правило — обычная функция `(f: Root) => ValidationError | null` над снапшотом `model.get()`.
|
|
78
|
+
Соседние поля читаются без `as`; ошибка вешается на поле-носитель `sig`:
|
|
79
|
+
|
|
80
|
+
```typescript
|
|
81
|
+
import type { ValidationError } from '@reformer/core';
|
|
82
|
+
|
|
83
|
+
const initialPaymentVsProperty = (f: CreditApplicationForm): ValidationError | null =>
|
|
84
|
+
f.initialPayment && f.propertyValue && f.initialPayment > f.propertyValue
|
|
85
|
+
? { code: 'tooHigh', message: 'Взнос не может превышать стоимость' }
|
|
86
|
+
: null;
|
|
87
|
+
|
|
88
|
+
// внутри defineValidationSchema<CreditApplicationForm>(({ model }) => { ... }):
|
|
89
|
+
cross(model.$.initialPayment, initialPaymentVsProperty);
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Recipe 5 — `compute` читает модель напрямую (без аннотаций)
|
|
93
|
+
|
|
94
|
+
`compute(target, () => …)` читает value-модель (`model.field`) — типы полей выводятся из типа
|
|
95
|
+
модели, `as`-касты не нужны:
|
|
96
|
+
|
|
97
|
+
```typescript
|
|
98
|
+
compute(model.$.monthlyPayment, () =>
|
|
99
|
+
annuityMonthly(model.loanAmount ?? 0, model.loanTerm ?? 0, model.interestRate ?? 0)
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
// nested reads — тоже напрямую
|
|
103
|
+
compute(model.$.fullName, () =>
|
|
104
|
+
[model.personalData.firstName, model.personalData.lastName].filter(Boolean).join(' ')
|
|
105
|
+
);
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Recipe 6 — `null` vs `undefined` для опциональных полей
|
|
109
|
+
|
|
110
|
+
Оба работают. `null` — конвенция «пользователь очистил поле». Встроенные валидаторы
|
|
111
|
+
(`min`, `max`, `minLength`, `maxLength`, `minDate`, `maxDate`, `minAge`, `maxAge`) пропускают
|
|
112
|
+
пустые значения — guard `if (value != null)` не нужен.
|
|
113
|
+
|
|
114
|
+
```typescript
|
|
115
|
+
export type CreditForm = {
|
|
116
|
+
loanAmount: number | null; // min(model.$.loanAmount, 50000) — ок
|
|
117
|
+
loanPurpose: string | null; // minLength — ок
|
|
118
|
+
birthDate: string | null; // minAge — ок
|
|
119
|
+
};
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Recipe 7 — Нода поля для кастомных компонентов
|
|
123
|
+
|
|
124
|
+
`useFormControl(control)` типизируется по `FieldNode<T>`. В props компонента используй `FieldNode<T>`:
|
|
125
|
+
|
|
126
|
+
```typescript
|
|
127
|
+
import type { FieldNode } from '@reformer/core';
|
|
128
|
+
|
|
129
|
+
type MyFieldProps<T> = { control: FieldNode<T> };
|
|
130
|
+
function MyField<T>({ control }: MyFieldProps<T>) {
|
|
131
|
+
const { value, errors, disabled } = useFormControl(control);
|
|
132
|
+
// ...
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### Recipe 8 — Вынос правил в именованные функции
|
|
137
|
+
|
|
138
|
+
Field-правила — именованные `Rule<T>`, validation-схема остаётся плоской:
|
|
139
|
+
|
|
140
|
+
```typescript
|
|
141
|
+
import type { Rule } from '@reformer/core/validation';
|
|
142
|
+
|
|
143
|
+
const validateAdultAge: Rule<string | null> = (value) => {
|
|
144
|
+
if (!value) return null;
|
|
145
|
+
const age = new Date().getFullYear() - new Date(value).getFullYear();
|
|
146
|
+
return age < 18 ? { code: 'tooYoung', message: 'Минимум 18 лет' } : null;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
// внутри defineValidationSchema<MyForm>(({ model }) => { ... }):
|
|
150
|
+
validate(model.$.birthDate, [validateAdultAge]);
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### Anti-patterns to avoid
|
|
154
|
+
|
|
155
|
+
- `import { computeFrom } from '@reformer/core/behaviors'` для примитива, вызываемого вне
|
|
156
|
+
`defineFormBehavior` → примитив живёт в `@reformer/core` (возвращает cleanup).
|
|
157
|
+
- `interface MyForm { ... }` для form-shape → см. Recipe 2.
|
|
158
|
+
- `as`-касты значений полей внутри `compute` → читай `model.field` напрямую (Recipe 5).
|
|
159
|
+
- строковые пути / `(form) => ...` в behaviors → это удалённый API, используй сигналы (`model.$.x`).
|