@reformer/core 11.0.0-beta.8 → 11.0.0-beta.9

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 (224) hide show
  1. package/README.md +91 -73
  2. package/dist/behaviors-value-HA_Q03hc.js +108 -0
  3. package/dist/behaviors.js +370 -242
  4. package/dist/{create-model-BBRHhHwK.js → create-model-BvcbNPgL.js} +6 -6
  5. package/dist/date-utils-90-qZbse.js +43 -0
  6. package/dist/{en-CjAqn7h0.js → en-HP8XEych.js} +6 -3
  7. package/dist/form/aggregate-signals.d.ts +21 -63
  8. package/dist/form/behaviors/collections.d.ts +28 -7
  9. package/dist/form/behaviors/context.d.ts +19 -2
  10. package/dist/form/behaviors/index.d.ts +3 -3
  11. package/dist/form/behaviors/internals.d.ts +9 -2
  12. package/dist/form/behaviors/node.d.ts +25 -2
  13. package/dist/form/behaviors/operators.d.ts +17 -9
  14. package/dist/form/behaviors/schema-operators.d.ts +6 -4
  15. package/dist/form/behaviors/types.d.ts +12 -4
  16. package/dist/form/create-form.d.ts +47 -35
  17. package/dist/form/flow/flow.d.ts +87 -0
  18. package/dist/form/form-bundle.d.ts +45 -28
  19. package/dist/form/form-config-base.d.ts +46 -0
  20. package/dist/form/form-level.d.ts +121 -0
  21. package/dist/form/form-proxy-builder.d.ts +6 -22
  22. package/dist/form/index.d.ts +16 -21
  23. package/dist/form/nodes/form-array-node.d.ts +100 -0
  24. package/dist/form/nodes/form-control-node.d.ts +73 -0
  25. package/dist/form/nodes/form-group-node.d.ts +66 -0
  26. package/dist/form/nodes/node-kind.d.ts +29 -0
  27. package/dist/form/nodes/state/availability.d.ts +6 -0
  28. package/dist/form/nodes/state/component.d.ts +6 -0
  29. package/dist/form/nodes/state/index.d.ts +12 -0
  30. package/dist/form/nodes/state/interaction.d.ts +13 -0
  31. package/dist/form/nodes/state/issues.d.ts +9 -0
  32. package/dist/form/nodes/state/pending.d.ts +6 -0
  33. package/dist/form/schema/compile.d.ts +59 -0
  34. package/dist/form/schema/compiled.d.ts +143 -0
  35. package/dist/form/schema/row-key.d.ts +11 -0
  36. package/dist/form/schema-controller.d.ts +30 -14
  37. package/dist/form/signal-node-registry.d.ts +6 -7
  38. package/dist/form/submission.d.ts +93 -0
  39. package/dist/form/type-guards.d.ts +14 -73
  40. package/dist/form/types/contracts.d.ts +19 -92
  41. package/dist/form/types/field-config.d.ts +27 -0
  42. package/dist/form/types/index.d.ts +5 -57
  43. package/dist/form/types/nodes.d.ts +159 -0
  44. package/dist/form/types/schema-node.d.ts +139 -63
  45. package/dist/form/types/validation-schema.d.ts +23 -27
  46. package/dist/form/validation/context.d.ts +42 -7
  47. package/dist/form/validation/index.d.ts +9 -8
  48. package/dist/form/validation/operators.d.ts +22 -30
  49. package/dist/form/validation/run.d.ts +93 -18
  50. package/dist/form/validation/strategy.d.ts +41 -13
  51. package/dist/form/validation/types.d.ts +74 -20
  52. package/dist/form/validators/date-utils.d.ts +13 -1
  53. package/dist/form/validators/email.d.ts +8 -7
  54. package/dist/form/validators/file-type.d.ts +8 -7
  55. package/dist/form/validators/file-utils.d.ts +2 -1
  56. package/dist/form/validators/future-date.d.ts +8 -7
  57. package/dist/form/validators/index.d.ts +1 -1
  58. package/dist/form/validators/integer.d.ts +8 -7
  59. package/dist/form/validators/is-date.d.ts +11 -9
  60. package/dist/form/validators/is-number.d.ts +11 -9
  61. package/dist/form/validators/max-age.d.ts +13 -9
  62. package/dist/form/validators/max-date.d.ts +8 -7
  63. package/dist/form/validators/max-file-size.d.ts +8 -7
  64. package/dist/form/validators/max-files.d.ts +8 -7
  65. package/dist/form/validators/max-length.d.ts +14 -7
  66. package/dist/form/validators/max-total-file-size.d.ts +8 -7
  67. package/dist/form/validators/max.d.ts +10 -8
  68. package/dist/form/validators/min-age.d.ts +12 -9
  69. package/dist/form/validators/min-date.d.ts +8 -7
  70. package/dist/form/validators/min-file-size.d.ts +8 -7
  71. package/dist/form/validators/min-files.d.ts +11 -9
  72. package/dist/form/validators/min-length.d.ts +14 -7
  73. package/dist/form/validators/min.d.ts +10 -8
  74. package/dist/form/validators/multiple-of.d.ts +10 -8
  75. package/dist/form/validators/non-negative.d.ts +8 -7
  76. package/dist/form/validators/non-zero.d.ts +8 -7
  77. package/dist/form/validators/past-date.d.ts +8 -7
  78. package/dist/form/validators/pattern.d.ts +9 -8
  79. package/dist/form/validators/phone.d.ts +8 -7
  80. package/dist/form/validators/required.d.ts +11 -9
  81. package/dist/form/validators/url.d.ts +20 -13
  82. package/dist/form/validators/validation-issue.d.ts +8 -0
  83. package/dist/i18n/descriptor.d.ts +1 -1
  84. package/dist/i18n/en.json.d.ts +5 -1
  85. package/dist/i18n/locale-store.d.ts +7 -0
  86. package/dist/i18n/message-format.d.ts +24 -4
  87. package/dist/i18n/ru.json.d.ts +5 -1
  88. package/dist/i18n/translator.d.ts +11 -0
  89. package/dist/i18n/validation-message.d.ts +42 -7
  90. package/dist/i18n.d.ts +7 -12
  91. package/dist/i18n.js +66 -283
  92. package/dist/index.d.ts +16 -7
  93. package/dist/index.js +924 -2855
  94. package/dist/loader-BWkigpVe.js +52 -0
  95. package/dist/locale/en.js +1 -1
  96. package/dist/locale/en.json +4 -1
  97. package/dist/locale/ru.js +4 -1
  98. package/dist/locale/ru.json +4 -1
  99. package/dist/locale-CG54AU2Q.js +219 -0
  100. package/dist/locale.js +1 -1
  101. package/dist/model/behaviors-value.d.ts +24 -6
  102. package/dist/model/create-model.d.ts +2 -2
  103. package/dist/model/derived-registry.d.ts +1 -1
  104. package/dist/model/index.d.ts +11 -5
  105. package/dist/model/model-nodes.d.ts +79 -12
  106. package/dist/model/model-signals-proxy.d.ts +5 -6
  107. package/dist/model/model-value-proxy.d.ts +20 -15
  108. package/dist/model/safe-effect.d.ts +33 -1
  109. package/dist/model/types.d.ts +63 -13
  110. package/dist/model.js +20 -18
  111. package/dist/node-kind-C-r6ZMdo.js +15 -0
  112. package/dist/platforms/react/field-binding/field-adapter.d.ts +1 -1
  113. package/dist/platforms/react/field-binding/field-handle.d.ts +5 -2
  114. package/dist/platforms/react/form-bundle-context.d.ts +2 -2
  115. package/dist/platforms/react/hooks/types.d.ts +57 -38
  116. package/dist/platforms/react/hooks/use-form-bundle.d.ts +19 -4
  117. package/dist/platforms/react/hooks/use-form-flow.d.ts +40 -0
  118. package/dist/platforms/react/hooks/use-form-validation.d.ts +12 -6
  119. package/dist/platforms/react/hooks/useArrayLength.d.ts +6 -6
  120. package/dist/platforms/react/hooks/useFormControl.d.ts +7 -9
  121. package/dist/platforms/react/hooks/useFormControlValue.d.ts +18 -19
  122. package/dist/platforms/react/hooks/useSignalSubscription.d.ts +9 -1
  123. package/dist/platforms/react/i18n/context.d.ts +6 -1
  124. package/dist/platforms/react/i18n/hooks.d.ts +16 -10
  125. package/dist/platforms/react/i18n/provider.d.ts +9 -2
  126. package/dist/platforms/react/index.d.ts +6 -4
  127. package/dist/react.d.ts +22 -0
  128. package/dist/react.js +505 -0
  129. package/dist/runtime-token.d.ts +1 -2
  130. package/dist/safe-effect-BbtX3KuN.js +643 -0
  131. package/dist/schema-controller-CpiO2JoK.js +143 -0
  132. package/dist/signals.d.ts +15 -9
  133. package/dist/signals.js +11 -6
  134. package/dist/strategy-CoFSemuP.js +375 -0
  135. package/dist/type-guards-D-N_5Ddh.js +19 -0
  136. package/dist/validation-issue-B9mjI8jz.js +7 -0
  137. package/dist/validation-message-Ced9MGxs.js +194 -0
  138. package/dist/validation.js +9 -9
  139. package/dist/validators/email.js +5 -5
  140. package/dist/validators/future-date.js +8 -8
  141. package/dist/validators/integer.js +3 -3
  142. package/dist/validators/is-date.js +4 -4
  143. package/dist/validators/is-number.js +4 -4
  144. package/dist/validators/max-age.js +8 -9
  145. package/dist/validators/max-date.js +8 -8
  146. package/dist/validators/max-length.js +7 -7
  147. package/dist/validators/max.js +4 -4
  148. package/dist/validators/min-age.js +10 -8
  149. package/dist/validators/min-date.js +3 -3
  150. package/dist/validators/min-length.js +5 -5
  151. package/dist/validators/min.js +4 -4
  152. package/dist/validators/multiple-of.js +11 -7
  153. package/dist/validators/non-negative.js +3 -3
  154. package/dist/validators/non-zero.js +3 -3
  155. package/dist/validators/past-date.js +8 -8
  156. package/dist/validators/pattern.js +5 -5
  157. package/dist/validators/phone.js +11 -9
  158. package/dist/validators/required.js +2 -2
  159. package/dist/validators/url.js +48 -12
  160. package/dist/validators.js +17 -17
  161. package/docs/llms/01-api-reference.md +60 -34
  162. package/docs/llms/02-quick-start.md +111 -80
  163. package/docs/llms/03-api-signatures.md +368 -153
  164. package/docs/llms/04-common-patterns.md +58 -56
  165. package/docs/llms/05-common-mistakes.md +69 -44
  166. package/docs/llms/06-troubleshooting.md +14 -9
  167. package/docs/llms/07-complete-import.md +74 -43
  168. package/docs/llms/08-form-types.md +11 -4
  169. package/docs/llms/09-formschema.md +129 -81
  170. package/docs/llms/10-arrays.md +106 -75
  171. package/docs/llms/11-async-watchfield.md +2 -2
  172. package/docs/llms/13-multi-step.md +134 -63
  173. package/docs/llms/14-extended-mistakes.md +20 -17
  174. package/docs/llms/15-project-structure.md +221 -74
  175. package/docs/llms/16-ui-components.md +55 -42
  176. package/docs/llms/17-nonexistent-api.md +199 -32
  177. package/docs/llms/18-conditional-fields.md +66 -32
  178. package/docs/llms/19-reading-values.md +15 -3
  179. package/docs/llms/20-compute-vs-watch.md +16 -10
  180. package/docs/llms/21-array-operations.md +51 -27
  181. package/docs/llms/22-cycle-detection.md +1 -1
  182. package/docs/llms/23-copy-from.md +2 -2
  183. package/docs/llms/24-sync-fields.md +6 -5
  184. package/docs/llms/25-reset-when.md +14 -7
  185. package/docs/llms/26-transform-value.md +4 -4
  186. package/docs/llms/27-revalidate-when.md +16 -15
  187. package/docs/llms/28-submit-and-reset.md +276 -128
  188. package/docs/llms/29-async-preload.md +39 -29
  189. package/docs/llms/30-type-safety-recipes.md +105 -51
  190. package/docs/llms/31-async-validator-debounce.md +96 -53
  191. package/docs/llms/33-validation-strategy.md +177 -65
  192. package/docs/llms/34-i18n.md +69 -43
  193. package/docs/llms/35-unified-contract.md +100 -39
  194. package/docs/llms/36-issue-messages.md +284 -0
  195. package/docs/llms/37-compiled-schema.md +332 -0
  196. package/docs/llms/38-form-flow.md +397 -0
  197. package/llms-index.json +1 -1
  198. package/llms.txt +6084 -3420
  199. package/package.json +23 -4
  200. package/dist/behaviors-value-C7dSEhCc.js +0 -134
  201. package/dist/date-utils-dTT_x_Be.js +0 -31
  202. package/dist/form/create-core-form.d.ts +0 -72
  203. package/dist/form/factories/index.d.ts +0 -6
  204. package/dist/form/factories/node-factory.d.ts +0 -189
  205. package/dist/form/form-submitter.d.ts +0 -121
  206. package/dist/form/nodes/array-node.d.ts +0 -276
  207. package/dist/form/nodes/field-node.d.ts +0 -297
  208. package/dist/form/nodes/form-node.d.ts +0 -326
  209. package/dist/form/nodes/group-node.d.ts +0 -284
  210. package/dist/form/nodes/model-array-node.d.ts +0 -126
  211. package/dist/form/nodes/subscription-manager.d.ts +0 -180
  212. package/dist/form/status-machine.d.ts +0 -115
  213. package/dist/form/types/deep-schema.d.ts +0 -107
  214. package/dist/form/types/form-proxy.d.ts +0 -141
  215. package/dist/form/unique-id.d.ts +0 -45
  216. package/dist/form/validation/config.d.ts +0 -73
  217. package/dist/form/validation/error-handler.d.ts +0 -166
  218. package/dist/form/validators/validation-error.d.ts +0 -8
  219. package/dist/index-BrNkQj1y.js +0 -105
  220. package/dist/loader-Dv_Hl7JC.js +0 -216
  221. package/dist/model-value-proxy-caTmAiUd.js +0 -419
  222. package/dist/node-BeV_lV5i.js +0 -100
  223. package/dist/strategy-DgzELWno.js +0 -204
  224. package/dist/validation-error-B-U3RsV0.js +0 -11
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Purpose
4
4
 
5
- Загрузка формы данными с сервера под M1: (1) **initial values** в `createModel(...)`; (2)
5
+ Загрузка формы данными с сервера: (1) **initial values** в `createModel(...)` / `createForm({ initial })`; (2)
6
6
  **`model.set` / `model.patch`** для загрузки/обновления значений; (3) **external React-hook**
7
7
  для full-blown async preload (параллельный fetch заявки + справочников, обработка ошибок,
8
8
  race-guard через deps). Динамические `componentProps` (опции селектов) обновляются через
@@ -14,7 +14,8 @@ race-guard через deps). Динамические `componentProps` (опци
14
14
  ```typescript
15
15
  // Модель:
16
16
  model.set(value: T): void; // полная установка значений (load полного DTO). Не меняет initial.
17
- model.patch(value: Partial<T>): void; // частичное слияние (только переданные ключи). Не меняет initial.
17
+ // DTO без части ключей: поля остаются прежними, в dev — предупреждение.
18
+ model.patch(value: DeepPartial<T>): void; // частичное слияние (только переданные ключи, на любой глубине групп; undefined — «не трогать»). Не меняет initial.
18
19
  model.reset(): void; // вернуть к initial-снимку
19
20
  model.captureInitial(): void; // сделать текущие значения новым initial-снимком
20
21
 
@@ -30,26 +31,36 @@ Initial values задаются в `createModel(initial)`; `reset()` возвр
30
31
  ### Initial values в модели
31
32
 
32
33
  ```typescript
33
- import { createModel, createFormFromModel } from '@reformer/core';
34
+ import { createModel, createFormFromModel, type FormSchemaNode } from '@reformer/core';
34
35
  import { Input, SelectAsync, CheckboxWithLabel } from '@reformer/ui-kit';
35
36
 
36
- type ProfileForm = { username: string; language: 'ru' | 'en'; marketing: boolean };
37
+ interface ProfileForm {
38
+ username: string;
39
+ language: 'ru' | 'en';
40
+ marketing: boolean;
41
+ }
37
42
 
38
43
  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: SelectAsync,
44
- componentProps: {
45
- label: 'Язык',
46
- options: [
47
- { value: 'ru', label: 'Русский' },
48
- { value: 'en', label: 'English' },
49
- ],
44
+ const schema: FormSchemaNode = {
45
+ children: [
46
+ { model: model.$.username, component: Input, componentProps: { label: 'Username' } },
47
+ {
48
+ model: model.$.language,
49
+ component: SelectAsync,
50
+ componentProps: {
51
+ label: 'Язык',
52
+ options: [
53
+ { value: 'ru', label: 'Русский' },
54
+ { value: 'en', label: 'English' },
55
+ ],
56
+ },
57
+ },
58
+ {
59
+ model: model.$.marketing,
60
+ component: CheckboxWithLabel,
61
+ componentProps: { label: 'Рассылка' },
50
62
  },
51
- },
52
- marketing: { value: model.$.marketing, component: CheckboxWithLabel, componentProps: { label: 'Рассылка' } },
63
+ ],
53
64
  };
54
65
  const form = createFormFromModel({ model, schema });
55
66
  // model.get() === { username: '', language: 'ru', marketing: true }
@@ -60,13 +71,13 @@ const form = createFormFromModel({ model, schema });
60
71
 
61
72
  ```tsx
62
73
  import { useEffect, useState } from 'react';
63
- import type { FormModel, FormProxy } from '@reformer/core';
74
+ import type { FormModel, FormGroup } from '@reformer/core';
64
75
 
65
76
  interface LoadingState { isLoading: boolean; error: string | null }
66
77
 
67
78
  export function useLoadCreditApplication(
68
79
  model: FormModel<CreditApplicationForm>,
69
- form: FormProxy<CreditApplicationForm>,
80
+ form: FormGroup<CreditApplicationForm>,
70
81
  applicationId: string | null
71
82
  ): LoadingState {
72
83
  const [state, setState] = useState<LoadingState>({ isLoading: !!applicationId, error: null });
@@ -102,7 +113,7 @@ export function useLoadCreditApplication(
102
113
  })();
103
114
 
104
115
  return () => { cancelled = true; };
105
- }, [applicationId]); // model/form стабильны (создан через useMemo)
116
+ }, [applicationId]); // model/form стабильны (сборка через useFormBundle)
106
117
 
107
118
  return state;
108
119
  }
@@ -137,16 +148,13 @@ export const addressBehavior = defineFormBehavior<AddressForm>(({ model, form })
137
148
  ```typescript
138
149
  // ❌ model/form пересоздаются на каждый рендер → preload запускается каждый раз
139
150
  function MyForm() {
140
- const model = createModel<T>(initial); // КАЖДЫЙ рендер!
141
- const form = createFormFromModel({ model, schema });
151
+ const bundle = createForm<T>({ initial, schema }); // КАЖДЫЙ рендер!
142
152
  }
143
153
 
144
- // ✅ Стабильные ссылки через useMemo
154
+ // ✅ Стабильная сборка: useFormBundle вызывает фабрику ровно один раз (useMemo React вправе
155
+ // сбросить и пересобрать форму, потеряв введённое)
145
156
  function MyForm() {
146
- const { model, form } = useMemo(() => {
147
- const m = createModel<T>(initial);
148
- return { model: m, form: createFormFromModel({ model: m, schema: buildSchema(m) }) };
149
- }, []);
157
+ const { model, form } = useFormBundle(() => createForm<T>({ initial, schema }));
150
158
  }
151
159
  ```
152
160
 
@@ -174,8 +182,10 @@ useEffect(() => {
174
182
 
175
183
  ## Troubleshooting
176
184
 
177
- **Q: После `set`/`patch` не показываются ошибки.**
178
- A: `set`/`patch` не запускают валидацию. После загрузки вызови `await validateModel(model, schema)` (внешний раннер из `@reformer/core/validation`).
185
+ **Q: После `set`/`patch` не показываются замечания.**
186
+ A: `set`/`patch` не запускают валидацию (кроме живой стратегии `change`). После загрузки вызови
187
+ `await bundle.validate()`; без сборки — `await validateModel(model, schema)` из
188
+ `@reformer/core/validation`.
179
189
 
180
190
  **Q: Опции в `<Select>` не появляются после `updateComponentProps`.**
181
191
  A: (1) Оберни вызов в `queueMicrotask`; (2) убедись, что компонент подписан через `useFormControl`;
@@ -1,12 +1,14 @@
1
1
  ## 30. TYPE-SAFETY RECIPES
2
2
 
3
- Идиоматичные паттерны, которые держат сгенерированный код без `any` и `as`-кастов под M1.
3
+ Идиоматичные паттерны, которые держат сгенерированный код без `any` и `as`-кастов.
4
4
 
5
5
  ### Recipe 1 — Imports (root cause prevention)
6
6
 
7
- - Модель/форма/хуки/типы/**примитивы behaviors** — из `@reformer/core`.
7
+ - Модель/форма/типы — из `@reformer/core`; хуки — из `@reformer/core/react`; примитивы над
8
+ сигналами — из `@reformer/core/model`.
8
9
  - Схема валидации (операторы + раннер `validateModel`) — из `@reformer/core/validation`.
9
10
  - Чистые фабрики валидаторов — из `@reformer/core/validators`.
11
+ - Словарь текстов замечаний (`defineIssueMessages`) — из `@reformer/core/i18n`.
10
12
  - Декларативный DSL (`defineFormBehavior` + операторы) — из `@reformer/core/behaviors`.
11
13
 
12
14
  ```typescript
@@ -14,13 +16,13 @@ import {
14
16
  createModel,
15
17
  createForm,
16
18
  type FormModel,
17
- type FormProxy,
18
- type ModelSignals,
19
+ type FormGroup,
20
+ type FormSchemaNode,
19
21
  } from '@reformer/core';
22
+ import { useFormBundle, useFormControl } from '@reformer/core/react';
20
23
  import {
21
- defineValidationSchema,
24
+ defineValidation,
22
25
  validate,
23
- cross,
24
26
  validateModel,
25
27
  type Rule,
26
28
  } from '@reformer/core/validation';
@@ -28,65 +30,96 @@ import { required, min, max, email } from '@reformer/core/validators';
28
30
  import { defineFormBehavior, compute, enableWhen, onChange } from '@reformer/core/behaviors';
29
31
  ```
30
32
 
31
- > **watchField — из `@reformer/core`** (примитив), НЕ из `@reformer/core/behaviors` (там `onChange`).
33
+ > **watchField — из `@reformer/core/model`** (примитив), НЕ из `@reformer/core/behaviors` (там `onChange`)
34
+ > и не из корня `@reformer/core`.
32
35
 
33
- ### Recipe 2 — Form-shape types as `type`, not `interface`
36
+ ### Recipe 2 — Form-shape types: `interface` или `type`
34
37
 
35
- `Record<string, FormValue>` требует index signature. У `interface` её нет неявно; у `type` —
36
- структурно. Объявляй через `type`-alias всё, что попадает в `FormProxy<T>`/`ArrayNode<T>`:
37
- корневую форму, вложенные группы, типы элементов массива.
38
+ Оба варианта работают одинаково. Ограничения `Record<string, …>` у `createModel<T>`,
39
+ `createForm<T>`, `FormGroup<T>` и `FormArray<T>` нет (`createModel` и `createForm` требуют только
40
+ `T extends object`). Вид ноды поля `FormFields<T>` выводит по виду значения: обычный объект —
41
+ `FormGroup`, массив объектов — `FormArray`, остальное (примитив, массив примитивов, `Date`, `File`)
42
+ — `FormControl`. Index signature здесь не участвует, поэтому `interface` распознаётся так же, как
43
+ `type`. Эталонная форма объявлена через `interface`.
38
44
 
39
45
  ```typescript
40
- export type PropertyItem = {
46
+ export interface PropertyItem {
41
47
  type: 'apartment' | 'house' | 'car';
42
48
  description: string;
43
49
  estimatedValue: number;
44
- };
50
+ }
45
51
 
46
- export type CreditApplicationForm = {
52
+ export interface CreditApplication {
47
53
  loanAmount: number | null;
48
- properties: PropertyItem[];
54
+ properties: PropertyItem[]; // form.properties — FormArray<PropertyItem>
55
+ address: Address; // form.address — FormGroup<Address>
56
+ tags: string[]; // form.tags — FormControl<string[]>
49
57
  // ...
50
- };
58
+ }
59
+
60
+ // вычисляемые поля — отдельным типом; форма — пересечение
61
+ export interface CreditApplicationDerived {
62
+ monthlyPayment: number;
63
+ }
64
+ export type CreditApplicationForm = CreditApplication & CreditApplicationDerived;
51
65
  ```
52
66
 
53
- ### Recipe 3 — Схема привязана к сигналам модели
67
+ Оговорка только для своих обобщённых хелперов: `interface` не присваивается
68
+ `Record<string, unknown>` (неявной index signature у него нет). Ограничение пиши через
69
+ `T extends object`:
54
70
 
55
- `value` поля — это сигнал модели (`model.$.field`), а не литерал. Тип поля выводится из сигнала.
56
- Layout-схема БЕЗ валидаторов — правила живут в отдельной validation-схеме.
71
+ ```typescript
72
+ // ❌ interface-форму этот хелпер не примет
73
+ function snapshot<T extends Record<string, unknown>>(form: FormGroup<T>): T { return form.getValue(); }
74
+
75
+ // ✅
76
+ function snapshot<T extends object>(form: FormGroup<T>): T { return form.getValue(); }
77
+ ```
78
+
79
+ ### Recipe 3 — Схема привязана к ручкам модели
80
+
81
+ Привязка узла — ручка модели (`model: model.$.field`), а не литерал. Тип узла `FormSchemaNode`
82
+ закрыт: вид узла (поле, массив под-форм, подформа) сверяется с тем, чем является привязка, а
83
+ опечатка в ключе не компилируется. Правил в схеме нет — они живут в отдельной схеме валидации.
57
84
 
58
85
  ```typescript
59
- const model = createModel<CreditApplicationForm>(initial);
60
-
61
- const schema = {
62
- loanAmount: { value: model.$.loanAmount, component: InputNumber },
63
- // вложенная группа — builder, принимающий ModelSignals<Sub>
64
- personalData: personalDataNodes(model.$.personalData),
65
- // массив — { array, item }
66
- properties: { array: model.properties, item: propertyItem },
67
- };
86
+ const creditSchema = (model: FormModel<CreditApplicationForm>): FormSchemaNode => ({
87
+ children: [
88
+ { model: model.$.loanAmount, component: InputNumber },
89
+ // вложенная группа — подформа: билдер получает под-модель FormModel<PersonalData>
90
+ { model: model.$.personalData, part: personalData },
91
+ // массив под-форм — { model, item }: билдер получает под-модель строки
92
+ { model: model.$.properties, component: FieldArray, item: propertyRow },
93
+ ],
94
+ });
95
+
96
+ // правила — отдельно; тип правила сверяется с типом поля
97
+ const LOAN_AMOUNT_RULES: Rule<number | null>[] = [required(), min(50000)];
68
98
 
69
- // правила — отдельно; типы полей выводятся из сигналов
70
- const validation = defineValidationSchema<CreditApplicationForm>(({ model }) => {
71
- validate(model.$.loanAmount, [required(), min(50000)]);
99
+ const validation = defineValidation<CreditApplicationForm>(({ model }) => {
100
+ validate(model.$.loanAmount, LOAN_AMOUNT_RULES);
72
101
  });
73
102
  ```
74
103
 
75
- ### Recipe 4 — Cross-field валидаторы: `cross` над типизированным снапшотом
104
+ ### Recipe 4 — Cross-field валидаторы: `cross` над типизированным снимком
76
105
 
77
- Правило — обычная функция `(f: Root) => ValidationError | null` над снапшотом `model.get()`.
78
- Соседние поля читаются без `as`; ошибка вешается на поле-носитель `sig`:
106
+ Правило — обычная функция `(snapshot: T) => ValidationIssue | null`. Оператор `cross` берётся из
107
+ аргумента схемы: тип снимка выведен из схемы, соседние поля читаются без `as`, ошибка вешается на
108
+ поле-носитель:
79
109
 
80
110
  ```typescript
81
- import type { ValidationError } from '@reformer/core';
111
+ import type { ValidationIssue } from '@reformer/core';
82
112
 
83
- const initialPaymentVsProperty = (f: CreditApplicationForm): ValidationError | null =>
84
- f.initialPayment && f.propertyValue && f.initialPayment > f.propertyValue
85
- ? { code: 'tooHigh', message: 'Взнос не может превышать стоимость' }
113
+ const initialPaymentVsProperty = (application: CreditApplicationForm): ValidationIssue | null =>
114
+ application.initialPayment &&
115
+ application.propertyValue &&
116
+ application.initialPayment > application.propertyValue
117
+ ? { code: 'initialPaymentTooHigh' } // текст — в словаре формы (defineIssueMessages)
86
118
  : null;
87
119
 
88
- // внутри defineValidationSchema<CreditApplicationForm>(({ model }) => { ... }):
89
- cross(model.$.initialPayment, initialPaymentVsProperty);
120
+ const loanRules = defineValidation<CreditApplicationForm>(({ model, cross }) => {
121
+ cross(model.$.initialPayment, initialPaymentVsProperty);
122
+ });
90
123
  ```
91
124
 
92
125
  ### Recipe 5 — `compute` читает модель напрямую (без аннотаций)
@@ -112,27 +145,32 @@ compute(model.$.fullName, () =>
112
145
  пустые значения — guard `if (value != null)` не нужен.
113
146
 
114
147
  ```typescript
115
- export type CreditForm = {
116
- loanAmount: number | null; // min(model.$.loanAmount, 50000) — ок
148
+ export interface CreditForm {
149
+ loanAmount: number | null; // validate(model.$.loanAmount, [min(50000)]) — ок
117
150
  loanPurpose: string | null; // minLength — ок
118
151
  birthDate: string | null; // minAge — ок
119
- };
152
+ }
120
153
  ```
121
154
 
122
155
  ### Recipe 7 — Нода поля для кастомных компонентов
123
156
 
124
- `useFormControl(control)` типизируется по `FieldNode<T>`. В props компонента используй `FieldNode<T>`:
157
+ `useFormControl(control)` типизируется по `FormControl<T>`. В props компонента используй `FormControl<T>`:
125
158
 
126
159
  ```typescript
127
- import type { FieldNode } from '@reformer/core';
160
+ import type { FormControl } from '@reformer/core';
161
+ import { useFormControl } from '@reformer/core/react';
128
162
 
129
- type MyFieldProps<T> = { control: FieldNode<T> };
163
+ type MyFieldProps<T> = { control: FormControl<T> };
130
164
  function MyField<T>({ control }: MyFieldProps<T>) {
131
- const { value, errors, disabled } = useFormControl(control);
165
+ const { value, issues, disabled } = useFormControl(control);
132
166
  // ...
133
167
  }
134
168
  ```
135
169
 
170
+ Для массива под-форм — `FormArray<Item>` (`useFormControl` отдаёт `ArrayControlState`), для группы —
171
+ `FormGroup<Group>`. Вид неизвестной ноды сужают гарды `isFormControl` / `isFormGroup` /
172
+ `isFormArray`.
173
+
136
174
  ### Recipe 8 — Вынос правил в именованные функции
137
175
 
138
176
  Field-правила — именованные `Rule<T>`, validation-схема остаётся плоской:
@@ -143,17 +181,33 @@ import type { Rule } from '@reformer/core/validation';
143
181
  const validateAdultAge: Rule<string | null> = (value) => {
144
182
  if (!value) return null;
145
183
  const age = new Date().getFullYear() - new Date(value).getFullYear();
146
- return age < 18 ? { code: 'tooYoung', message: 'Минимум 18 лет' } : null;
184
+ return age < 18 ? { code: 'birthDate.adult', params: { minAge: 18 } } : null;
147
185
  };
148
186
 
149
- // внутри defineValidationSchema<MyForm>(({ model }) => { ... }):
187
+ // внутри defineValidation<MyForm>(({ model }) => { ... }):
150
188
  validate(model.$.birthDate, [validateAdultAge]);
151
189
  ```
152
190
 
191
+ ### Recipe 9 — Результаты уровня формы — сужение по `status`
192
+
193
+ `bundle.validate()`, `bundle.submission.submit(handler)` и `flow.next()` отвечают
194
+ дискриминированными объединениями: поле результата доступно после проверки `status`, без `as`.
195
+
196
+ ```typescript
197
+ const outcome = await bundle.submission.submit(api.send); // SubmitResult<Awaited<ReturnType<typeof api.send>>>
198
+ if (outcome.status === 'submitted') use(outcome.result);
199
+ else if (outcome.status === 'failed') report(outcome.error);
200
+ else showIssues(outcome.validation.issues); // 'invalid' | 'error' | 'cancelled'
201
+
202
+ const transition = await bundle.flow!.next(); // FlowTransition
203
+ if (transition.status === 'stayed') console.log(transition.reason); // 'invalid' | 'error' | …
204
+ ```
205
+
153
206
  ### Anti-patterns to avoid
154
207
 
155
208
  - `import { computeFrom } from '@reformer/core/behaviors'` для примитива, вызываемого вне
156
- `defineFormBehavior` → примитив живёт в `@reformer/core` (возвращает cleanup).
157
- - `interface MyForm { ... }` для form-shape → см. Recipe 2.
209
+ `defineFormBehavior` → примитив живёт в `@reformer/core/model` (возвращает cleanup).
210
+ - `T extends Record<string, unknown>` в своих хелперах формы → `T extends object` (Recipe 2).
158
211
  - `as`-касты значений полей внутри `compute` → читай `model.field` напрямую (Recipe 5).
159
212
  - строковые пути / `(form) => ...` в behaviors → это удалённый API, используй сигналы (`model.$.x`).
213
+ - `node instanceof …` → ноды — интерфейсы; гарды `isFormControl` / `isFormGroup` / `isFormArray`.
@@ -1,62 +1,110 @@
1
1
  ## 31. ASYNC VALIDATOR
2
2
 
3
3
  Для проверок типа «уникальность email», «валидация ИНН через API», «проверка адреса» —
4
- async-правило это `AsyncRule<T>` = `(value, { signal }) => Promise<ValidationError | null>`.
4
+ async-правило это `AsyncRule<T>` = `(value, { signal }) => Promise<ValidationIssue | null>`.
5
5
  Оно регистрируется оператором `validateAsync(sig, [asyncRules])` внутри схемы валидации
6
- (`@reformer/core/validation`) и исполняется внешним раннером `validateModel` (async-правила
7
- прогоняются параллельно через `Promise.all`, раннер их дожидается).
6
+ (`@reformer/core/validation`) и исполняется прогоном — уровнем формы (`bundle.validate()`,
7
+ `bundle.submission.submit(handler)`, `flow.next()`) либо раннером `validateModel`. Async-правила
8
+ прогоняются параллельно через `Promise.all`, прогон их дожидается.
8
9
 
9
- Слои разделены: layout (`createForm`-схема / JSON) НЕ несёт валидаторов — правила живут в
10
- отдельной функции-схеме `defineValidationSchema<T>(({ model }) => …)`.
10
+ Слои разделены: схема формы (билдер `createForm` / JSON) правил НЕ несёт — они живут в
11
+ отдельной функции-схеме `defineValidation<T>(({ model }) => …)`.
12
+
13
+ **Сбой async-правила блокирует.** Если правило отклонило промис (сеть, исключение), прогон получает
14
+ статус `error`, а поле — замечание `{ code: 'ruleFailed' }` («Не удалось проверить поле. Повторите
15
+ попытку»): форму, которую не удалось проверить, прогон валидной не считает.
11
16
 
12
17
  ```ts
13
- import { createModel } from '@reformer/core';
18
+ import { createForm, createModel } from '@reformer/core';
14
19
  import {
15
20
  validate,
16
21
  validateAsync,
17
- defineValidationSchema,
22
+ defineValidation,
18
23
  validateModel,
19
24
  type AsyncRule,
20
25
  } from '@reformer/core/validation';
21
26
  import { required, email } from '@reformer/core/validators';
22
27
 
23
- // async-правило: (value, { signal }) => Promise<ValidationError | null>.
28
+ // async-правило: (value, { signal }) => Promise<ValidationIssue | null>.
24
29
  // `signal` — AbortSignal устаревшего прогона: прокинь его в fetch, чтобы отменить in-flight.
25
30
  const checkEmailUnique: AsyncRule<string> = async (value, { signal }) => {
26
31
  if (!value) return null; // пусто = валидно (sync `required` отдельно)
27
- try {
28
- const res = await fetch(`/api/check-email?email=${encodeURIComponent(value)}`, { signal });
29
- const { available } = (await res.json()) as { available: boolean };
30
- return available ? null : { code: 'email-taken', message: 'Email уже зарегистрирован' };
31
- } catch {
32
- return null; // сетевой сбой/отмена НЕ блокирует submit — возвращаем null, а не ошибку
33
- }
32
+ // try/catch не нужен: отклонённый запрос раннер запишет сбоем правила (статус `error`,
33
+ // ошибка `ruleFailed` на поле), а отмену устаревшего прогона отличит сам по `signal`.
34
+ const res = await fetch(`/api/check-email?email=${encodeURIComponent(value)}`, { signal });
35
+ const { available } = (await res.json()) as { available: boolean };
36
+ return available ? null : { code: 'email.taken' }; // текст кода — в словаре формы
34
37
  };
35
38
 
36
- const model = createModel<{ email: string }>({ email: '' });
37
-
38
39
  // Схема — обычная функция над моделью; sync и async — разными операторами на одном сигнале.
39
- const schema = defineValidationSchema<{ email: string }>(({ model }) => {
40
+ const schema = defineValidation<{ email: string }>(({ model }) => {
40
41
  validate(model.$.email, [required(), email()]); // sync-фабрики
41
42
  validateAsync(model.$.email, [checkEmailUnique]); // async-правило
42
43
  });
43
44
 
44
- // Прогон по требованию (submit / шаг):
45
+ // В сборке — прогон уровнем формы:
46
+ const signup = createForm<{ email: string }>({ initial: { email: '' }, validation: schema });
47
+ const outcome = await signup.submission.submit(api.signup); // обработчик — только при valid
48
+
49
+ // Без сборки — раннер:
50
+ const model = createModel<{ email: string }>({ email: '' });
45
51
  const ok = await validateModel(model, schema); // Promise<boolean>
46
52
  ```
47
53
 
48
54
  ### Как это исполняется
49
55
 
50
- 1. `validateModel(model, schema)` открывает ambient-окно и **синхронно** прогоняет схему:
56
+ 1. Прогон (`bundle.validate()` / `validateModel(model, schema)`) открывает ambient-окно и
57
+ **синхронно** прогоняет схему:
51
58
  `validate`/`validateAsync`/`cross` регистрируют правила своих полей.
52
59
  2. Sync-правила выполняются сразу; async-правила из `validateAsync` собираются и после закрытия
53
60
  ambient-окна дожидаются параллельно (`Promise.all`) с прокинутым `AbortSignal`.
54
- 3. Ошибки роутятся в ноды формы (`getNodeForSignal(sig).setErrors(...)`), UI подсвечивает поле;
55
- поля, ставшие валидными, гасятся (`setErrors([])`).
56
- 4. Возвращает `Promise<boolean>` — `true`, если нет блокирующих ошибок (`severity:'warning'`
57
- не блокирует). Устаревший (отменённый) прогон возвращает `false` — ему нельзя доверять для submit.
61
+ 3. Пока async-правила поля не завершились, его нода в состоянии `pending`
62
+ (`form.email.pending.value`, `useFormControl(form.email).pending`).
63
+ 4. Замечания разносятся по нодам формы, UI подсвечивает поле; поля, ставшие валидными, гасятся.
64
+ Отклонённое правило даёт на своём поле замечание `ruleFailed`.
65
+ 5. Итог — `ValidationResult` со статусом (`bundle.validate()`, `runValidation`) либо `boolean`
66
+ (`validateModel`: `true` только при `valid`; `severity:'warning'` не блокирует). Замечания
67
+ (`invalid`), сбой правила (`error`) и устаревший отменённый прогон (`cancelled`) — не `valid`.
58
68
 
59
- Синхронного варианта у нового контракта нет: раннер один — `validateModel`, и он всегда `async`.
69
+ Синхронного варианта у контракта нет: прогон всегда `async`.
70
+
71
+ ### Результат прогона и сбой правила
72
+
73
+ Когда нужно отличить «в форме замечания» от «проверить не удалось», бери полный результат —
74
+ `bundle.validate()` / `bundle.validateStep(id)` сборки, а для отправки — статус `SubmitResult`:
75
+
76
+ ```ts
77
+ const outcome = await bundle.submission.submit(api.send);
78
+ if (outcome.status === 'error') {
79
+ // outcome.validation.failures: [{ handle, error }] — какое правило и почему не вернуло результат
80
+ showToast('Не удалось проверить форму. Повторите попытку');
81
+ }
82
+ ```
83
+
84
+ Без сборки — `runValidation(model, schema)` (только сбор) и `applyValidationResult`:
85
+
86
+ ```ts
87
+ import { applyValidationResult, runValidation } from '@reformer/core/validation';
88
+
89
+ const result = await runValidation(model, schema); // только сбор: ни замечаний, ни pending на нодах
90
+ if (result.status === 'cancelled') return; // вытеснен более новым прогоном — не разносится
91
+ applyValidationResult(result, { touch: true }); // замечания (и ruleFailed) — на поля
92
+ if (result.status === 'valid') await send(model.get());
93
+ ```
94
+
95
+ Если сбой сети НЕ должен блокировать отправку (проверка необязательна), перехвати его в самом
96
+ правиле и верни `null` — это явное решение автора правила, а не поведение по умолчанию:
97
+
98
+ ```ts
99
+ const softCheck: AsyncRule<string> = async (value, { signal }) => {
100
+ try {
101
+ const res = await fetch(`/api/check?value=${encodeURIComponent(value)}`, { signal });
102
+ return (await res.json()).ok ? null : { code: 'rejected' };
103
+ } catch {
104
+ return null; // осознанно: недоступность сервиса проверку не проваливает
105
+ }
106
+ };
107
+ ```
60
108
 
61
109
  ### Sync и async — два оператора
62
110
 
@@ -72,37 +120,32 @@ validateAsync(model.$.inn, [checkInnInRegistry]); // обращение к API
72
120
 
73
121
  ### UI integration
74
122
 
75
- `validateModel(...)` возвращает `Promise<boolean>` — индикатор проверки держи вокруг этого
76
- `await`. Раннер схемы **не** выставляет per-field `pending` на ноде (он роутит только ошибки через
77
- `setErrors`), поэтому спиннер async-валидации — это собственный флаг (обычно form-level, как
78
- `ui.pending` в submit-флоу, или локальный state компонента):
123
+ Пока идут async-правила поля, раннер держит его ноду в `pending` — индикатор проверки поля
124
+ берётся из состояния ноды, свой флаг не нужен:
79
125
 
80
126
  ```tsx
81
- const [checking, setChecking] = useState(false);
127
+ const { issues, pending, shouldShowIssues } = useFormControl(form.email);
128
+ const message = useIssueMessage(formMessages); // @reformer/core/react
82
129
 
83
- const runValidation = async (): Promise<boolean> => {
84
- setChecking(true);
85
- try {
86
- return await validateModel(model, schema);
87
- } finally {
88
- setChecking(false);
89
- }
90
- };
91
-
92
- return checking ? <Spinner /> : errors.length ? <Error errors={errors} /> : null;
130
+ return pending ? <Spinner /> : shouldShowIssues ? <Error text={message(issues[0])} /> : null;
93
131
  ```
94
132
 
133
+ Индикатор прогона всей формы — сигнал `bundle.validating` сборки (`createForm`): он истинен,
134
+ пока идёт полный прогон либо прогон шага; `bundle.submission.submitting` — пока работает обработчик
135
+ отправки.
136
+
95
137
  ### Debounce и отмена
96
138
 
97
- - **Отмена устаревших — раннером, бесплатно.** Быстрый повторный `validateModel(model, schema)`
98
- той же пары `(model, schema)` отменяет предыдущий in-flight прогон через `AbortController`;
99
- его `AbortSignal` прокинут в `validateAsync`-правила, поэтому `fetch(url, { signal })` рвётся
100
- сам. Ручной debounce для КОРРЕКТНОСТИ не нужен — держи схему стабильным `const`
101
- (`defineValidationSchema`), иначе отмена не сматчит прогоны по идентичности.
102
- - Валидация запускается on-demand (submit / шаг / через `revalidateWhen`), а не на каждый
103
- keystroke — отдельный `debounce` в правиле обычно не нужен.
104
- - Если нужно дебаунсить дорогой async-валидатор относительно частых изменений — триггерь прогон
105
- из поведения через `onChange`, который даёт debounce из коробки:
139
+ - **Отмена устаревших — бесплатно.** Быстрый повторный прогон той же пары `(model, schema)`
140
+ отменяет предыдущий in-flight прогон через `AbortController`; его `AbortSignal` прокинут в
141
+ `validateAsync`-правила, поэтому `fetch(url, { signal })` рвётся сам. Ручной debounce для
142
+ КОРРЕКТНОСТИ не нужен — держи схему стабильным `const` (`defineValidation`), иначе отмена не
143
+ сматчит прогоны по идентичности.
144
+ - Валидация запускается on-demand (submit / шаг / через `revalidateWhen`) либо живой стратегией
145
+ сборки — `validation: { rules, strategy: 'change', debounce: 300 }` (см. `33-validation-strategy.md`):
146
+ у живой фазы debounce встроен.
147
+ - Если нужно дебаунсить дорогой async-валидатор относительно частых изменений одного поля — триггерь
148
+ прогон из поведения через `onChange`, который даёт debounce из коробки:
106
149
 
107
150
  ```ts
108
151
  import { defineFormBehavior, onChange } from '@reformer/core/behaviors';
@@ -114,16 +157,16 @@ return checking ? <Spinner /> : errors.length ? <Error errors={errors} /> : null
114
157
  ```
115
158
 
116
159
  Либо оборачивай в свой debounce колбэк `revalidateWhen([...], () => void validateModel(...))`.
117
- - **Async cross-field** — инлайн `validateAsync`-правило, замыкающее `model` и читающее снапшот
118
- соседей `model.get()` до первого `await` (у `AsyncRule` нет `root`, только `value` + `signal`;
119
- `cross(...)` — синхронный):
160
+ - **Async cross-field** — инлайн `validateAsync`-правило, замыкающее `model` и читающее снимок
161
+ соседей `model.get()` до первого `await` (`AsyncRule` получает только `value` и `signal`;
162
+ `cross` — синхронный):
120
163
 
121
164
  ```ts
122
165
  validateAsync(model.$.email, [
123
166
  async (email, { signal }) => {
124
167
  const { username } = model.get(); // снапшот соседних полей до await
125
168
  const res = await fetch(`/api/check?email=${email}&user=${username}`, { signal });
126
- return (await res.json()).ok ? null : { code: 'conflict', message: 'Пара занята' };
169
+ return (await res.json()).ok ? null : { code: 'conflict' };
127
170
  },
128
171
  ]);
129
172
  ```