@reformer/core 4.0.0 → 5.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -34,4 +34,4 @@ import { FieldPathNode } from '../../types';
|
|
|
34
34
|
* }
|
|
35
35
|
* ```
|
|
36
36
|
*/
|
|
37
|
-
export declare function email<TForm, TField extends string | undefined = string>(fieldPath: FieldPathNode<TForm, TField> | undefined, options?: ValidateOptions): void;
|
|
37
|
+
export declare function email<TForm, TField extends string | null | undefined = string>(fieldPath: FieldPathNode<TForm, TField> | undefined, options?: ValidateOptions): void;
|
|
@@ -37,4 +37,4 @@ import { FieldPathNode } from '../../types';
|
|
|
37
37
|
* }
|
|
38
38
|
* ```
|
|
39
39
|
*/
|
|
40
|
-
export declare function pattern<TForm, TField extends string | undefined = string>(fieldPath: FieldPathNode<TForm, TField> | undefined, regex: RegExp, options?: ValidateOptions): void;
|
|
40
|
+
export declare function pattern<TForm, TField extends string | null | undefined = string>(fieldPath: FieldPathNode<TForm, TField> | undefined, regex: RegExp, options?: ValidateOptions): void;
|
|
@@ -21,7 +21,7 @@ export type PhoneFormat = 'international' | 'ru' | 'us' | 'any';
|
|
|
21
21
|
* phone(path.phoneNumber, { format: 'international', message: 'Неверный формат телефона' });
|
|
22
22
|
* ```
|
|
23
23
|
*/
|
|
24
|
-
export declare function phone<TForm, TField extends string | undefined = string>(fieldPath: FieldPathNode<TForm, TField> | undefined, options?: ValidateOptions & {
|
|
24
|
+
export declare function phone<TForm, TField extends string | null | undefined = string>(fieldPath: FieldPathNode<TForm, TField> | undefined, options?: ValidateOptions & {
|
|
25
25
|
/** Формат телефона */
|
|
26
26
|
format?: PhoneFormat;
|
|
27
27
|
}): void;
|
|
@@ -14,7 +14,7 @@ import { FieldPathNode } from '../../types';
|
|
|
14
14
|
* url(path.website, { requireProtocol: true });
|
|
15
15
|
* ```
|
|
16
16
|
*/
|
|
17
|
-
export declare function url<TForm, TField extends string | undefined = string>(fieldPath: FieldPathNode<TForm, TField> | undefined, options?: ValidateOptions & {
|
|
17
|
+
export declare function url<TForm, TField extends string | null | undefined = string>(fieldPath: FieldPathNode<TForm, TField> | undefined, options?: ValidateOptions & {
|
|
18
18
|
/** Требовать наличие протокола (http:// или https://) */
|
|
19
19
|
requireProtocol?: boolean;
|
|
20
20
|
/** Разрешенные протоколы */
|
package/llms.txt
CHANGED
|
@@ -34,6 +34,8 @@
|
|
|
34
34
|
- 28-submit-and-reset.md — Submit и Reset — Жизненный цикл отправки формы
|
|
35
35
|
- 29-async-preload.md — Async Preload — Загрузка начальных значений и динамических справочников
|
|
36
36
|
- 30-type-safety-recipes.md — type-safety-recipes
|
|
37
|
+
- 31-async-validator-debounce.md — async-validator-debounce
|
|
38
|
+
- 32-async-options-loading.md — async-options-loading
|
|
37
39
|
- API Reference (auto-generated from JSDoc)
|
|
38
40
|
|
|
39
41
|
## 1. 1. API Reference
|
|
@@ -81,23 +83,38 @@ const { value, errors, disabled } = useFormControl(control.loanType);
|
|
|
81
83
|
|
|
82
84
|
## 2. 1.5 QUICK START - Minimal Working Form
|
|
83
85
|
|
|
86
|
+
> **Schema-driven UI rule (read first)**: компонент И его пропсы (label, placeholder,
|
|
87
|
+
> options, type) объявляются в **схеме поля** (`component` + `componentProps`).
|
|
88
|
+
> В JSX рендерится один универсальный `<FormField control={form.x} />` из
|
|
89
|
+
> `@reformer/ui-kit` БЕЗ дополнительных props. Не пиши свои `Input`/`Select`/
|
|
90
|
+
> `Checkbox`-обёртки с `label`-prop'ами — это anti-pattern. См.
|
|
91
|
+
> `find_recipe(package="@reformer/ui-kit", topic="form-field-integration")`.
|
|
92
|
+
|
|
84
93
|
```typescript
|
|
85
|
-
import { createForm,
|
|
94
|
+
import { createForm, type FormProxy, type FormSchema } from '@reformer/core';
|
|
86
95
|
import { required, email } from '@reformer/core/validators';
|
|
87
|
-
import
|
|
96
|
+
import { FormField, Input, Button } from '@reformer/ui-kit';
|
|
88
97
|
|
|
89
|
-
// 1. Define form type
|
|
90
|
-
|
|
98
|
+
// 1. Define form type as `type` alias (not `interface` — see Recipe 2)
|
|
99
|
+
type ContactForm = {
|
|
91
100
|
name: string;
|
|
92
101
|
email: string;
|
|
93
|
-
}
|
|
102
|
+
};
|
|
94
103
|
|
|
95
|
-
// 2.
|
|
104
|
+
// 2. Schema: component + componentProps decl in fields, no JSX label props
|
|
96
105
|
const form = createForm<ContactForm>({
|
|
97
106
|
form: {
|
|
98
|
-
name: {
|
|
99
|
-
|
|
100
|
-
|
|
107
|
+
name: {
|
|
108
|
+
value: '',
|
|
109
|
+
component: Input,
|
|
110
|
+
componentProps: { label: 'Name', placeholder: 'Your name' },
|
|
111
|
+
},
|
|
112
|
+
email: {
|
|
113
|
+
value: '',
|
|
114
|
+
component: Input,
|
|
115
|
+
componentProps: { label: 'Email', type: 'email' },
|
|
116
|
+
},
|
|
117
|
+
} satisfies FormSchema<ContactForm>,
|
|
101
118
|
validation: (path) => {
|
|
102
119
|
required(path.name, { message: 'Name is required' });
|
|
103
120
|
required(path.email, { message: 'Email is required' });
|
|
@@ -105,52 +122,68 @@ const form = createForm<ContactForm>({
|
|
|
105
122
|
},
|
|
106
123
|
});
|
|
107
124
|
|
|
108
|
-
// 3. Use in React component
|
|
125
|
+
// 3. Use in React component — thin JSX, FormField does ALL heavy lifting
|
|
109
126
|
function ContactFormComponent() {
|
|
110
|
-
const nameCtrl = useFormControl(form.name);
|
|
111
|
-
const emailCtrl = useFormControl(form.email);
|
|
112
|
-
|
|
113
127
|
const handleSubmit = async () => {
|
|
114
|
-
await form.submit((values) => {
|
|
128
|
+
await form.submit((values: ContactForm) => {
|
|
115
129
|
console.log('Form submitted:', values);
|
|
116
130
|
});
|
|
117
131
|
};
|
|
118
132
|
|
|
119
133
|
return (
|
|
120
134
|
<form onSubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
|
|
121
|
-
<
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
onChange={(e) => form.name.setValue(e.target.value)}
|
|
125
|
-
disabled={nameCtrl.disabled}
|
|
126
|
-
/>
|
|
127
|
-
{nameCtrl.errors.map((err) => <span key={err.code}>{err.message}</span>)}
|
|
128
|
-
</div>
|
|
129
|
-
<div>
|
|
130
|
-
<input
|
|
131
|
-
value={emailCtrl.value}
|
|
132
|
-
onChange={(e) => form.email.setValue(e.target.value)}
|
|
133
|
-
disabled={emailCtrl.disabled}
|
|
134
|
-
/>
|
|
135
|
-
{emailCtrl.errors.map((err) => <span key={err.code}>{err.message}</span>)}
|
|
136
|
-
</div>
|
|
137
|
-
<button type="submit">Send</button>
|
|
135
|
+
<FormField control={form.name} testId="name" />
|
|
136
|
+
<FormField control={form.email} testId="email" />
|
|
137
|
+
<Button type="submit">Send</Button>
|
|
138
138
|
</form>
|
|
139
139
|
);
|
|
140
140
|
}
|
|
141
141
|
|
|
142
142
|
// 4. Pass form to child components via props (NOT context!)
|
|
143
|
-
|
|
143
|
+
type FormStepProps = {
|
|
144
144
|
form: FormProxy<ContactForm>;
|
|
145
|
-
}
|
|
145
|
+
};
|
|
146
146
|
|
|
147
147
|
function FormStep({ form }: FormStepProps) {
|
|
148
|
-
|
|
149
|
-
const { value } = useFormControl(form.name);
|
|
150
|
-
return <div>Name: {value}</div>;
|
|
148
|
+
return <FormField control={form.name} testId="name" />;
|
|
151
149
|
}
|
|
152
150
|
```
|
|
153
151
|
|
|
152
|
+
### Arrays of objects — tuple format
|
|
153
|
+
|
|
154
|
+
⚠️ Массивы объектов в схеме объявляются **через tuple `[itemSchema]`**, НЕ через `FieldConfig` с `value: []`:
|
|
155
|
+
|
|
156
|
+
```typescript
|
|
157
|
+
// ❌ DON'T — TS error: Type 'FieldConfig<PropertyItem[]>' is not assignable to type '[FormSchema<PropertyItem>]'
|
|
158
|
+
const schema: FormSchema<{ properties: PropertyItem[] }> = {
|
|
159
|
+
properties: { value: [], component: Input }, // ← intuitive but WRONG
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
// ✅ DO — tuple [itemSchema]: ArrayNode infers item shape from первого элемента
|
|
163
|
+
const schema: FormSchema<{ properties: PropertyItem[] }> = {
|
|
164
|
+
properties: [
|
|
165
|
+
{
|
|
166
|
+
type: { value: 'apartment', component: Select, componentProps: { ... } } satisfies FieldConfig<PropertyType>,
|
|
167
|
+
description: { value: '', component: Textarea, componentProps: { label: 'Описание' } },
|
|
168
|
+
estimatedValue: { value: 0, component: Input, componentProps: { label: 'Стоимость', type: 'number' } },
|
|
169
|
+
},
|
|
170
|
+
],
|
|
171
|
+
};
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
В runtime массив пуст (`form.properties.value === []`); tuple — это **template** для каждого item, который применяется при `form.properties.push()` / `.insert()`. См. подробности в `find_recipe(topic="form-array")` и `find_recipe(topic="array-operations")`.
|
|
175
|
+
|
|
176
|
+
### When to write your own field components (advanced — rare)
|
|
177
|
+
|
|
178
|
+
Свои компоненты нужны ТОЛЬКО если:
|
|
179
|
+
|
|
180
|
+
- ты намеренно избегаешь `@reformer/ui-kit` (например, проект уже имеет свою design system)
|
|
181
|
+
- нужен особый низкоуровневый input, который не покрывается `FormField` + `componentProps`
|
|
182
|
+
|
|
183
|
+
В этом случае см. секцию `## 14.5 UI COMPONENT PATTERNS` ниже — но даже там
|
|
184
|
+
паттерн **schema-driven** (label/options не из JSX-props, а из `componentProps` через
|
|
185
|
+
`useFormControl(...).componentProps`).
|
|
186
|
+
|
|
154
187
|
## 3. 2. API SIGNATURES
|
|
155
188
|
|
|
156
189
|
### Validators
|
|
@@ -497,10 +530,46 @@ const behavior: BehaviorSchemaFn<LoanForm> = (path) => {
|
|
|
497
530
|
};
|
|
498
531
|
```
|
|
499
532
|
|
|
500
|
-
Reference patterns: `complex-multy-step-form/schemas/credit-application-behavior.ts
|
|
533
|
+
Reference patterns: `complex-multy-step-form/schemas/credit-application-behavior.ts`.
|
|
501
534
|
|
|
502
535
|
## 6. 4. COMMON MISTAKES
|
|
503
536
|
|
|
537
|
+
### TSC overload-resolution error: `'form' does not exist in FormSchema<T>`
|
|
538
|
+
|
|
539
|
+
**Симптом**:
|
|
540
|
+
|
|
541
|
+
```
|
|
542
|
+
TS2769: Object literal may only specify known properties, and 'form' does not
|
|
543
|
+
exist in type 'FormSchema<MyForm>'.
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
(или похожее с `validation` / `behavior` ключами).
|
|
547
|
+
|
|
548
|
+
**Причина**: `createForm` имеет 2 overload — `GroupNodeConfig<T>` (с `form/validation/behavior`)
|
|
549
|
+
и schema-only. Если в inline literal `form: { ... }` есть глубокая inference-проблема
|
|
550
|
+
(чаще всего — literal default value у union-типа поля, см. Recipe 8 в
|
|
551
|
+
[30-type-safety-recipes.md](30-type-safety-recipes.md)), TS отбрасывает Overload 1 и
|
|
552
|
+
матчит Overload 2 (schema-only). В нём ключа `form` нет — получаешь misleading error.
|
|
553
|
+
|
|
554
|
+
**Workaround** — extract `form` literal в typed local:
|
|
555
|
+
|
|
556
|
+
```ts
|
|
557
|
+
const form: FormSchema<MyForm> = {
|
|
558
|
+
fieldA: { value: 'foo', component: Input /* ... */ },
|
|
559
|
+
// ...
|
|
560
|
+
};
|
|
561
|
+
|
|
562
|
+
createForm({ form, validation, behavior });
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
Теперь TS репортит **реальную** per-field ошибку — например:
|
|
566
|
+
|
|
567
|
+
```
|
|
568
|
+
TS2322: Type '"male"' is not assignable to type 'Gender | null'
|
|
569
|
+
```
|
|
570
|
+
|
|
571
|
+
После этого решай конкретную проблему (Recipe 8 — `satisfies FieldConfig<UnionType>`).
|
|
572
|
+
|
|
504
573
|
### Imports rule (#1 cause of cascading errors — read first)
|
|
505
574
|
|
|
506
575
|
Types live in `@reformer/core`. Functions live in submodules (`/validators`,
|
|
@@ -1286,105 +1355,185 @@ export const creditApplicationValidation: ValidationSchemaFn<Form> = (path) => {
|
|
|
1286
1355
|
|
|
1287
1356
|
## 17. 14.5 UI COMPONENT PATTERNS
|
|
1288
1357
|
|
|
1289
|
-
|
|
1358
|
+
> **Default rule (read first)**: для UI используй `FormField` из
|
|
1359
|
+
> [`@reformer/ui-kit`](../../reformer-ui-kit/) — он покрывает 95% случаев одной
|
|
1360
|
+
> строкой `<FormField control={form.x} />`. Свои field-обёртки пиши ТОЛЬКО если
|
|
1361
|
+
> ui-kit не подходит (другая design system, особый low-level input).
|
|
1362
|
+
>
|
|
1363
|
+
> Канонический schema-driven подход:
|
|
1364
|
+
>
|
|
1365
|
+
> - **компонент** объявляется в схеме как `component: Input` (или `Select`, `Checkbox`, etc.)
|
|
1366
|
+
> - **пропсы** компонента — в `componentProps: { label, placeholder, options, type, ... }`
|
|
1367
|
+
> - **JSX рендерит**: `<FormField control={form.x} />` БЕЗ дополнительных props
|
|
1368
|
+
>
|
|
1369
|
+
> См. `find_recipe(package="@reformer/ui-kit", topic="form-field-integration")`
|
|
1370
|
+
> для полного руководства.
|
|
1290
1371
|
|
|
1291
|
-
###
|
|
1372
|
+
### Default — FormField из ui-kit (canonical)
|
|
1292
1373
|
|
|
1293
1374
|
```tsx
|
|
1294
|
-
import
|
|
1295
|
-
import {
|
|
1375
|
+
import { useMemo } from 'react';
|
|
1376
|
+
import { createForm, type FormSchema } from '@reformer/core';
|
|
1377
|
+
import { FormField, Input, Select, Checkbox, Button } from '@reformer/ui-kit';
|
|
1296
1378
|
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
}
|
|
1379
|
+
type RegistrationForm = {
|
|
1380
|
+
email: string;
|
|
1381
|
+
country: string;
|
|
1382
|
+
agree: boolean;
|
|
1383
|
+
};
|
|
1303
1384
|
|
|
1304
|
-
function
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1385
|
+
function RegistrationPage() {
|
|
1386
|
+
const form = useMemo(
|
|
1387
|
+
() =>
|
|
1388
|
+
createForm<RegistrationForm>({
|
|
1389
|
+
form: {
|
|
1390
|
+
email: {
|
|
1391
|
+
value: '',
|
|
1392
|
+
component: Input,
|
|
1393
|
+
componentProps: { label: 'Email', type: 'email', placeholder: 'you@example.com' },
|
|
1394
|
+
},
|
|
1395
|
+
country: {
|
|
1396
|
+
value: 'ru',
|
|
1397
|
+
component: Select,
|
|
1398
|
+
componentProps: {
|
|
1399
|
+
label: 'Country',
|
|
1400
|
+
options: [
|
|
1401
|
+
{ value: 'ru', label: 'Россия' },
|
|
1402
|
+
{ value: 'by', label: 'Беларусь' },
|
|
1403
|
+
],
|
|
1404
|
+
},
|
|
1405
|
+
},
|
|
1406
|
+
agree: {
|
|
1407
|
+
value: false,
|
|
1408
|
+
component: Checkbox,
|
|
1409
|
+
componentProps: { label: 'I agree to terms' },
|
|
1410
|
+
},
|
|
1411
|
+
} satisfies FormSchema<RegistrationForm>,
|
|
1412
|
+
}),
|
|
1413
|
+
[]
|
|
1414
|
+
);
|
|
1312
1415
|
|
|
1313
1416
|
return (
|
|
1314
|
-
<
|
|
1315
|
-
{
|
|
1316
|
-
<
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
const val = type === 'number'
|
|
1321
|
-
? Number(e.target.value) as T
|
|
1322
|
-
: e.target.value as T;
|
|
1323
|
-
control.setValue(val);
|
|
1324
|
-
}}
|
|
1325
|
-
onBlur={() => control.markAsTouched()}
|
|
1326
|
-
disabled={disabled}
|
|
1327
|
-
placeholder={placeholder}
|
|
1328
|
-
className={showError ? 'error' : ''}
|
|
1329
|
-
/>
|
|
1330
|
-
{showError && (
|
|
1331
|
-
<span className="error-message">{errors[0].message}</span>
|
|
1332
|
-
)}
|
|
1333
|
-
</div>
|
|
1417
|
+
<form>
|
|
1418
|
+
<FormField control={form.email} testId="email" />
|
|
1419
|
+
<FormField control={form.country} testId="country" />
|
|
1420
|
+
<FormField control={form.agree} testId="agree" />
|
|
1421
|
+
<Button type="submit">Register</Button>
|
|
1422
|
+
</form>
|
|
1334
1423
|
);
|
|
1335
1424
|
}
|
|
1425
|
+
```
|
|
1426
|
+
|
|
1427
|
+
`FormField` сам читает `componentProps.label`, `componentProps.placeholder`,
|
|
1428
|
+
`componentProps.options` через `useFormControl(...).componentProps` и применяет
|
|
1429
|
+
их к нужному `<input>`/`<select>`/etc. Error rendering, `pending` для async-валидаций,
|
|
1430
|
+
`data-testid` для e2e — всё из коробки.
|
|
1431
|
+
|
|
1432
|
+
### Anti-patterns (не делай так)
|
|
1433
|
+
|
|
1434
|
+
❌ **Свои field-компоненты с label-prop'ами в JSX**:
|
|
1336
1435
|
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
<
|
|
1436
|
+
```tsx
|
|
1437
|
+
// WRONG — дублирует логику FormField, ломает schema-driven архитектуру
|
|
1438
|
+
<Input control={form.email} label="Email" placeholder="..." />
|
|
1439
|
+
<Select control={form.country} options={[...]} />
|
|
1340
1440
|
```
|
|
1341
1441
|
|
|
1342
|
-
|
|
1442
|
+
❌ **Передача компонент-пропсов через JSX вместо схемы**:
|
|
1343
1443
|
|
|
1344
1444
|
```tsx
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1445
|
+
// WRONG — нарушает single source of truth (схема)
|
|
1446
|
+
<FormField control={form.email} label="Email" />
|
|
1447
|
+
```
|
|
1448
|
+
|
|
1449
|
+
✅ Всё это в схеме:
|
|
1450
|
+
|
|
1451
|
+
```ts
|
|
1452
|
+
{ email: { component: Input, componentProps: { label: 'Email' } } }
|
|
1453
|
+
```
|
|
1454
|
+
|
|
1455
|
+
```tsx
|
|
1456
|
+
<FormField control={form.email} />
|
|
1457
|
+
```
|
|
1458
|
+
|
|
1459
|
+
### Advanced — кастомный input через `children` slot
|
|
1460
|
+
|
|
1461
|
+
Когда нужен низкоуровневый input, которого нет в ui-kit (маска, особый combobox):
|
|
1462
|
+
|
|
1463
|
+
```tsx
|
|
1464
|
+
import { FormField } from '@reformer/ui-kit';
|
|
1465
|
+
import { InputMask } from 'react-input-mask';
|
|
1466
|
+
|
|
1467
|
+
<FormField control={form.phone} testId="phone">
|
|
1468
|
+
<InputMask mask="+7 (999) 999-99-99" />
|
|
1469
|
+
</FormField>;
|
|
1470
|
+
```
|
|
1471
|
+
|
|
1472
|
+
`children` оборачивается в `CdkFormField.Control asChild` и получает все нужные
|
|
1473
|
+
props (`value`, `onChange`, `onBlur`, `aria-invalid`).
|
|
1474
|
+
|
|
1475
|
+
### Advanced — write your own from scratch (rare)
|
|
1476
|
+
|
|
1477
|
+
Если ты не хочешь подключать `@reformer/ui-kit`, пиши свои компоненты на основе
|
|
1478
|
+
`useFormControl` — но **сохраняй schema-driven подход**: читай label/placeholder
|
|
1479
|
+
из `componentProps`, не из JSX-props.
|
|
1480
|
+
|
|
1481
|
+
```tsx
|
|
1482
|
+
import type { FieldNode } from '@reformer/core';
|
|
1483
|
+
import { useFormControl } from '@reformer/core';
|
|
1350
1484
|
|
|
1351
|
-
|
|
1352
|
-
|
|
1485
|
+
type MyFormFieldProps<T> = { control: FieldNode<T> }; // ← ОДИН prop
|
|
1486
|
+
|
|
1487
|
+
function MyFormField<T>({ control }: MyFormFieldProps<T>) {
|
|
1488
|
+
const { value, errors, disabled, shouldShowError, componentProps } = useFormControl(control);
|
|
1489
|
+
// componentProps = { label, placeholder, type, options, ... } — из СХЕМЫ
|
|
1490
|
+
const cp = (componentProps ?? {}) as Record<string, unknown>;
|
|
1353
1491
|
|
|
1354
1492
|
return (
|
|
1355
|
-
<
|
|
1356
|
-
{label && <
|
|
1357
|
-
<
|
|
1358
|
-
|
|
1359
|
-
|
|
1493
|
+
<label>
|
|
1494
|
+
{cp.label && <span>{cp.label as string}</span>}
|
|
1495
|
+
<input
|
|
1496
|
+
type={(cp.type as string) ?? 'text'}
|
|
1497
|
+
value={(value ?? '') as string}
|
|
1498
|
+
placeholder={cp.placeholder as string | undefined}
|
|
1360
1499
|
disabled={disabled}
|
|
1361
|
-
|
|
1362
|
-
{
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
))}
|
|
1367
|
-
</select>
|
|
1368
|
-
{touched && errors[0] && <span className="error-message">{errors[0].message}</span>}
|
|
1369
|
-
</div>
|
|
1500
|
+
onChange={(e) => (control.setValue as (v: unknown) => void)(e.target.value)}
|
|
1501
|
+
onBlur={() => control.markAsTouched()}
|
|
1502
|
+
/>
|
|
1503
|
+
{shouldShowError && errors[0] && <span>{errors[0].message}</span>}
|
|
1504
|
+
</label>
|
|
1370
1505
|
);
|
|
1371
1506
|
}
|
|
1372
1507
|
```
|
|
1373
1508
|
|
|
1374
|
-
|
|
1509
|
+
Использование — как у `FormField`:
|
|
1510
|
+
|
|
1511
|
+
```tsx
|
|
1512
|
+
<MyFormField control={form.email} /> // ← без label-prop
|
|
1513
|
+
```
|
|
1514
|
+
|
|
1515
|
+
### Integration with UI libraries (shadcn etc.)
|
|
1516
|
+
|
|
1517
|
+
Если есть существующая design system — оборачивай её компоненты в один
|
|
1518
|
+
`MyFormField` (как выше) и используй один прop `control`. Не множь обёртки на
|
|
1519
|
+
тип input'а — пусть `componentProps.type` диспатчит внутри.
|
|
1375
1520
|
|
|
1376
1521
|
```tsx
|
|
1377
|
-
// With shadcn/ui
|
|
1378
1522
|
import { Input } from '@/components/ui/input';
|
|
1379
1523
|
import { Label } from '@/components/ui/label';
|
|
1380
1524
|
|
|
1381
|
-
function ShadcnFormField({ control
|
|
1382
|
-
const { value, errors, disabled } = useFormControl(control);
|
|
1525
|
+
function ShadcnFormField({ control }: { control: FieldNode<string> }) {
|
|
1526
|
+
const { value, errors, disabled, componentProps } = useFormControl(control);
|
|
1527
|
+
const cp = (componentProps ?? {}) as Record<string, unknown>;
|
|
1383
1528
|
|
|
1384
1529
|
return (
|
|
1385
1530
|
<div className="space-y-2">
|
|
1386
|
-
<Label>{label}</Label>
|
|
1387
|
-
<Input
|
|
1531
|
+
{cp.label && <Label>{cp.label as string}</Label>}
|
|
1532
|
+
<Input
|
|
1533
|
+
value={(value ?? '') as string}
|
|
1534
|
+
onChange={(e) => control.setValue(e.target.value)}
|
|
1535
|
+
disabled={disabled}
|
|
1536
|
+
/>
|
|
1388
1537
|
{errors[0] && <p className="text-red-500">{errors[0].message}</p>}
|
|
1389
1538
|
</div>
|
|
1390
1539
|
);
|
|
@@ -3550,6 +3699,44 @@ const navRef = useRef<FormWizardHandle<MyForm>>(null);
|
|
|
3550
3699
|
/>
|
|
3551
3700
|
```
|
|
3552
3701
|
|
|
3702
|
+
### Recipe 8 — enum-typed default values (union literal widening)
|
|
3703
|
+
|
|
3704
|
+
Когда default `value` поля — литерал union-type, TS widens его до `string`,
|
|
3705
|
+
ломая `FormSchema<T>` assignability:
|
|
3706
|
+
|
|
3707
|
+
```typescript
|
|
3708
|
+
type Gender = 'male' | 'female';
|
|
3709
|
+
|
|
3710
|
+
// ❌ TS error: '"male"' is not assignable to 'Gender | null'
|
|
3711
|
+
const schema: FormSchema<{ gender: Gender }> = {
|
|
3712
|
+
gender: {
|
|
3713
|
+
value: 'male', // widens to string
|
|
3714
|
+
component: RadioGroup,
|
|
3715
|
+
componentProps: { options: [...] },
|
|
3716
|
+
},
|
|
3717
|
+
};
|
|
3718
|
+
```
|
|
3719
|
+
|
|
3720
|
+
**Fix** — `satisfies FieldConfig<UnionType>` сужает без `as`-каста:
|
|
3721
|
+
|
|
3722
|
+
```typescript
|
|
3723
|
+
gender: {
|
|
3724
|
+
value: 'male',
|
|
3725
|
+
component: RadioGroup,
|
|
3726
|
+
componentProps: { options: [...] },
|
|
3727
|
+
} satisfies FieldConfig<Gender>,
|
|
3728
|
+
```
|
|
3729
|
+
|
|
3730
|
+
Альтернативы:
|
|
3731
|
+
|
|
3732
|
+
- `value: 'male' as Gender` — works, но `as`-каст. Избегай (см. anti-patterns).
|
|
3733
|
+
- `value: null` (если `Gender | null` допустим) — нет widening проблемы, но требует
|
|
3734
|
+
null-check в render/validation.
|
|
3735
|
+
|
|
3736
|
+
Применяется к любому union-полю: `loanType`, `employmentStatus`, `maritalStatus`,
|
|
3737
|
+
`propertyType`, и т.п. Симптом без этого fix'а — misleading TS2769
|
|
3738
|
+
"`'form' does not exist in FormSchema<...>`" (см. `05-common-mistakes.md`).
|
|
3739
|
+
|
|
3553
3740
|
### Anti-patterns to avoid
|
|
3554
3741
|
|
|
3555
3742
|
- `import { type ValidationSchemaFn } from '@reformer/core/validators'` →
|
|
@@ -3562,7 +3749,165 @@ const navRef = useRef<FormWizardHandle<MyForm>>(null);
|
|
|
3562
3749
|
- `as never` cast on `computeFrom` target — never necessary; if you reach
|
|
3563
3750
|
for it, you have one of the issues above.
|
|
3564
3751
|
|
|
3565
|
-
## 67.
|
|
3752
|
+
## 67. 31. ASYNC VALIDATOR WITH DEBOUNCE
|
|
3753
|
+
|
|
3754
|
+
Для проверок типа «уникальность email», «валидация INN через API», «проверка
|
|
3755
|
+
адреса по DaData» используй `asyncValidators` в `FieldConfig`:
|
|
3756
|
+
|
|
3757
|
+
```ts
|
|
3758
|
+
import { type FieldConfig, type FormSchema, type AsyncValidatorFn } from '@reformer/core';
|
|
3759
|
+
import { required, email } from '@reformer/core/validators';
|
|
3760
|
+
|
|
3761
|
+
const checkEmailUnique: AsyncValidatorFn<string> = async (value) => {
|
|
3762
|
+
if (!value) return null; // empty = valid (sync `required` separately)
|
|
3763
|
+
|
|
3764
|
+
try {
|
|
3765
|
+
const res = await fetch(`/api/check-email?email=${encodeURIComponent(value)}`);
|
|
3766
|
+
const { available } = (await res.json()) as { available: boolean };
|
|
3767
|
+
return available ? null : { code: 'email-taken', message: 'Email уже зарегистрирован' };
|
|
3768
|
+
} catch {
|
|
3769
|
+
return { code: 'check-failed', message: 'Не удалось проверить email' };
|
|
3770
|
+
}
|
|
3771
|
+
};
|
|
3772
|
+
|
|
3773
|
+
const schema: FormSchema<{ email: string }> = {
|
|
3774
|
+
email: {
|
|
3775
|
+
value: '',
|
|
3776
|
+
component: Input,
|
|
3777
|
+
validators: [required(), email()], // sync first
|
|
3778
|
+
asyncValidators: [checkEmailUnique], // async after sync passed
|
|
3779
|
+
debounce: 500, // debounce input → API (поле `debounce`, не `asyncDebounceMs`)
|
|
3780
|
+
},
|
|
3781
|
+
};
|
|
3782
|
+
```
|
|
3783
|
+
|
|
3784
|
+
### Lifecycle
|
|
3785
|
+
|
|
3786
|
+
1. На каждое `setValue` запускается sync `validators` (`required`, `email`)
|
|
3787
|
+
2. Если sync passed — стартует таймер `debounce`
|
|
3788
|
+
3. По истечении debounce и стабильном value — вызывается каждый `asyncValidator`
|
|
3789
|
+
4. Во время async-проверки `useFormControl(...).pending === true` — UI может показать спиннер
|
|
3790
|
+
5. Результат записывается в `errors` поля
|
|
3791
|
+
|
|
3792
|
+
### UI integration
|
|
3793
|
+
|
|
3794
|
+
`FormField` из `@reformer/ui-kit` автоматически рендерит `<span>Проверка...</span>`
|
|
3795
|
+
когда `pending === true`. Если рендеришь сам — используй:
|
|
3796
|
+
|
|
3797
|
+
```tsx
|
|
3798
|
+
const { pending, errors } = useFormControl(form.email);
|
|
3799
|
+
return pending ? <Spinner /> : errors.length ? <Error errors={errors} /> : null;
|
|
3800
|
+
```
|
|
3801
|
+
|
|
3802
|
+
### Common patterns
|
|
3803
|
+
|
|
3804
|
+
- **Debounce 500ms** — баланс между UX и API rate-limit. Для дорогих API увеличивай
|
|
3805
|
+
до 1000-2000ms.
|
|
3806
|
+
- **Cancellation** — если `setValue` приходит во время выполнения предыдущего async,
|
|
3807
|
+
ReFormer автоматически отбрасывает результат старого вызова. Не нужно вручную
|
|
3808
|
+
abort'ить fetch (но reasonable practice — поддержать `AbortSignal` через
|
|
3809
|
+
`ctx.signal` если есть).
|
|
3810
|
+
- **Cross-field async** — для валидаций типа «дата начала > дата окончания» используй
|
|
3811
|
+
sync `validators` с `applyWhen`, а не `asyncValidators`.
|
|
3812
|
+
|
|
3813
|
+
### See also
|
|
3814
|
+
|
|
3815
|
+
- [11-async-watchfield.md](11-async-watchfield.md) — async для `watchField`, не валидаторов
|
|
3816
|
+
- [29-async-preload.md](29-async-preload.md) — async preload данных в init формы
|
|
3817
|
+
- API: `validateAsync(field): Promise<boolean>` — manual trigger, обычно не нужен
|
|
3818
|
+
|
|
3819
|
+
## 68. 32. ASYNC OPTIONS LOADING
|
|
3820
|
+
|
|
3821
|
+
Для динамической подгрузки опций dropdown'а в зависимости от значения другого поля
|
|
3822
|
+
(например: `region` → `city options`, `carBrand` → `carModel options`) — используй
|
|
3823
|
+
`watchField` + `updateComponentProps({ options })`:
|
|
3824
|
+
|
|
3825
|
+
```ts
|
|
3826
|
+
import { watchField } from '@reformer/core/behaviors';
|
|
3827
|
+
import type { BehaviorSchemaFn } from '@reformer/core';
|
|
3828
|
+
|
|
3829
|
+
type CityOption = { value: string; label: string };
|
|
3830
|
+
|
|
3831
|
+
async function fetchCitiesByRegion(region: string): Promise<CityOption[]> {
|
|
3832
|
+
const res = await fetch(`/api/cities?region=${encodeURIComponent(region)}`);
|
|
3833
|
+
return res.json();
|
|
3834
|
+
}
|
|
3835
|
+
|
|
3836
|
+
const behavior: BehaviorSchemaFn<MyForm> = (path) => {
|
|
3837
|
+
watchField(
|
|
3838
|
+
path.registrationAddress.region,
|
|
3839
|
+
async (region, ctx) => {
|
|
3840
|
+
if (!region) {
|
|
3841
|
+
ctx.form.registrationAddress.city.updateComponentProps({ options: [] });
|
|
3842
|
+
ctx.form.registrationAddress.city.setValue('');
|
|
3843
|
+
return;
|
|
3844
|
+
}
|
|
3845
|
+
|
|
3846
|
+
// Set loading state (optional — UI показывает spinner)
|
|
3847
|
+
ctx.form.registrationAddress.city.updateComponentProps({
|
|
3848
|
+
loading: true,
|
|
3849
|
+
options: [],
|
|
3850
|
+
});
|
|
3851
|
+
|
|
3852
|
+
try {
|
|
3853
|
+
const options = await fetchCitiesByRegion(region);
|
|
3854
|
+
ctx.form.registrationAddress.city.updateComponentProps({
|
|
3855
|
+
loading: false,
|
|
3856
|
+
options,
|
|
3857
|
+
});
|
|
3858
|
+
} catch {
|
|
3859
|
+
ctx.form.registrationAddress.city.updateComponentProps({
|
|
3860
|
+
loading: false,
|
|
3861
|
+
options: [],
|
|
3862
|
+
});
|
|
3863
|
+
// optionally — set error на поле через setError
|
|
3864
|
+
}
|
|
3865
|
+
},
|
|
3866
|
+
{ debounce: 300 }
|
|
3867
|
+
);
|
|
3868
|
+
};
|
|
3869
|
+
```
|
|
3870
|
+
|
|
3871
|
+
### Lifecycle
|
|
3872
|
+
|
|
3873
|
+
1. `watchField` подписан на изменения `path.region`
|
|
3874
|
+
2. На каждое изменение — debounce 300ms (чтобы не fetch на каждое нажатие клавиши)
|
|
3875
|
+
3. После debounce — async `fetchCitiesByRegion(region)`
|
|
3876
|
+
4. Результат — `updateComponentProps({ options })` на target поле
|
|
3877
|
+
5. UI (Select / Combobox) автоматически обновится через signal на componentProps
|
|
3878
|
+
|
|
3879
|
+
### Common patterns
|
|
3880
|
+
|
|
3881
|
+
- **Debounce 300-500ms** — баланс между UX и rate-limit
|
|
3882
|
+
- **Reset target value** — при смене source очисти `setValue('')` чтобы не остался stale выбор
|
|
3883
|
+
- **Loading state** — `componentProps.loading: true` пока fetch идёт (UI компонент должен это поддержать)
|
|
3884
|
+
- **Cancellation** — ReFormer **не** отменяет старый fetch автоматически. Если предыдущий fetch ещё идёт, а пришло новое значение — новый запустится параллельно. Может приводить к race. Workaround: использовать `AbortController` через closure:
|
|
3885
|
+
```ts
|
|
3886
|
+
let abortController: AbortController | null = null;
|
|
3887
|
+
watchField(path.region, async (region, ctx) => {
|
|
3888
|
+
abortController?.abort();
|
|
3889
|
+
abortController = new AbortController();
|
|
3890
|
+
try {
|
|
3891
|
+
const opts = await fetchCities(region, { signal: abortController.signal });
|
|
3892
|
+
ctx.form.city.updateComponentProps({ options: opts });
|
|
3893
|
+
} catch (e) {
|
|
3894
|
+
if ((e as Error).name === 'AbortError') return;
|
|
3895
|
+
throw e;
|
|
3896
|
+
}
|
|
3897
|
+
});
|
|
3898
|
+
```
|
|
3899
|
+
|
|
3900
|
+
### Initial load (preload at form mount)
|
|
3901
|
+
|
|
3902
|
+
Для preload опций при инициализации формы — используй `async-preload` recipe (см. [29-async-preload.md](29-async-preload.md)). Не `watchField` — он не triggers на init.
|
|
3903
|
+
|
|
3904
|
+
### See also
|
|
3905
|
+
|
|
3906
|
+
- [11-async-watchfield.md](11-async-watchfield.md) — общий паттерн async в `watchField`
|
|
3907
|
+
- [29-async-preload.md](29-async-preload.md) — preload данных при инициализации
|
|
3908
|
+
- [31-async-validator-debounce.md](31-async-validator-debounce.md) — для async **валидации** (не options)
|
|
3909
|
+
|
|
3910
|
+
## 69. API Reference
|
|
3566
3911
|
|
|
3567
3912
|
_Auto-generated from JSDoc on public exports._
|
|
3568
3913
|
|
|
@@ -4640,7 +4985,7 @@ _Source: src/core/behavior/behaviors/enable-when.ts_
|
|
|
4640
4985
|
|
|
4641
4986
|
**Signature:**
|
|
4642
4987
|
```typescript
|
|
4643
|
-
export function email<TForm, TField extends string | undefined = string>(
|
|
4988
|
+
export function email<TForm, TField extends string | null | undefined = string>(
|
|
4644
4989
|
fieldPath: FieldPathNode<TForm, TField> | undefined,
|
|
4645
4990
|
options?: ValidateOptions
|
|
4646
4991
|
): void
|
|
@@ -6947,7 +7292,7 @@ _Source: src/core/utils/field-path-navigator.ts_
|
|
|
6947
7292
|
|
|
6948
7293
|
**Signature:**
|
|
6949
7294
|
```typescript
|
|
6950
|
-
export function pattern<TForm, TField extends string | undefined = string>(
|
|
7295
|
+
export function pattern<TForm, TField extends string | null | undefined = string>(
|
|
6951
7296
|
fieldPath: FieldPathNode<TForm, TField> | undefined,
|
|
6952
7297
|
regex: RegExp,
|
|
6953
7298
|
options?: ValidateOptions
|
|
@@ -6994,7 +7339,7 @@ _Source: src/core/validation/validators/pattern.ts_
|
|
|
6994
7339
|
|
|
6995
7340
|
**Signature:**
|
|
6996
7341
|
```typescript
|
|
6997
|
-
export function phone<TForm, TField extends string | undefined = string>(
|
|
7342
|
+
export function phone<TForm, TField extends string | null | undefined = string>(
|
|
6998
7343
|
fieldPath: FieldPathNode<TForm, TField> | undefined,
|
|
6999
7344
|
options?: ValidateOptions & {
|
|
7000
7345
|
/** Формат телефона */
|
|
@@ -7953,7 +8298,7 @@ _Source: src/core/types/index.ts_
|
|
|
7953
8298
|
|
|
7954
8299
|
**Signature:**
|
|
7955
8300
|
```typescript
|
|
7956
|
-
export function url<TForm, TField extends string | undefined = string>(
|
|
8301
|
+
export function url<TForm, TField extends string | null | undefined = string>(
|
|
7957
8302
|
fieldPath: FieldPathNode<TForm, TField> | undefined,
|
|
7958
8303
|
options?: ValidateOptions & {
|
|
7959
8304
|
/** Требовать наличие протокола (http:// или https://) */
|