@reformer/core 10.2.0 → 11.0.0-beta.10
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/README.md +90 -68
- package/dist/behaviors-value-D05z3my6.js +109 -0
- package/dist/behaviors.d.ts +1 -1
- package/dist/behaviors.js +376 -211
- package/dist/create-model-DPSG3I0O.js +30 -0
- package/dist/date-utils-90-qZbse.js +43 -0
- package/dist/en-HP8XEych.js +44 -0
- package/dist/form/aggregate-signals.d.ts +21 -63
- package/dist/form/behaviors/collections.d.ts +84 -0
- package/dist/form/behaviors/context.d.ts +56 -0
- package/dist/form/behaviors/index.d.ts +30 -0
- package/dist/form/behaviors/internals.d.ts +41 -0
- package/dist/form/{behaviors-node.d.ts → behaviors/node.d.ts} +28 -5
- package/dist/form/behaviors/operators.d.ts +59 -0
- package/dist/form/behaviors/schema-operators.d.ts +83 -0
- package/dist/form/behaviors/types.d.ts +37 -0
- package/dist/form/create-form.d.ts +59 -70
- package/dist/form/flow/flow.d.ts +87 -0
- package/dist/form/form-bundle.d.ts +153 -0
- package/dist/form/form-level.d.ts +121 -0
- package/dist/form/form-proxy-builder.d.ts +6 -22
- package/dist/form/index.d.ts +23 -27
- package/dist/form/nodes/form-array-node.d.ts +100 -0
- package/dist/form/nodes/form-control-node.d.ts +73 -0
- package/dist/form/nodes/form-group-node.d.ts +66 -0
- package/dist/form/nodes/node-kind.d.ts +29 -0
- package/dist/form/nodes/state/availability.d.ts +6 -0
- package/dist/form/nodes/state/component.d.ts +6 -0
- package/dist/form/nodes/state/index.d.ts +12 -0
- package/dist/form/nodes/state/interaction.d.ts +13 -0
- package/dist/form/nodes/state/issues.d.ts +9 -0
- package/dist/form/nodes/state/pending.d.ts +6 -0
- package/dist/form/schema/compile.d.ts +59 -0
- package/dist/form/schema/compiled.d.ts +143 -0
- package/dist/form/schema/row-key.d.ts +11 -0
- package/dist/form/schema-controller.d.ts +159 -0
- package/dist/form/schema-subtree.d.ts +28 -0
- package/dist/form/signal-node-registry.d.ts +27 -22
- package/dist/form/submission.d.ts +93 -0
- package/dist/form/type-guards.d.ts +14 -73
- package/dist/form/types/contracts.d.ts +21 -91
- package/dist/form/types/field-config.d.ts +27 -0
- package/dist/form/types/index.d.ts +5 -91
- package/dist/form/types/nodes.d.ts +159 -0
- package/dist/form/types/schema-node.d.ts +148 -50
- package/dist/form/types/validation-schema.d.ts +24 -41
- package/dist/form/validation/context.d.ts +67 -0
- package/dist/form/validation/index.d.ts +24 -28
- package/dist/form/validation/operators.d.ts +101 -0
- package/dist/form/validation/run.d.ts +120 -0
- package/dist/form/validation/strategy.d.ts +89 -0
- package/dist/form/validation/types.d.ts +83 -0
- package/dist/form/validators/date-utils.d.ts +42 -0
- package/dist/form/validators/email.d.ts +23 -0
- package/dist/form/{validation/validators → validators}/file-type.d.ts +10 -10
- package/dist/form/{validation/validators → validators}/file-utils.d.ts +3 -2
- package/dist/form/validators/future-date.d.ts +23 -0
- package/dist/form/{validation/validators → validators}/index.d.ts +7 -4
- package/dist/form/validators/integer.d.ts +22 -0
- package/dist/form/validators/is-date.d.ts +24 -0
- package/dist/form/validators/is-number.d.ts +25 -0
- package/dist/form/validators/max-age.d.ts +28 -0
- package/dist/form/validators/max-date.d.ts +25 -0
- package/dist/form/{validation/validators → validators}/max-file-size.d.ts +10 -10
- package/dist/form/validators/max-files.d.ts +26 -0
- package/dist/form/validators/max-length.d.ts +33 -0
- package/dist/form/validators/max-total-file-size.d.ts +26 -0
- package/dist/form/validators/max.d.ts +26 -0
- package/dist/form/validators/min-age.d.ts +27 -0
- package/dist/form/validators/min-date.d.ts +25 -0
- package/dist/form/{validation/validators → validators}/min-file-size.d.ts +10 -10
- package/dist/form/validators/min-files.d.ts +27 -0
- package/dist/form/validators/min-length.d.ts +33 -0
- package/dist/form/validators/min.d.ts +26 -0
- package/dist/form/validators/multiple-of.d.ts +25 -0
- package/dist/form/validators/non-negative.d.ts +22 -0
- package/dist/form/validators/non-zero.d.ts +22 -0
- package/dist/form/validators/past-date.d.ts +23 -0
- package/dist/form/validators/pattern.d.ts +26 -0
- package/dist/form/{validation/validators → validators}/phone.d.ts +11 -11
- package/dist/form/validators/required.d.ts +29 -0
- package/dist/form/validators/url.d.ts +45 -0
- package/dist/form/validators/validation-issue.d.ts +8 -0
- package/dist/i18n/descriptor.d.ts +54 -0
- package/dist/i18n/en.json.d.ts +49 -0
- package/dist/i18n/loader.d.ts +71 -0
- package/dist/i18n/locale-store.d.ts +46 -0
- package/dist/i18n/locale.d.ts +90 -0
- package/dist/i18n/message-format.d.ts +136 -0
- package/dist/i18n/resolve-localized.d.ts +17 -0
- package/dist/i18n/ru.json.d.ts +49 -0
- package/dist/i18n/translator.d.ts +61 -0
- package/dist/i18n/validation-message.d.ts +53 -0
- package/dist/i18n.d.ts +26 -0
- package/dist/i18n.js +86 -0
- package/dist/index.d.ts +18 -6
- package/dist/index.js +932 -2779
- package/dist/loader-BWkigpVe.js +52 -0
- package/dist/locale/en.d.ts +2 -0
- package/dist/locale/en.js +5 -0
- package/dist/locale/en.json +45 -0
- package/dist/locale/index.d.ts +18 -0
- package/dist/locale/ru.d.ts +2 -0
- package/dist/locale/ru.js +44 -0
- package/dist/locale/ru.json +45 -0
- package/dist/locale-CG54AU2Q.js +219 -0
- package/dist/locale.d.ts +2 -0
- package/dist/locale.js +9 -0
- package/dist/{state → model}/behaviors-value.d.ts +24 -6
- package/dist/{state/form-model.d.ts → model/create-model.d.ts} +18 -4
- package/dist/{state → model}/derived-registry.d.ts +1 -1
- package/dist/model/index.d.ts +42 -0
- package/dist/model/model-nodes.d.ts +190 -0
- package/dist/model/model-signals-proxy.d.ts +63 -0
- package/dist/model/model-value-proxy.d.ts +84 -0
- package/dist/{state → model}/safe-effect.d.ts +50 -2
- package/dist/model/types.d.ts +239 -0
- package/dist/model.d.ts +2 -0
- package/dist/model.js +27 -0
- package/dist/node-kind-C-r6ZMdo.js +15 -0
- package/dist/platforms/react/field-binding/field-adapter.d.ts +87 -0
- package/dist/platforms/react/field-binding/field-handle.d.ts +53 -0
- package/dist/platforms/react/form-bundle-context.d.ts +27 -0
- package/dist/{form → platforms/react}/hooks/types.d.ts +60 -38
- package/dist/platforms/react/hooks/use-form-bundle.d.ts +41 -0
- package/dist/platforms/react/hooks/use-form-flow.d.ts +40 -0
- package/dist/{form → platforms/react}/hooks/use-form-validation.d.ts +13 -7
- package/dist/{form → platforms/react}/hooks/useArrayLength.d.ts +6 -6
- package/dist/{form → platforms/react}/hooks/useFormControl.d.ts +7 -9
- package/dist/{form → platforms/react}/hooks/useFormControlValue.d.ts +18 -18
- package/dist/{form → platforms/react}/hooks/useSignalSubscription.d.ts +9 -1
- package/dist/platforms/react/i18n/context.d.ts +18 -0
- package/dist/platforms/react/i18n/hooks.d.ts +65 -0
- package/dist/platforms/react/i18n/provider.d.ts +59 -0
- package/dist/platforms/react/index.d.ts +30 -0
- package/dist/react.d.ts +22 -0
- package/dist/react.js +505 -0
- package/dist/runtime-token.d.ts +22 -0
- package/dist/safe-effect-BazLOv1g.js +669 -0
- package/dist/schema-controller-CpiO2JoK.js +143 -0
- package/dist/signals.d.ts +15 -9
- package/dist/signals.js +11 -6
- package/dist/strategy-DzvnsWLh.js +375 -0
- package/dist/type-guards-D-N_5Ddh.js +19 -0
- package/dist/validation-issue-B9mjI8jz.js +7 -0
- package/dist/validation-message-Ced9MGxs.js +194 -0
- package/dist/validation.d.ts +1 -1
- package/dist/validation.js +11 -165
- package/dist/validators/email.d.ts +1 -1
- package/dist/validators/email.js +5 -8
- package/dist/validators/future-date.d.ts +1 -1
- package/dist/validators/future-date.js +10 -13
- package/dist/validators/integer.d.ts +1 -1
- package/dist/validators/integer.js +4 -7
- package/dist/validators/is-date.d.ts +1 -1
- package/dist/validators/is-date.js +5 -8
- package/dist/validators/is-number.d.ts +1 -1
- package/dist/validators/is-number.js +4 -7
- package/dist/validators/max-age.d.ts +1 -1
- package/dist/validators/max-age.js +8 -12
- package/dist/validators/max-date.d.ts +1 -1
- package/dist/validators/max-date.js +10 -13
- package/dist/validators/max-length.d.ts +1 -1
- package/dist/validators/max-length.js +7 -10
- package/dist/validators/max.d.ts +1 -1
- package/dist/validators/max.js +4 -7
- package/dist/validators/min-age.d.ts +1 -1
- package/dist/validators/min-age.js +10 -11
- package/dist/validators/min-date.d.ts +1 -1
- package/dist/validators/min-date.js +8 -11
- package/dist/validators/min-length.d.ts +1 -1
- package/dist/validators/min-length.js +4 -7
- package/dist/validators/min.d.ts +1 -1
- package/dist/validators/min.js +4 -7
- package/dist/validators/multiple-of.d.ts +1 -1
- package/dist/validators/multiple-of.js +11 -10
- package/dist/validators/non-negative.d.ts +1 -1
- package/dist/validators/non-negative.js +2 -5
- package/dist/validators/non-zero.d.ts +1 -1
- package/dist/validators/non-zero.js +4 -7
- package/dist/validators/past-date.d.ts +1 -1
- package/dist/validators/past-date.js +10 -13
- package/dist/validators/pattern.d.ts +1 -1
- package/dist/validators/pattern.js +5 -8
- package/dist/validators/phone.d.ts +1 -1
- package/dist/validators/phone.js +10 -11
- package/dist/validators/required.d.ts +1 -1
- package/dist/validators/required.js +4 -7
- package/dist/validators/url.d.ts +1 -1
- package/dist/validators/url.js +48 -17
- package/dist/validators.d.ts +1 -5
- package/dist/validators.js +98 -154
- package/docs/llms/01-api-reference.md +89 -0
- package/docs/llms/02-quick-start.md +181 -0
- package/docs/llms/03-api-signatures.md +494 -0
- package/docs/llms/04-common-patterns.md +204 -0
- package/docs/llms/05-common-mistakes.md +192 -0
- package/docs/llms/06-troubleshooting.md +16 -0
- package/docs/llms/07-complete-import.md +130 -0
- package/docs/llms/08-form-types.md +54 -0
- package/docs/llms/09-formschema.md +191 -0
- package/docs/llms/10-arrays.md +241 -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 +162 -0
- package/docs/llms/14-extended-mistakes.md +101 -0
- package/docs/llms/15-project-structure.md +279 -0
- package/docs/llms/16-ui-components.md +229 -0
- package/docs/llms/17-nonexistent-api.md +278 -0
- package/docs/llms/18-conditional-fields.md +222 -0
- package/docs/llms/19-reading-values.md +100 -0
- package/docs/llms/20-compute-vs-watch.md +153 -0
- package/docs/llms/21-array-operations.md +163 -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 +115 -0
- package/docs/llms/25-reset-when.md +122 -0
- package/docs/llms/26-transform-value.md +136 -0
- package/docs/llms/27-revalidate-when.md +133 -0
- package/docs/llms/28-submit-and-reset.md +409 -0
- package/docs/llms/29-async-preload.md +203 -0
- package/docs/llms/30-type-safety-recipes.md +213 -0
- package/docs/llms/31-async-validator-debounce.md +178 -0
- package/docs/llms/32-async-options-loading.md +108 -0
- package/docs/llms/33-validation-strategy.md +305 -0
- package/docs/llms/34-i18n.md +298 -0
- package/docs/llms/35-unified-contract.md +309 -0
- package/docs/llms/36-issue-messages.md +284 -0
- package/docs/llms/37-compiled-schema.md +332 -0
- package/docs/llms/38-form-flow.md +396 -0
- package/llms-index.json +1 -0
- package/llms.txt +10014 -3826
- package/package.json +77 -15
- package/dist/behaviors-node-DGEFMTQu.js +0 -18
- package/dist/behaviors-value-C7dSEhCc.js +0 -134
- package/dist/date-utils-dTT_x_Be.js +0 -31
- package/dist/derived-registry-CluZCQ7U.js +0 -16
- package/dist/form/behaviors.d.ts +0 -133
- package/dist/form/error-handler.d.ts +0 -166
- package/dist/form/factories/index.d.ts +0 -6
- package/dist/form/factories/node-factory.d.ts +0 -188
- package/dist/form/form-submitter.d.ts +0 -125
- package/dist/form/nodes/array-node.d.ts +0 -360
- package/dist/form/nodes/field-node.d.ts +0 -294
- package/dist/form/nodes/form-node.d.ts +0 -340
- package/dist/form/nodes/group-node.d.ts +0 -288
- package/dist/form/nodes/model-array-node.d.ts +0 -121
- package/dist/form/status-machine.d.ts +0 -153
- package/dist/form/types/deep-schema.d.ts +0 -116
- package/dist/form/types/form-proxy.d.ts +0 -141
- package/dist/form/unique-id.d.ts +0 -53
- package/dist/form/validation/validators/date-utils.d.ts +0 -30
- package/dist/form/validation/validators/email.d.ts +0 -23
- package/dist/form/validation/validators/future-date.d.ts +0 -23
- package/dist/form/validation/validators/integer.d.ts +0 -22
- package/dist/form/validation/validators/is-date.d.ts +0 -23
- package/dist/form/validation/validators/is-number.d.ts +0 -24
- package/dist/form/validation/validators/max-age.d.ts +0 -25
- package/dist/form/validation/validators/max-date.d.ts +0 -25
- package/dist/form/validation/validators/max-files.d.ts +0 -26
- package/dist/form/validation/validators/max-length.d.ts +0 -27
- package/dist/form/validation/validators/max-total-file-size.d.ts +0 -26
- package/dist/form/validation/validators/max.d.ts +0 -25
- package/dist/form/validation/validators/min-age.d.ts +0 -25
- package/dist/form/validation/validators/min-date.d.ts +0 -25
- package/dist/form/validation/validators/min-files.d.ts +0 -26
- package/dist/form/validation/validators/min-length.d.ts +0 -27
- package/dist/form/validation/validators/min.d.ts +0 -25
- package/dist/form/validation/validators/multiple-of.d.ts +0 -24
- package/dist/form/validation/validators/non-negative.d.ts +0 -22
- package/dist/form/validation/validators/non-zero.d.ts +0 -22
- package/dist/form/validation/validators/past-date.d.ts +0 -23
- package/dist/form/validation/validators/pattern.d.ts +0 -33
- package/dist/form/validation/validators/required.d.ts +0 -32
- package/dist/form/validation/validators/url.d.ts +0 -43
- package/dist/form/validation-schema.d.ts +0 -106
- package/dist/form/validation-strategy.d.ts +0 -55
- package/dist/form/validators.d.ts +0 -6
- package/dist/form-model-DYCzGGKF.js +0 -286
- package/dist/state/index.d.ts +0 -31
- package/dist/state/subscription-manager.d.ts +0 -180
- package/dist/state/types.d.ts +0 -144
- package/dist/state.d.ts +0 -2
- package/dist/state.js +0 -21
- package/dist/subscription-manager-705bls97.js +0 -171
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# Async Preload — Загрузка начальных значений и справочников
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
Загрузка формы данными с сервера: (1) **initial values** в `createModel(...)` / `createForm({ initial })`; (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
|
+
// DTO без части ключей: поля остаются прежними, в dev — предупреждение.
|
|
18
|
+
model.patch(value: DeepPartial<T>): void; // частичное слияние (только переданные ключи, на любой глубине групп; undefined — «не трогать»). Не меняет initial.
|
|
19
|
+
model.reset(): void; // вернуть к initial-снимку
|
|
20
|
+
model.captureInitial(): void; // сделать текущие значения новым initial-снимком
|
|
21
|
+
|
|
22
|
+
// Нода поля/группы:
|
|
23
|
+
form.field.updateComponentProps(props: Record<string, unknown>): void; // динамические опции и т.п.
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Initial values задаются в `createModel(initial)`; `reset()` возвращает к ним. Чтобы загруженные
|
|
27
|
+
данные стали новой «точкой отсчёта» для `reset()` — вызови `model.captureInitial()` после load.
|
|
28
|
+
|
|
29
|
+
## Examples
|
|
30
|
+
|
|
31
|
+
### Initial values в модели
|
|
32
|
+
|
|
33
|
+
```typescript
|
|
34
|
+
import { createModel, createFormFromModel, type FormSchemaNode } from '@reformer/core';
|
|
35
|
+
import { Input, SelectAsync, CheckboxWithLabel } from '@reformer/ui-kit';
|
|
36
|
+
|
|
37
|
+
interface ProfileForm {
|
|
38
|
+
username: string;
|
|
39
|
+
language: 'ru' | 'en';
|
|
40
|
+
marketing: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const model = createModel<ProfileForm>({ username: '', language: 'ru', marketing: true });
|
|
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: 'Рассылка' },
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
};
|
|
65
|
+
const form = createFormFromModel({ model, schema });
|
|
66
|
+
// model.get() === { username: '', language: 'ru', marketing: true }
|
|
67
|
+
// после правок: model.reset() возвращает к этим значениям
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Async preload через external hook + model.set
|
|
71
|
+
|
|
72
|
+
```tsx
|
|
73
|
+
import { useEffect, useState } from 'react';
|
|
74
|
+
import type { FormModel, FormGroup } from '@reformer/core';
|
|
75
|
+
|
|
76
|
+
interface LoadingState { isLoading: boolean; error: string | null }
|
|
77
|
+
|
|
78
|
+
export function useLoadCreditApplication(
|
|
79
|
+
model: FormModel<CreditApplicationForm>,
|
|
80
|
+
form: FormGroup<CreditApplicationForm>,
|
|
81
|
+
applicationId: string | null
|
|
82
|
+
): LoadingState {
|
|
83
|
+
const [state, setState] = useState<LoadingState>({ isLoading: !!applicationId, error: null });
|
|
84
|
+
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
if (!applicationId) { setState({ isLoading: false, error: null }); return; }
|
|
87
|
+
let cancelled = false;
|
|
88
|
+
|
|
89
|
+
(async () => {
|
|
90
|
+
setState({ isLoading: true, error: null });
|
|
91
|
+
try {
|
|
92
|
+
const [appResp, dictsResp] = await Promise.all([
|
|
93
|
+
fetchCreditApplication(applicationId),
|
|
94
|
+
fetchDictionaries(),
|
|
95
|
+
]);
|
|
96
|
+
if (cancelled) return; // race-guard: сменили applicationId / unmount
|
|
97
|
+
if (appResp.status !== 200 || dictsResp.status !== 200) throw new Error('Сервер вернул ошибку');
|
|
98
|
+
|
|
99
|
+
// Загрузка значений в модель
|
|
100
|
+
model.set(appResp.data);
|
|
101
|
+
|
|
102
|
+
// Динамические componentProps — через queueMicrotask (после реактивных эффектов от set)
|
|
103
|
+
queueMicrotask(() => {
|
|
104
|
+
if (cancelled) return;
|
|
105
|
+
form.registrationAddress.city.updateComponentProps({ options: dictsResp.data.cities });
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
setState({ isLoading: false, error: null });
|
|
109
|
+
} catch (err) {
|
|
110
|
+
if (cancelled) return;
|
|
111
|
+
setState({ isLoading: false, error: err instanceof Error ? err.message : 'Ошибка' });
|
|
112
|
+
}
|
|
113
|
+
})();
|
|
114
|
+
|
|
115
|
+
return () => { cancelled = true; };
|
|
116
|
+
}, [applicationId]); // model/form стабильны (сборка через useFormBundle)
|
|
117
|
+
|
|
118
|
+
return state;
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Preload через behavior — onChange на поле
|
|
123
|
+
|
|
124
|
+
Загрузка справочника при выборе другого поля (после preload) — через `onChange`:
|
|
125
|
+
|
|
126
|
+
```typescript
|
|
127
|
+
import { defineFormBehavior, onChange } from '@reformer/core/behaviors';
|
|
128
|
+
|
|
129
|
+
export const addressBehavior = defineFormBehavior<AddressForm>(({ model, form }) => {
|
|
130
|
+
onChange(
|
|
131
|
+
model.$.region,
|
|
132
|
+
async (region, { signal }) => {
|
|
133
|
+
if (!region) { form.city.updateComponentProps({ options: [] }); return; }
|
|
134
|
+
try {
|
|
135
|
+
const cities = await fetchCities(region, { signal });
|
|
136
|
+
form.city.updateComponentProps({ options: cities });
|
|
137
|
+
} catch {
|
|
138
|
+
form.city.updateComponentProps({ options: [] });
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
{ debounce: 300 }
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Anti-patterns
|
|
147
|
+
|
|
148
|
+
```typescript
|
|
149
|
+
// ❌ model/form пересоздаются на каждый рендер → preload запускается каждый раз
|
|
150
|
+
function MyForm() {
|
|
151
|
+
const bundle = createForm<T>({ initial, schema }); // КАЖДЫЙ рендер!
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ✅ Стабильная сборка: useFormBundle вызывает фабрику ровно один раз (useMemo React вправе
|
|
155
|
+
// сбросить и пересобрать форму, потеряв введённое)
|
|
156
|
+
function MyForm() {
|
|
157
|
+
const { model, form } = useFormBundle(() => createForm<T>({ initial, schema }));
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
```typescript
|
|
162
|
+
// ❌ updateComponentProps синхронно после set → возможный конфликт с реактивными эффектами
|
|
163
|
+
model.set(data);
|
|
164
|
+
form.region.updateComponentProps({ options: [...] });
|
|
165
|
+
|
|
166
|
+
// ✅ queueMicrotask, чтобы реактивные эффекты от set завершились
|
|
167
|
+
model.set(data);
|
|
168
|
+
queueMicrotask(() => form.region.updateComponentProps({ options: [...] }));
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
```typescript
|
|
172
|
+
// ❌ Нет race-guard в async useEffect
|
|
173
|
+
useEffect(() => { fetchData(id).then((d) => model.set(d)); }, [id]);
|
|
174
|
+
|
|
175
|
+
// ✅ Cleanup-флаг
|
|
176
|
+
useEffect(() => {
|
|
177
|
+
let cancelled = false;
|
|
178
|
+
fetchData(id).then((d) => { if (!cancelled) model.set(d); });
|
|
179
|
+
return () => { cancelled = true; };
|
|
180
|
+
}, [id]);
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
## Troubleshooting
|
|
184
|
+
|
|
185
|
+
**Q: После `set`/`patch` не показываются замечания.**
|
|
186
|
+
A: `set`/`patch` не запускают валидацию (кроме живой стратегии `change`). После загрузки вызови
|
|
187
|
+
`await bundle.validate()`; без сборки — `await validateModel(model, schema)` из
|
|
188
|
+
`@reformer/core/validation`.
|
|
189
|
+
|
|
190
|
+
**Q: Опции в `<Select>` не появляются после `updateComponentProps`.**
|
|
191
|
+
A: (1) Оберни вызов в `queueMicrotask`; (2) убедись, что компонент подписан через `useFormControl`;
|
|
192
|
+
(3) для массивов — пройдись по элементам (`form.items.map`) и обнови каждый.
|
|
193
|
+
|
|
194
|
+
**Q: При `reset()` теряются данные с сервера.**
|
|
195
|
+
A: `reset()` возвращает к initial-снимку из `createModel`, не к последнему `set`. Чтобы «сбросить
|
|
196
|
+
к данным с сервера» — после `set` вызови `model.captureInitial()` (новая точка отсчёта).
|
|
197
|
+
|
|
198
|
+
## See also
|
|
199
|
+
|
|
200
|
+
- [28-submit-and-reset.md](./28-submit-and-reset.md) — обратная сторона жизненного цикла
|
|
201
|
+
- [11-async-watchfield.md](./11-async-watchfield.md) — `onChange` для динамики после preload
|
|
202
|
+
- [22-cycle-detection.md](./22-cycle-detection.md) — почему `queueMicrotask` нужен
|
|
203
|
+
- [16-ui-components.md](./16-ui-components.md) — `updateComponentProps` для динамических опций
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
## 30. TYPE-SAFETY RECIPES
|
|
2
|
+
|
|
3
|
+
Идиоматичные паттерны, которые держат сгенерированный код без `any` и `as`-кастов.
|
|
4
|
+
|
|
5
|
+
### Recipe 1 — Imports (root cause prevention)
|
|
6
|
+
|
|
7
|
+
- Модель/форма/типы — из `@reformer/core`; хуки — из `@reformer/core/react`; примитивы над
|
|
8
|
+
сигналами — из `@reformer/core/model`.
|
|
9
|
+
- Схема валидации (операторы + раннер `validateModel`) — из `@reformer/core/validation`.
|
|
10
|
+
- Чистые фабрики валидаторов — из `@reformer/core/validators`.
|
|
11
|
+
- Словарь текстов замечаний (`defineIssueMessages`) — из `@reformer/core/i18n`.
|
|
12
|
+
- Декларативный DSL (`defineFormBehavior` + операторы) — из `@reformer/core/behaviors`.
|
|
13
|
+
|
|
14
|
+
```typescript
|
|
15
|
+
import {
|
|
16
|
+
createModel,
|
|
17
|
+
createForm,
|
|
18
|
+
type FormModel,
|
|
19
|
+
type FormGroup,
|
|
20
|
+
type FormSchemaNode,
|
|
21
|
+
} from '@reformer/core';
|
|
22
|
+
import { useFormBundle, useFormControl } from '@reformer/core/react';
|
|
23
|
+
import {
|
|
24
|
+
defineValidation,
|
|
25
|
+
validate,
|
|
26
|
+
validateModel,
|
|
27
|
+
type Rule,
|
|
28
|
+
} from '@reformer/core/validation';
|
|
29
|
+
import { required, min, max, email } from '@reformer/core/validators';
|
|
30
|
+
import { defineFormBehavior, compute, enableWhen, onChange } from '@reformer/core/behaviors';
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
> **watchField — из `@reformer/core/model`** (примитив), НЕ из `@reformer/core/behaviors` (там `onChange`)
|
|
34
|
+
> и не из корня `@reformer/core`.
|
|
35
|
+
|
|
36
|
+
### Recipe 2 — Form-shape types: `interface` или `type`
|
|
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`.
|
|
44
|
+
|
|
45
|
+
```typescript
|
|
46
|
+
export interface PropertyItem {
|
|
47
|
+
type: 'apartment' | 'house' | 'car';
|
|
48
|
+
description: string;
|
|
49
|
+
estimatedValue: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface CreditApplication {
|
|
53
|
+
loanAmount: number | null;
|
|
54
|
+
properties: PropertyItem[]; // form.properties — FormArray<PropertyItem>
|
|
55
|
+
address: Address; // form.address — FormGroup<Address>
|
|
56
|
+
tags: string[]; // form.tags — FormControl<string[]>
|
|
57
|
+
// ...
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// вычисляемые поля — отдельным типом; форма — пересечение
|
|
61
|
+
export interface CreditApplicationDerived {
|
|
62
|
+
monthlyPayment: number;
|
|
63
|
+
}
|
|
64
|
+
export type CreditApplicationForm = CreditApplication & CreditApplicationDerived;
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Оговорка только для своих обобщённых хелперов: `interface` не присваивается
|
|
68
|
+
`Record<string, unknown>` (неявной index signature у него нет). Ограничение пиши через
|
|
69
|
+
`T extends object`:
|
|
70
|
+
|
|
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
|
+
опечатка в ключе не компилируется. Правил в схеме нет — они живут в отдельной схеме валидации.
|
|
84
|
+
|
|
85
|
+
```typescript
|
|
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)];
|
|
98
|
+
|
|
99
|
+
const validation = defineValidation<CreditApplicationForm>(({ model }) => {
|
|
100
|
+
validate(model.$.loanAmount, LOAN_AMOUNT_RULES);
|
|
101
|
+
});
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Recipe 4 — Cross-field валидаторы: `cross` над типизированным снимком
|
|
105
|
+
|
|
106
|
+
Правило — обычная функция `(snapshot: T) => ValidationIssue | null`. Оператор `cross` берётся из
|
|
107
|
+
аргумента схемы: тип снимка выведен из схемы, соседние поля читаются без `as`, ошибка вешается на
|
|
108
|
+
поле-носитель:
|
|
109
|
+
|
|
110
|
+
```typescript
|
|
111
|
+
import type { ValidationIssue } from '@reformer/core';
|
|
112
|
+
|
|
113
|
+
const initialPaymentVsProperty = (application: CreditApplicationForm): ValidationIssue | null =>
|
|
114
|
+
application.initialPayment &&
|
|
115
|
+
application.propertyValue &&
|
|
116
|
+
application.initialPayment > application.propertyValue
|
|
117
|
+
? { code: 'initialPaymentTooHigh' } // текст — в словаре формы (defineIssueMessages)
|
|
118
|
+
: null;
|
|
119
|
+
|
|
120
|
+
const loanRules = defineValidation<CreditApplicationForm>(({ model, cross }) => {
|
|
121
|
+
cross(model.$.initialPayment, initialPaymentVsProperty);
|
|
122
|
+
});
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Recipe 5 — `compute` читает модель напрямую (без аннотаций)
|
|
126
|
+
|
|
127
|
+
`compute(target, () => …)` читает value-модель (`model.field`) — типы полей выводятся из типа
|
|
128
|
+
модели, `as`-касты не нужны:
|
|
129
|
+
|
|
130
|
+
```typescript
|
|
131
|
+
compute(model.$.monthlyPayment, () =>
|
|
132
|
+
annuityMonthly(model.loanAmount ?? 0, model.loanTerm ?? 0, model.interestRate ?? 0)
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
// nested reads — тоже напрямую
|
|
136
|
+
compute(model.$.fullName, () =>
|
|
137
|
+
[model.personalData.firstName, model.personalData.lastName].filter(Boolean).join(' ')
|
|
138
|
+
);
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### Recipe 6 — `null` vs `undefined` для опциональных полей
|
|
142
|
+
|
|
143
|
+
Оба работают. `null` — конвенция «пользователь очистил поле». Встроенные валидаторы
|
|
144
|
+
(`min`, `max`, `minLength`, `maxLength`, `minDate`, `maxDate`, `minAge`, `maxAge`) пропускают
|
|
145
|
+
пустые значения — guard `if (value != null)` не нужен.
|
|
146
|
+
|
|
147
|
+
```typescript
|
|
148
|
+
export interface CreditForm {
|
|
149
|
+
loanAmount: number | null; // validate(model.$.loanAmount, [min(50000)]) — ок
|
|
150
|
+
loanPurpose: string | null; // minLength — ок
|
|
151
|
+
birthDate: string | null; // minAge — ок
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### Recipe 7 — Нода поля для кастомных компонентов
|
|
156
|
+
|
|
157
|
+
`useFormControl(control)` типизируется по `FormControl<T>`. В props компонента используй `FormControl<T>`:
|
|
158
|
+
|
|
159
|
+
```typescript
|
|
160
|
+
import type { FormControl } from '@reformer/core';
|
|
161
|
+
import { useFormControl } from '@reformer/core/react';
|
|
162
|
+
|
|
163
|
+
type MyFieldProps<T> = { control: FormControl<T> };
|
|
164
|
+
function MyField<T>({ control }: MyFieldProps<T>) {
|
|
165
|
+
const { value, issues, disabled } = useFormControl(control);
|
|
166
|
+
// ...
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Для массива под-форм — `FormArray<Item>` (`useFormControl` отдаёт `ArrayControlState`), для группы —
|
|
171
|
+
`FormGroup<Group>`. Вид неизвестной ноды сужают гарды `isFormControl` / `isFormGroup` /
|
|
172
|
+
`isFormArray`.
|
|
173
|
+
|
|
174
|
+
### Recipe 8 — Вынос правил в именованные функции
|
|
175
|
+
|
|
176
|
+
Field-правила — именованные `Rule<T>`, validation-схема остаётся плоской:
|
|
177
|
+
|
|
178
|
+
```typescript
|
|
179
|
+
import type { Rule } from '@reformer/core/validation';
|
|
180
|
+
|
|
181
|
+
const validateAdultAge: Rule<string | null> = (value) => {
|
|
182
|
+
if (!value) return null;
|
|
183
|
+
const age = new Date().getFullYear() - new Date(value).getFullYear();
|
|
184
|
+
return age < 18 ? { code: 'birthDate.adult', params: { minAge: 18 } } : null;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
// внутри defineValidation<MyForm>(({ model }) => { ... }):
|
|
188
|
+
validate(model.$.birthDate, [validateAdultAge]);
|
|
189
|
+
```
|
|
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
|
+
|
|
206
|
+
### Anti-patterns to avoid
|
|
207
|
+
|
|
208
|
+
- `import { computeFrom } from '@reformer/core/behaviors'` для примитива, вызываемого вне
|
|
209
|
+
`defineFormBehavior` → примитив живёт в `@reformer/core/model` (возвращает cleanup).
|
|
210
|
+
- `T extends Record<string, unknown>` в своих хелперах формы → `T extends object` (Recipe 2).
|
|
211
|
+
- `as`-касты значений полей внутри `compute` → читай `model.field` напрямую (Recipe 5).
|
|
212
|
+
- строковые пути / `(form) => ...` в behaviors → это удалённый API, используй сигналы (`model.$.x`).
|
|
213
|
+
- `node instanceof …` → ноды — интерфейсы; гарды `isFormControl` / `isFormGroup` / `isFormArray`.
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
## 31. ASYNC VALIDATOR
|
|
2
|
+
|
|
3
|
+
Для проверок типа «уникальность email», «валидация ИНН через API», «проверка адреса» —
|
|
4
|
+
async-правило это `AsyncRule<T>` = `(value, { signal }) => Promise<ValidationIssue | null>`.
|
|
5
|
+
Оно регистрируется оператором `validateAsync(sig, [asyncRules])` внутри схемы валидации
|
|
6
|
+
(`@reformer/core/validation`) и исполняется прогоном — уровнем формы (`bundle.validate()`,
|
|
7
|
+
`bundle.submission.submit(handler)`, `flow.next()`) либо раннером `validateModel`. Async-правила
|
|
8
|
+
прогоняются параллельно через `Promise.all`, прогон их дожидается.
|
|
9
|
+
|
|
10
|
+
Слои разделены: схема формы (билдер `createForm` / JSON) правил НЕ несёт — они живут в
|
|
11
|
+
отдельной функции-схеме `defineValidation<T>(({ model }) => …)`.
|
|
12
|
+
|
|
13
|
+
**Сбой async-правила блокирует.** Если правило отклонило промис (сеть, исключение), прогон получает
|
|
14
|
+
статус `error`, а поле — замечание `{ code: 'ruleFailed' }` («Не удалось проверить поле. Повторите
|
|
15
|
+
попытку»): форму, которую не удалось проверить, прогон валидной не считает.
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { createForm, createModel } from '@reformer/core';
|
|
19
|
+
import {
|
|
20
|
+
validate,
|
|
21
|
+
validateAsync,
|
|
22
|
+
defineValidation,
|
|
23
|
+
validateModel,
|
|
24
|
+
type AsyncRule,
|
|
25
|
+
} from '@reformer/core/validation';
|
|
26
|
+
import { required, email } from '@reformer/core/validators';
|
|
27
|
+
|
|
28
|
+
// async-правило: (value, { signal }) => Promise<ValidationIssue | null>.
|
|
29
|
+
// `signal` — AbortSignal устаревшего прогона: прокинь его в fetch, чтобы отменить in-flight.
|
|
30
|
+
const checkEmailUnique: AsyncRule<string> = async (value, { signal }) => {
|
|
31
|
+
if (!value) return null; // пусто = валидно (sync `required` отдельно)
|
|
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' }; // текст кода — в словаре формы
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// Схема — обычная функция над моделью; sync и async — разными операторами на одном сигнале.
|
|
40
|
+
const schema = defineValidation<{ email: string }>(({ model }) => {
|
|
41
|
+
validate(model.$.email, [required(), email()]); // sync-фабрики
|
|
42
|
+
validateAsync(model.$.email, [checkEmailUnique]); // async-правило
|
|
43
|
+
});
|
|
44
|
+
|
|
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: '' });
|
|
51
|
+
const ok = await validateModel(model, schema); // Promise<boolean>
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Как это исполняется
|
|
55
|
+
|
|
56
|
+
1. Прогон (`bundle.validate()` / `validateModel(model, schema)`) открывает ambient-окно и
|
|
57
|
+
**синхронно** прогоняет схему:
|
|
58
|
+
`validate`/`validateAsync`/`cross` регистрируют правила своих полей.
|
|
59
|
+
2. Sync-правила выполняются сразу; async-правила из `validateAsync` собираются и после закрытия
|
|
60
|
+
ambient-окна дожидаются параллельно (`Promise.all`) с прокинутым `AbortSignal`.
|
|
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`.
|
|
68
|
+
|
|
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
|
+
```
|
|
108
|
+
|
|
109
|
+
### Sync и async — два оператора
|
|
110
|
+
|
|
111
|
+
Разделение sync/async делается не полем схемы, а разными операторами на одном сигнале:
|
|
112
|
+
`validate(sig, [syncRules])` и `validateAsync(sig, [asyncRules])`. Раннер прогоняет sync-правила
|
|
113
|
+
инлайн, а async — дожидается. Держи async-правило дешёвым: ранний `if (!value) return null`
|
|
114
|
+
пропускает сетевой вызов, пока `required()`/формат ещё не пройдены.
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
validate(model.$.inn, [required(), pattern(/^\d{12}$/)]); // формат — sync, мгновенно
|
|
118
|
+
validateAsync(model.$.inn, [checkInnInRegistry]); // обращение к API — отдельным оператором
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### UI integration
|
|
122
|
+
|
|
123
|
+
Пока идут async-правила поля, раннер держит его ноду в `pending` — индикатор проверки поля
|
|
124
|
+
берётся из состояния ноды, свой флаг не нужен:
|
|
125
|
+
|
|
126
|
+
```tsx
|
|
127
|
+
const { issues, pending, shouldShowIssues } = useFormControl(form.email);
|
|
128
|
+
const message = useIssueMessage(formMessages); // @reformer/core/react
|
|
129
|
+
|
|
130
|
+
return pending ? <Spinner /> : shouldShowIssues ? <Error text={message(issues[0])} /> : null;
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Индикатор прогона всей формы — сигнал `bundle.validating` сборки (`createForm`): он истинен,
|
|
134
|
+
пока идёт полный прогон либо прогон шага; `bundle.submission.submitting` — пока работает обработчик
|
|
135
|
+
отправки.
|
|
136
|
+
|
|
137
|
+
### Debounce и отмена
|
|
138
|
+
|
|
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 из коробки:
|
|
149
|
+
|
|
150
|
+
```ts
|
|
151
|
+
import { defineFormBehavior, onChange } from '@reformer/core/behaviors';
|
|
152
|
+
import { validateModel } from '@reformer/core/validation';
|
|
153
|
+
|
|
154
|
+
export const behavior = defineFormBehavior<{ username: string }>(({ model }) => {
|
|
155
|
+
onChange(model.$.username, () => void validateModel(model, schema), { debounce: 300 });
|
|
156
|
+
});
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Либо оборачивай в свой debounce колбэк `revalidateWhen([...], () => void validateModel(...))`.
|
|
160
|
+
- **Async cross-field** — инлайн `validateAsync`-правило, замыкающее `model` и читающее снимок
|
|
161
|
+
соседей `model.get()` до первого `await` (`AsyncRule` получает только `value` и `signal`;
|
|
162
|
+
`cross` — синхронный):
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
validateAsync(model.$.email, [
|
|
166
|
+
async (email, { signal }) => {
|
|
167
|
+
const { username } = model.get(); // снапшот соседних полей до await
|
|
168
|
+
const res = await fetch(`/api/check?email=${email}&user=${username}`, { signal });
|
|
169
|
+
return (await res.json()).ok ? null : { code: 'conflict' };
|
|
170
|
+
},
|
|
171
|
+
]);
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### See also
|
|
175
|
+
|
|
176
|
+
- [27-revalidate-when.md](27-revalidate-when.md) — перезапуск `validateModel` по триггерам
|
|
177
|
+
- [29-async-preload.md](29-async-preload.md) — async preload данных при init формы
|
|
178
|
+
- [32-async-options-loading.md](32-async-options-loading.md) — `onChange` с debounce + AbortSignal
|