@reformer/core 2.0.0-beta.12 → 2.0.0-beta.14
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
|
|
@@ -501,6 +534,40 @@ Reference patterns: `complex-multy-step-form/schemas/credit-application-behavior
|
|
|
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
|
+
TS2769: Object literal may only specify known properties, and 'form' does not
|
|
542
|
+
exist in type 'FormSchema<MyForm>'.
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
(или похожее с `validation` / `behavior` ключами).
|
|
546
|
+
|
|
547
|
+
**Причина**: `createForm` имеет 2 overload — `GroupNodeConfig<T>` (с `form/validation/behavior`)
|
|
548
|
+
и schema-only. Если в inline literal `form: { ... }` есть глубокая inference-проблема
|
|
549
|
+
(чаще всего — literal default value у union-типа поля, см. Recipe 8 в
|
|
550
|
+
[30-type-safety-recipes.md](30-type-safety-recipes.md)), TS отбрасывает Overload 1 и
|
|
551
|
+
матчит Overload 2 (schema-only). В нём ключа `form` нет — получаешь misleading error.
|
|
552
|
+
|
|
553
|
+
**Workaround** — extract `form` literal в typed local:
|
|
554
|
+
|
|
555
|
+
```ts
|
|
556
|
+
const form: FormSchema<MyForm> = {
|
|
557
|
+
fieldA: { value: 'foo', component: Input, /* ... */ },
|
|
558
|
+
// ...
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
createForm({ form, validation, behavior });
|
|
562
|
+
```
|
|
563
|
+
|
|
564
|
+
Теперь TS репортит **реальную** per-field ошибку — например:
|
|
565
|
+
```
|
|
566
|
+
TS2322: Type '"male"' is not assignable to type 'Gender | null'
|
|
567
|
+
```
|
|
568
|
+
|
|
569
|
+
После этого решай конкретную проблему (Recipe 8 — `satisfies FieldConfig<UnionType>`).
|
|
570
|
+
|
|
504
571
|
### Imports rule (#1 cause of cascading errors — read first)
|
|
505
572
|
|
|
506
573
|
Types live in `@reformer/core`. Functions live in submodules (`/validators`,
|
|
@@ -1286,105 +1353,180 @@ export const creditApplicationValidation: ValidationSchemaFn<Form> = (path) => {
|
|
|
1286
1353
|
|
|
1287
1354
|
## 17. 14.5 UI COMPONENT PATTERNS
|
|
1288
1355
|
|
|
1289
|
-
|
|
1356
|
+
> **Default rule (read first)**: для UI используй `FormField` из
|
|
1357
|
+
> [`@reformer/ui-kit`](../../reformer-ui-kit/) — он покрывает 95% случаев одной
|
|
1358
|
+
> строкой `<FormField control={form.x} />`. Свои field-обёртки пиши ТОЛЬКО если
|
|
1359
|
+
> ui-kit не подходит (другая design system, особый low-level input).
|
|
1360
|
+
>
|
|
1361
|
+
> Канонический schema-driven подход:
|
|
1362
|
+
> - **компонент** объявляется в схеме как `component: Input` (или `Select`, `Checkbox`, etc.)
|
|
1363
|
+
> - **пропсы** компонента — в `componentProps: { label, placeholder, options, type, ... }`
|
|
1364
|
+
> - **JSX рендерит**: `<FormField control={form.x} />` БЕЗ дополнительных props
|
|
1365
|
+
>
|
|
1366
|
+
> См. `find_recipe(package="@reformer/ui-kit", topic="form-field-integration")`
|
|
1367
|
+
> для полного руководства.
|
|
1290
1368
|
|
|
1291
|
-
###
|
|
1369
|
+
### Default — FormField из ui-kit (canonical)
|
|
1292
1370
|
|
|
1293
1371
|
```tsx
|
|
1294
|
-
import
|
|
1295
|
-
import {
|
|
1372
|
+
import { useMemo } from 'react';
|
|
1373
|
+
import { createForm, type FormSchema } from '@reformer/core';
|
|
1374
|
+
import { FormField, Input, Select, Checkbox, Button } from '@reformer/ui-kit';
|
|
1296
1375
|
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
}
|
|
1376
|
+
type RegistrationForm = {
|
|
1377
|
+
email: string;
|
|
1378
|
+
country: string;
|
|
1379
|
+
agree: boolean;
|
|
1380
|
+
};
|
|
1303
1381
|
|
|
1304
|
-
function
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1382
|
+
function RegistrationPage() {
|
|
1383
|
+
const form = useMemo(() =>
|
|
1384
|
+
createForm<RegistrationForm>({
|
|
1385
|
+
form: {
|
|
1386
|
+
email: {
|
|
1387
|
+
value: '',
|
|
1388
|
+
component: Input,
|
|
1389
|
+
componentProps: { label: 'Email', type: 'email', placeholder: 'you@example.com' },
|
|
1390
|
+
},
|
|
1391
|
+
country: {
|
|
1392
|
+
value: 'ru',
|
|
1393
|
+
component: Select,
|
|
1394
|
+
componentProps: {
|
|
1395
|
+
label: 'Country',
|
|
1396
|
+
options: [
|
|
1397
|
+
{ value: 'ru', label: 'Россия' },
|
|
1398
|
+
{ value: 'by', label: 'Беларусь' },
|
|
1399
|
+
],
|
|
1400
|
+
},
|
|
1401
|
+
},
|
|
1402
|
+
agree: {
|
|
1403
|
+
value: false,
|
|
1404
|
+
component: Checkbox,
|
|
1405
|
+
componentProps: { label: 'I agree to terms' },
|
|
1406
|
+
},
|
|
1407
|
+
} satisfies FormSchema<RegistrationForm>,
|
|
1408
|
+
}),
|
|
1409
|
+
[]
|
|
1410
|
+
);
|
|
1312
1411
|
|
|
1313
1412
|
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>
|
|
1413
|
+
<form>
|
|
1414
|
+
<FormField control={form.email} testId="email" />
|
|
1415
|
+
<FormField control={form.country} testId="country" />
|
|
1416
|
+
<FormField control={form.agree} testId="agree" />
|
|
1417
|
+
<Button type="submit">Register</Button>
|
|
1418
|
+
</form>
|
|
1334
1419
|
);
|
|
1335
1420
|
}
|
|
1421
|
+
```
|
|
1422
|
+
|
|
1423
|
+
`FormField` сам читает `componentProps.label`, `componentProps.placeholder`,
|
|
1424
|
+
`componentProps.options` через `useFormControl(...).componentProps` и применяет
|
|
1425
|
+
их к нужному `<input>`/`<select>`/etc. Error rendering, `pending` для async-валидаций,
|
|
1426
|
+
`data-testid` для e2e — всё из коробки.
|
|
1427
|
+
|
|
1428
|
+
### Anti-patterns (не делай так)
|
|
1336
1429
|
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1430
|
+
❌ **Свои field-компоненты с label-prop'ами в JSX**:
|
|
1431
|
+
```tsx
|
|
1432
|
+
// WRONG — дублирует логику FormField, ломает schema-driven архитектуру
|
|
1433
|
+
<Input control={form.email} label="Email" placeholder="..." />
|
|
1434
|
+
<Select control={form.country} options={[...]} />
|
|
1340
1435
|
```
|
|
1341
1436
|
|
|
1342
|
-
|
|
1437
|
+
❌ **Передача компонент-пропсов через JSX вместо схемы**:
|
|
1438
|
+
```tsx
|
|
1439
|
+
// WRONG — нарушает single source of truth (схема)
|
|
1440
|
+
<FormField control={form.email} label="Email" />
|
|
1441
|
+
```
|
|
1442
|
+
|
|
1443
|
+
✅ Всё это в схеме:
|
|
1444
|
+
```ts
|
|
1445
|
+
{ email: { component: Input, componentProps: { label: 'Email' } } }
|
|
1446
|
+
```
|
|
1343
1447
|
|
|
1344
1448
|
```tsx
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1449
|
+
<FormField control={form.email} />
|
|
1450
|
+
```
|
|
1451
|
+
|
|
1452
|
+
### Advanced — кастомный input через `children` slot
|
|
1453
|
+
|
|
1454
|
+
Когда нужен низкоуровневый input, которого нет в ui-kit (маска, особый combobox):
|
|
1455
|
+
|
|
1456
|
+
```tsx
|
|
1457
|
+
import { FormField } from '@reformer/ui-kit';
|
|
1458
|
+
import { InputMask } from 'react-input-mask';
|
|
1459
|
+
|
|
1460
|
+
<FormField control={form.phone} testId="phone">
|
|
1461
|
+
<InputMask mask="+7 (999) 999-99-99" />
|
|
1462
|
+
</FormField>
|
|
1463
|
+
```
|
|
1350
1464
|
|
|
1351
|
-
|
|
1352
|
-
|
|
1465
|
+
`children` оборачивается в `CdkFormField.Control asChild` и получает все нужные
|
|
1466
|
+
props (`value`, `onChange`, `onBlur`, `aria-invalid`).
|
|
1467
|
+
|
|
1468
|
+
### Advanced — write your own from scratch (rare)
|
|
1469
|
+
|
|
1470
|
+
Если ты не хочешь подключать `@reformer/ui-kit`, пиши свои компоненты на основе
|
|
1471
|
+
`useFormControl` — но **сохраняй schema-driven подход**: читай label/placeholder
|
|
1472
|
+
из `componentProps`, не из JSX-props.
|
|
1473
|
+
|
|
1474
|
+
```tsx
|
|
1475
|
+
import type { FieldNode } from '@reformer/core';
|
|
1476
|
+
import { useFormControl } from '@reformer/core';
|
|
1477
|
+
|
|
1478
|
+
type MyFormFieldProps<T> = { control: FieldNode<T> }; // ← ОДИН prop
|
|
1479
|
+
|
|
1480
|
+
function MyFormField<T>({ control }: MyFormFieldProps<T>) {
|
|
1481
|
+
const { value, errors, disabled, shouldShowError, componentProps } = useFormControl(control);
|
|
1482
|
+
// componentProps = { label, placeholder, type, options, ... } — из СХЕМЫ
|
|
1483
|
+
const cp = (componentProps ?? {}) as Record<string, unknown>;
|
|
1353
1484
|
|
|
1354
1485
|
return (
|
|
1355
|
-
<
|
|
1356
|
-
{label && <
|
|
1357
|
-
<
|
|
1358
|
-
|
|
1359
|
-
|
|
1486
|
+
<label>
|
|
1487
|
+
{cp.label && <span>{cp.label as string}</span>}
|
|
1488
|
+
<input
|
|
1489
|
+
type={(cp.type as string) ?? 'text'}
|
|
1490
|
+
value={(value ?? '') as string}
|
|
1491
|
+
placeholder={cp.placeholder as string | undefined}
|
|
1360
1492
|
disabled={disabled}
|
|
1361
|
-
|
|
1362
|
-
{
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
))}
|
|
1367
|
-
</select>
|
|
1368
|
-
{touched && errors[0] && <span className="error-message">{errors[0].message}</span>}
|
|
1369
|
-
</div>
|
|
1493
|
+
onChange={(e) => (control.setValue as (v: unknown) => void)(e.target.value)}
|
|
1494
|
+
onBlur={() => control.markAsTouched()}
|
|
1495
|
+
/>
|
|
1496
|
+
{shouldShowError && errors[0] && <span>{errors[0].message}</span>}
|
|
1497
|
+
</label>
|
|
1370
1498
|
);
|
|
1371
1499
|
}
|
|
1372
1500
|
```
|
|
1373
1501
|
|
|
1374
|
-
|
|
1502
|
+
Использование — как у `FormField`:
|
|
1503
|
+
|
|
1504
|
+
```tsx
|
|
1505
|
+
<MyFormField control={form.email} /> // ← без label-prop
|
|
1506
|
+
```
|
|
1507
|
+
|
|
1508
|
+
### Integration with UI libraries (shadcn etc.)
|
|
1509
|
+
|
|
1510
|
+
Если есть существующая design system — оборачивай её компоненты в один
|
|
1511
|
+
`MyFormField` (как выше) и используй один прop `control`. Не множь обёртки на
|
|
1512
|
+
тип input'а — пусть `componentProps.type` диспатчит внутри.
|
|
1375
1513
|
|
|
1376
1514
|
```tsx
|
|
1377
|
-
// With shadcn/ui
|
|
1378
1515
|
import { Input } from '@/components/ui/input';
|
|
1379
1516
|
import { Label } from '@/components/ui/label';
|
|
1380
1517
|
|
|
1381
|
-
function ShadcnFormField({ control
|
|
1382
|
-
const { value, errors, disabled } = useFormControl(control);
|
|
1518
|
+
function ShadcnFormField({ control }: { control: FieldNode<string> }) {
|
|
1519
|
+
const { value, errors, disabled, componentProps } = useFormControl(control);
|
|
1520
|
+
const cp = (componentProps ?? {}) as Record<string, unknown>;
|
|
1383
1521
|
|
|
1384
1522
|
return (
|
|
1385
1523
|
<div className="space-y-2">
|
|
1386
|
-
<Label>{label}</Label>
|
|
1387
|
-
<Input
|
|
1524
|
+
{cp.label && <Label>{cp.label as string}</Label>}
|
|
1525
|
+
<Input
|
|
1526
|
+
value={(value ?? '') as string}
|
|
1527
|
+
onChange={(e) => control.setValue(e.target.value)}
|
|
1528
|
+
disabled={disabled}
|
|
1529
|
+
/>
|
|
1388
1530
|
{errors[0] && <p className="text-red-500">{errors[0].message}</p>}
|
|
1389
1531
|
</div>
|
|
1390
1532
|
);
|
|
@@ -3550,6 +3692,44 @@ const navRef = useRef<FormWizardHandle<MyForm>>(null);
|
|
|
3550
3692
|
/>
|
|
3551
3693
|
```
|
|
3552
3694
|
|
|
3695
|
+
### Recipe 8 — enum-typed default values (union literal widening)
|
|
3696
|
+
|
|
3697
|
+
Когда default `value` поля — литерал union-type, TS widens его до `string`,
|
|
3698
|
+
ломая `FormSchema<T>` assignability:
|
|
3699
|
+
|
|
3700
|
+
```typescript
|
|
3701
|
+
type Gender = 'male' | 'female';
|
|
3702
|
+
|
|
3703
|
+
// ❌ TS error: '"male"' is not assignable to 'Gender | null'
|
|
3704
|
+
const schema: FormSchema<{ gender: Gender }> = {
|
|
3705
|
+
gender: {
|
|
3706
|
+
value: 'male', // widens to string
|
|
3707
|
+
component: RadioGroup,
|
|
3708
|
+
componentProps: { options: [...] },
|
|
3709
|
+
},
|
|
3710
|
+
};
|
|
3711
|
+
```
|
|
3712
|
+
|
|
3713
|
+
**Fix** — `satisfies FieldConfig<UnionType>` сужает без `as`-каста:
|
|
3714
|
+
|
|
3715
|
+
```typescript
|
|
3716
|
+
gender: {
|
|
3717
|
+
value: 'male',
|
|
3718
|
+
component: RadioGroup,
|
|
3719
|
+
componentProps: { options: [...] },
|
|
3720
|
+
} satisfies FieldConfig<Gender>,
|
|
3721
|
+
```
|
|
3722
|
+
|
|
3723
|
+
Альтернативы:
|
|
3724
|
+
|
|
3725
|
+
- `value: 'male' as Gender` — works, но `as`-каст. Избегай (см. anti-patterns).
|
|
3726
|
+
- `value: null` (если `Gender | null` допустим) — нет widening проблемы, но требует
|
|
3727
|
+
null-check в render/validation.
|
|
3728
|
+
|
|
3729
|
+
Применяется к любому union-полю: `loanType`, `employmentStatus`, `maritalStatus`,
|
|
3730
|
+
`propertyType`, и т.п. Симптом без этого fix'а — misleading TS2769
|
|
3731
|
+
"`'form' does not exist in FormSchema<...>`" (см. `05-common-mistakes.md`).
|
|
3732
|
+
|
|
3553
3733
|
### Anti-patterns to avoid
|
|
3554
3734
|
|
|
3555
3735
|
- `import { type ValidationSchemaFn } from '@reformer/core/validators'` →
|
|
@@ -3562,7 +3742,167 @@ const navRef = useRef<FormWizardHandle<MyForm>>(null);
|
|
|
3562
3742
|
- `as never` cast on `computeFrom` target — never necessary; if you reach
|
|
3563
3743
|
for it, you have one of the issues above.
|
|
3564
3744
|
|
|
3565
|
-
## 67.
|
|
3745
|
+
## 67. 31. ASYNC VALIDATOR WITH DEBOUNCE
|
|
3746
|
+
|
|
3747
|
+
Для проверок типа «уникальность email», «валидация INN через API», «проверка
|
|
3748
|
+
адреса по DaData» используй `asyncValidators` в `FieldConfig`:
|
|
3749
|
+
|
|
3750
|
+
```ts
|
|
3751
|
+
import { type FieldConfig, type FormSchema, type AsyncValidatorFn } from '@reformer/core';
|
|
3752
|
+
import { required, email } from '@reformer/core/validators';
|
|
3753
|
+
|
|
3754
|
+
const checkEmailUnique: AsyncValidatorFn<string> = async (value) => {
|
|
3755
|
+
if (!value) return null; // empty = valid (sync `required` separately)
|
|
3756
|
+
|
|
3757
|
+
try {
|
|
3758
|
+
const res = await fetch(`/api/check-email?email=${encodeURIComponent(value)}`);
|
|
3759
|
+
const { available } = (await res.json()) as { available: boolean };
|
|
3760
|
+
return available
|
|
3761
|
+
? null
|
|
3762
|
+
: { code: 'email-taken', message: 'Email уже зарегистрирован' };
|
|
3763
|
+
} catch {
|
|
3764
|
+
return { code: 'check-failed', message: 'Не удалось проверить email' };
|
|
3765
|
+
}
|
|
3766
|
+
};
|
|
3767
|
+
|
|
3768
|
+
const schema: FormSchema<{ email: string }> = {
|
|
3769
|
+
email: {
|
|
3770
|
+
value: '',
|
|
3771
|
+
component: Input,
|
|
3772
|
+
validators: [required(), email()], // sync first
|
|
3773
|
+
asyncValidators: [checkEmailUnique], // async after sync passed
|
|
3774
|
+
debounce: 500, // debounce input → API (поле `debounce`, не `asyncDebounceMs`)
|
|
3775
|
+
},
|
|
3776
|
+
};
|
|
3777
|
+
```
|
|
3778
|
+
|
|
3779
|
+
### Lifecycle
|
|
3780
|
+
|
|
3781
|
+
1. На каждое `setValue` запускается sync `validators` (`required`, `email`)
|
|
3782
|
+
2. Если sync passed — стартует таймер `debounce`
|
|
3783
|
+
3. По истечении debounce и стабильном value — вызывается каждый `asyncValidator`
|
|
3784
|
+
4. Во время async-проверки `useFormControl(...).pending === true` — UI может показать спиннер
|
|
3785
|
+
5. Результат записывается в `errors` поля
|
|
3786
|
+
|
|
3787
|
+
### UI integration
|
|
3788
|
+
|
|
3789
|
+
`FormField` из `@reformer/ui-kit` автоматически рендерит `<span>Проверка...</span>`
|
|
3790
|
+
когда `pending === true`. Если рендеришь сам — используй:
|
|
3791
|
+
|
|
3792
|
+
```tsx
|
|
3793
|
+
const { pending, errors } = useFormControl(form.email);
|
|
3794
|
+
return pending ? <Spinner /> : errors.length ? <Error errors={errors} /> : null;
|
|
3795
|
+
```
|
|
3796
|
+
|
|
3797
|
+
### Common patterns
|
|
3798
|
+
|
|
3799
|
+
- **Debounce 500ms** — баланс между UX и API rate-limit. Для дорогих API увеличивай
|
|
3800
|
+
до 1000-2000ms.
|
|
3801
|
+
- **Cancellation** — если `setValue` приходит во время выполнения предыдущего async,
|
|
3802
|
+
ReFormer автоматически отбрасывает результат старого вызова. Не нужно вручную
|
|
3803
|
+
abort'ить fetch (но reasonable practice — поддержать `AbortSignal` через
|
|
3804
|
+
`ctx.signal` если есть).
|
|
3805
|
+
- **Cross-field async** — для валидаций типа «дата начала > дата окончания» используй
|
|
3806
|
+
sync `validators` с `applyWhen`, а не `asyncValidators`.
|
|
3807
|
+
|
|
3808
|
+
### See also
|
|
3809
|
+
|
|
3810
|
+
- [11-async-watchfield.md](11-async-watchfield.md) — async для `watchField`, не валидаторов
|
|
3811
|
+
- [29-async-preload.md](29-async-preload.md) — async preload данных в init формы
|
|
3812
|
+
- API: `validateAsync(field): Promise<boolean>` — manual trigger, обычно не нужен
|
|
3813
|
+
|
|
3814
|
+
## 68. 32. ASYNC OPTIONS LOADING
|
|
3815
|
+
|
|
3816
|
+
Для динамической подгрузки опций dropdown'а в зависимости от значения другого поля
|
|
3817
|
+
(например: `region` → `city options`, `carBrand` → `carModel options`) — используй
|
|
3818
|
+
`watchField` + `updateComponentProps({ options })`:
|
|
3819
|
+
|
|
3820
|
+
```ts
|
|
3821
|
+
import { watchField } from '@reformer/core/behaviors';
|
|
3822
|
+
import type { BehaviorSchemaFn } from '@reformer/core';
|
|
3823
|
+
|
|
3824
|
+
type CityOption = { value: string; label: string };
|
|
3825
|
+
|
|
3826
|
+
async function fetchCitiesByRegion(region: string): Promise<CityOption[]> {
|
|
3827
|
+
const res = await fetch(`/api/cities?region=${encodeURIComponent(region)}`);
|
|
3828
|
+
return res.json();
|
|
3829
|
+
}
|
|
3830
|
+
|
|
3831
|
+
const behavior: BehaviorSchemaFn<MyForm> = (path) => {
|
|
3832
|
+
watchField(
|
|
3833
|
+
path.registrationAddress.region,
|
|
3834
|
+
async (region, ctx) => {
|
|
3835
|
+
if (!region) {
|
|
3836
|
+
ctx.form.registrationAddress.city.updateComponentProps({ options: [] });
|
|
3837
|
+
ctx.form.registrationAddress.city.setValue('');
|
|
3838
|
+
return;
|
|
3839
|
+
}
|
|
3840
|
+
|
|
3841
|
+
// Set loading state (optional — UI показывает spinner)
|
|
3842
|
+
ctx.form.registrationAddress.city.updateComponentProps({
|
|
3843
|
+
loading: true,
|
|
3844
|
+
options: [],
|
|
3845
|
+
});
|
|
3846
|
+
|
|
3847
|
+
try {
|
|
3848
|
+
const options = await fetchCitiesByRegion(region);
|
|
3849
|
+
ctx.form.registrationAddress.city.updateComponentProps({
|
|
3850
|
+
loading: false,
|
|
3851
|
+
options,
|
|
3852
|
+
});
|
|
3853
|
+
} catch {
|
|
3854
|
+
ctx.form.registrationAddress.city.updateComponentProps({
|
|
3855
|
+
loading: false,
|
|
3856
|
+
options: [],
|
|
3857
|
+
});
|
|
3858
|
+
// optionally — set error на поле через setError
|
|
3859
|
+
}
|
|
3860
|
+
},
|
|
3861
|
+
{ debounce: 300 }
|
|
3862
|
+
);
|
|
3863
|
+
};
|
|
3864
|
+
```
|
|
3865
|
+
|
|
3866
|
+
### Lifecycle
|
|
3867
|
+
|
|
3868
|
+
1. `watchField` подписан на изменения `path.region`
|
|
3869
|
+
2. На каждое изменение — debounce 300ms (чтобы не fetch на каждое нажатие клавиши)
|
|
3870
|
+
3. После debounce — async `fetchCitiesByRegion(region)`
|
|
3871
|
+
4. Результат — `updateComponentProps({ options })` на target поле
|
|
3872
|
+
5. UI (Select / Combobox) автоматически обновится через signal на componentProps
|
|
3873
|
+
|
|
3874
|
+
### Common patterns
|
|
3875
|
+
|
|
3876
|
+
- **Debounce 300-500ms** — баланс между UX и rate-limit
|
|
3877
|
+
- **Reset target value** — при смене source очисти `setValue('')` чтобы не остался stale выбор
|
|
3878
|
+
- **Loading state** — `componentProps.loading: true` пока fetch идёт (UI компонент должен это поддержать)
|
|
3879
|
+
- **Cancellation** — ReFormer **не** отменяет старый fetch автоматически. Если предыдущий fetch ещё идёт, а пришло новое значение — новый запустится параллельно. Может приводить к race. Workaround: использовать `AbortController` через closure:
|
|
3880
|
+
```ts
|
|
3881
|
+
let abortController: AbortController | null = null;
|
|
3882
|
+
watchField(path.region, async (region, ctx) => {
|
|
3883
|
+
abortController?.abort();
|
|
3884
|
+
abortController = new AbortController();
|
|
3885
|
+
try {
|
|
3886
|
+
const opts = await fetchCities(region, { signal: abortController.signal });
|
|
3887
|
+
ctx.form.city.updateComponentProps({ options: opts });
|
|
3888
|
+
} catch (e) {
|
|
3889
|
+
if ((e as Error).name === 'AbortError') return;
|
|
3890
|
+
throw e;
|
|
3891
|
+
}
|
|
3892
|
+
});
|
|
3893
|
+
```
|
|
3894
|
+
|
|
3895
|
+
### Initial load (preload at form mount)
|
|
3896
|
+
|
|
3897
|
+
Для preload опций при инициализации формы — используй `async-preload` recipe (см. [29-async-preload.md](29-async-preload.md)). Не `watchField` — он не triggers на init.
|
|
3898
|
+
|
|
3899
|
+
### See also
|
|
3900
|
+
|
|
3901
|
+
- [11-async-watchfield.md](11-async-watchfield.md) — общий паттерн async в `watchField`
|
|
3902
|
+
- [29-async-preload.md](29-async-preload.md) — preload данных при инициализации
|
|
3903
|
+
- [31-async-validator-debounce.md](31-async-validator-debounce.md) — для async **валидации** (не options)
|
|
3904
|
+
|
|
3905
|
+
## 69. API Reference
|
|
3566
3906
|
|
|
3567
3907
|
_Auto-generated from JSDoc on public exports._
|
|
3568
3908
|
|
|
@@ -4640,7 +4980,7 @@ _Source: src/core/behavior/behaviors/enable-when.ts_
|
|
|
4640
4980
|
|
|
4641
4981
|
**Signature:**
|
|
4642
4982
|
```typescript
|
|
4643
|
-
export function email<TForm, TField extends string | undefined = string>(
|
|
4983
|
+
export function email<TForm, TField extends string | null | undefined = string>(
|
|
4644
4984
|
fieldPath: FieldPathNode<TForm, TField> | undefined,
|
|
4645
4985
|
options?: ValidateOptions
|
|
4646
4986
|
): void
|
|
@@ -6947,7 +7287,7 @@ _Source: src/core/utils/field-path-navigator.ts_
|
|
|
6947
7287
|
|
|
6948
7288
|
**Signature:**
|
|
6949
7289
|
```typescript
|
|
6950
|
-
export function pattern<TForm, TField extends string | undefined = string>(
|
|
7290
|
+
export function pattern<TForm, TField extends string | null | undefined = string>(
|
|
6951
7291
|
fieldPath: FieldPathNode<TForm, TField> | undefined,
|
|
6952
7292
|
regex: RegExp,
|
|
6953
7293
|
options?: ValidateOptions
|
|
@@ -6994,7 +7334,7 @@ _Source: src/core/validation/validators/pattern.ts_
|
|
|
6994
7334
|
|
|
6995
7335
|
**Signature:**
|
|
6996
7336
|
```typescript
|
|
6997
|
-
export function phone<TForm, TField extends string | undefined = string>(
|
|
7337
|
+
export function phone<TForm, TField extends string | null | undefined = string>(
|
|
6998
7338
|
fieldPath: FieldPathNode<TForm, TField> | undefined,
|
|
6999
7339
|
options?: ValidateOptions & {
|
|
7000
7340
|
/** Формат телефона */
|
|
@@ -7953,7 +8293,7 @@ _Source: src/core/types/index.ts_
|
|
|
7953
8293
|
|
|
7954
8294
|
**Signature:**
|
|
7955
8295
|
```typescript
|
|
7956
|
-
export function url<TForm, TField extends string | undefined = string>(
|
|
8296
|
+
export function url<TForm, TField extends string | null | undefined = string>(
|
|
7957
8297
|
fieldPath: FieldPathNode<TForm, TField> | undefined,
|
|
7958
8298
|
options?: ValidateOptions & {
|
|
7959
8299
|
/** Требовать наличие протокола (http:// или https://) */
|
package/package.json
CHANGED