@reformer/core 10.0.0-beta.2 → 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.
Files changed (67) hide show
  1. package/dist/{behaviors-node-DUrqAqp1.js → behaviors-node-DGEFMTQu.js} +1 -1
  2. package/dist/{behaviors-value-lCpqicfa.js → behaviors-value-C7dSEhCc.js} +43 -57
  3. package/dist/behaviors.js +108 -107
  4. package/dist/derived-registry-CluZCQ7U.js +16 -0
  5. package/dist/form/form-submitter.d.ts +0 -4
  6. package/dist/form/hooks/types.d.ts +3 -3
  7. package/dist/form/hooks/use-form-validation.d.ts +48 -0
  8. package/dist/form/index.d.ts +2 -3
  9. package/dist/form/nodes/array-node.d.ts +0 -84
  10. package/dist/form/nodes/form-node.d.ts +0 -14
  11. package/dist/form/nodes/group-node.d.ts +0 -4
  12. package/dist/form/status-machine.d.ts +0 -38
  13. package/dist/form/types/contracts.d.ts +0 -6
  14. package/dist/form/types/deep-schema.d.ts +0 -9
  15. package/dist/form/types/index.d.ts +4 -38
  16. package/dist/form/types/schema-node.d.ts +13 -16
  17. package/dist/form/types/validation-schema.d.ts +0 -25
  18. package/dist/form/unique-id.d.ts +3 -11
  19. package/dist/form/validation-schema.d.ts +16 -3
  20. package/dist/form/validation-strategy.d.ts +55 -0
  21. package/dist/form/validators.d.ts +8 -3
  22. package/dist/form-model-DYCzGGKF.js +286 -0
  23. package/dist/index.d.ts +1 -1
  24. package/dist/index.js +477 -648
  25. package/dist/runtime-token.d.ts +23 -0
  26. package/dist/state/form-model.d.ts +12 -1
  27. package/dist/state.js +17 -15
  28. package/dist/subscription-manager-705bls97.js +171 -0
  29. package/dist/validation.js +133 -81
  30. package/dist/validators.d.ts +0 -4
  31. package/dist/validators.js +88 -122
  32. package/docs/llms/01-api-reference.md +63 -0
  33. package/docs/llms/02-quick-start.md +143 -0
  34. package/docs/llms/03-api-signatures.md +273 -0
  35. package/docs/llms/04-common-patterns.md +202 -0
  36. package/docs/llms/05-common-mistakes.md +167 -0
  37. package/docs/llms/06-troubleshooting.md +11 -0
  38. package/docs/llms/07-complete-import.md +99 -0
  39. package/docs/llms/08-form-types.md +47 -0
  40. package/docs/llms/09-formschema.md +143 -0
  41. package/docs/llms/10-arrays.md +161 -0
  42. package/docs/llms/11-async-watchfield.md +53 -0
  43. package/docs/llms/12-array-cleanup.md +31 -0
  44. package/docs/llms/13-multi-step.md +91 -0
  45. package/docs/llms/14-extended-mistakes.md +98 -0
  46. package/docs/llms/15-project-structure.md +125 -0
  47. package/docs/llms/16-ui-components.md +189 -0
  48. package/docs/llms/17-nonexistent-api.md +93 -0
  49. package/docs/llms/18-conditional-fields.md +183 -0
  50. package/docs/llms/19-reading-values.md +59 -0
  51. package/docs/llms/20-compute-vs-watch.md +138 -0
  52. package/docs/llms/21-array-operations.md +113 -0
  53. package/docs/llms/22-cycle-detection.md +86 -0
  54. package/docs/llms/23-copy-from.md +141 -0
  55. package/docs/llms/24-sync-fields.md +114 -0
  56. package/docs/llms/25-reset-when.md +115 -0
  57. package/docs/llms/26-transform-value.md +136 -0
  58. package/docs/llms/27-revalidate-when.md +132 -0
  59. package/docs/llms/28-submit-and-reset.md +259 -0
  60. package/docs/llms/29-async-preload.md +193 -0
  61. package/docs/llms/30-type-safety-recipes.md +159 -0
  62. package/docs/llms/31-async-validator-debounce.md +135 -0
  63. package/docs/llms/32-async-options-loading.md +108 -0
  64. package/docs/llms/33-validation-strategy.md +193 -0
  65. package/llms.txt +435 -1580
  66. package/package.json +33 -10
  67. package/dist/form-model-C8Fi83k4.js +0 -439
@@ -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`).