@reformer/core 11.0.0-beta.6 → 11.0.0-beta.8
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 +7 -3
- package/dist/behaviors.js +216 -179
- package/dist/create-model-BBRHhHwK.js +30 -0
- package/dist/en-CjAqn7h0.js +41 -0
- package/dist/form/behaviors/collections.d.ts +14 -1
- package/dist/form/behaviors/context.d.ts +18 -2
- package/dist/form/behaviors/index.d.ts +3 -1
- package/dist/form/behaviors/internals.d.ts +9 -1
- package/dist/form/behaviors/schema-operators.d.ts +76 -0
- package/dist/form/behaviors/types.d.ts +15 -3
- package/dist/form/create-core-form.d.ts +5 -3
- package/dist/form/create-form.d.ts +28 -53
- package/dist/form/form-bundle.d.ts +136 -0
- package/dist/form/index.d.ts +6 -1
- package/dist/form/nodes/field-node.d.ts +2 -2
- package/dist/form/nodes/group-node.d.ts +1 -1
- package/dist/form/nodes/model-array-node.d.ts +10 -5
- package/dist/form/schema-controller.d.ts +137 -0
- package/dist/form/schema-subtree.d.ts +28 -0
- package/dist/form/signal-node-registry.d.ts +22 -16
- package/dist/form/types/contracts.d.ts +6 -0
- package/dist/form/types/deep-schema.d.ts +2 -2
- package/dist/form/types/form-proxy.d.ts +1 -1
- package/dist/form/types/schema-node.d.ts +37 -12
- package/dist/form/types/validation-schema.d.ts +13 -1
- package/dist/form/validation/context.d.ts +6 -1
- package/dist/form/validation/index.d.ts +1 -1
- package/dist/form/validation/operators.d.ts +74 -7
- package/dist/form/validation/types.d.ts +2 -1
- package/dist/form/validators/validation-error.d.ts +8 -0
- package/dist/i18n/descriptor.d.ts +54 -0
- package/dist/i18n/en.json.d.ts +45 -0
- package/dist/i18n/loader.d.ts +71 -0
- package/dist/i18n/locale-store.d.ts +39 -0
- package/dist/i18n/locale.d.ts +90 -0
- package/dist/i18n/message-format.d.ts +116 -0
- package/dist/i18n/resolve-localized.d.ts +17 -0
- package/dist/i18n/ru.json.d.ts +45 -0
- package/dist/i18n/translator.d.ts +50 -0
- package/dist/i18n/validation-message.d.ts +18 -0
- package/dist/i18n.d.ts +31 -0
- package/dist/i18n.js +303 -0
- package/dist/index-BrNkQj1y.js +105 -0
- package/dist/index.js +737 -743
- package/dist/loader-Dv_Hl7JC.js +216 -0
- package/dist/locale/en.d.ts +2 -0
- package/dist/locale/en.js +5 -0
- package/dist/locale/en.json +42 -0
- package/dist/locale/index.d.ts +18 -0
- package/dist/locale/ru.d.ts +2 -0
- package/dist/locale/ru.js +41 -0
- package/dist/locale/ru.json +42 -0
- package/dist/locale.d.ts +2 -0
- package/dist/locale.js +9 -0
- package/dist/model/create-model.d.ts +17 -3
- package/dist/model/index.d.ts +8 -3
- package/dist/model/model-nodes.d.ts +67 -4
- package/dist/model/model-signals-proxy.d.ts +33 -1
- package/dist/model/model-value-proxy.d.ts +76 -0
- package/dist/model/types.d.ts +26 -9
- package/dist/model-value-proxy-caTmAiUd.js +419 -0
- package/dist/model.js +21 -17
- package/dist/node-BeV_lV5i.js +100 -0
- package/dist/platforms/react/form-bundle-context.d.ts +27 -0
- package/dist/platforms/react/hooks/types.d.ts +6 -3
- package/dist/platforms/react/hooks/useFormControlValue.d.ts +2 -1
- package/dist/platforms/react/i18n/context.d.ts +13 -0
- package/dist/platforms/react/i18n/hooks.d.ts +59 -0
- package/dist/platforms/react/i18n/provider.d.ts +52 -0
- package/dist/platforms/react/index.d.ts +1 -0
- package/dist/strategy-DgzELWno.js +204 -0
- package/dist/validation-error-B-U3RsV0.js +11 -0
- package/dist/validation.js +7 -6
- package/dist/validators/email.js +5 -8
- package/dist/validators/future-date.js +8 -11
- package/dist/validators/integer.js +4 -7
- package/dist/validators/is-date.js +5 -8
- package/dist/validators/is-number.js +4 -7
- package/dist/validators/max-age.js +8 -11
- package/dist/validators/max-date.js +8 -11
- package/dist/validators/max-length.js +5 -8
- package/dist/validators/max.js +4 -7
- package/dist/validators/min-age.js +8 -11
- package/dist/validators/min-date.js +8 -11
- package/dist/validators/min-length.js +5 -8
- package/dist/validators/min.js +4 -7
- package/dist/validators/multiple-of.js +5 -8
- package/dist/validators/non-negative.js +4 -7
- package/dist/validators/non-zero.js +4 -7
- package/dist/validators/past-date.js +8 -11
- package/dist/validators/pattern.js +5 -8
- package/dist/validators/phone.js +6 -9
- package/dist/validators/required.js +4 -7
- package/dist/validators/url.js +9 -14
- package/dist/validators.js +84 -106
- package/docs/llms/03-api-signatures.md +10 -4
- package/docs/llms/09-formschema.md +5 -5
- package/docs/llms/10-arrays.md +52 -3
- package/docs/llms/16-ui-components.md +2 -2
- package/docs/llms/18-conditional-fields.md +1 -1
- package/docs/llms/21-array-operations.md +26 -0
- package/docs/llms/28-submit-and-reset.md +3 -3
- package/docs/llms/29-async-preload.md +4 -4
- package/docs/llms/33-validation-strategy.md +2 -2
- package/docs/llms/34-i18n.md +272 -0
- package/docs/llms/35-unified-contract.md +246 -0
- package/llms-index.json +1 -1
- package/llms.txt +2746 -229
- package/package.json +22 -3
- package/dist/create-model-Dxh9cfLp.js +0 -97
- package/dist/model-signals-proxy-mV3WGdlO.js +0 -233
- package/dist/node-DGEFMTQu.js +0 -18
- package/dist/strategy-DA3JwxsR.js +0 -170
|
@@ -11,7 +11,7 @@ createModel<T extends object>(initial: T): FormModel<T>
|
|
|
11
11
|
// model.arrayField → ModelArray<Item> (push/removeAt/insertAt/move/swap/clear/at/map/length)
|
|
12
12
|
|
|
13
13
|
// Форма (ноды поверх сигналов модели)
|
|
14
|
-
|
|
14
|
+
createFormFromModel<T>({ model, schema, behavior? }): FormProxy<T>
|
|
15
15
|
// form.<field> → FieldNode / GroupNode / FormArrayProxy
|
|
16
16
|
// form.<field>.setValue(v) / .value.value / .errors.value / .disabled.value
|
|
17
17
|
// form.<field>.enable() / .disable() / .reset() / .markAsTouched() / .setErrors([...])
|
|
@@ -57,11 +57,17 @@ minAge(years: number, options?: { message?: string })
|
|
|
57
57
|
maxAge(years: number, options?: { message?: string })
|
|
58
58
|
```
|
|
59
59
|
|
|
60
|
+
Кроме `message`, каждое правило принимает `messageKey` (ключ словаря приложения) и `params`.
|
|
61
|
+
Без `message` правило кладёт в ошибку пустую строку, а текст при отображении берётся из словаря
|
|
62
|
+
локали по коду (`validation.<code>`): «Обязательное поле» под русской локалью, английская фраза без
|
|
63
|
+
провайдера. Явное `message` важнее словаря, но языком не переключается; для локализуемого текста —
|
|
64
|
+
`messageKey`. Подробно — [34-i18n.md](./34-i18n.md).
|
|
65
|
+
|
|
60
66
|
Использование: layout-дерево `createForm` привязывает поля к сигналам, правила живут отдельной
|
|
61
67
|
`ValidationSchema` и гоняются раннером `validateModel` по требованию.
|
|
62
68
|
|
|
63
69
|
```typescript
|
|
64
|
-
import { createModel, createForm } from '@reformer/core';
|
|
70
|
+
import { createModel, createForm, createFormFromModel } from '@reformer/core';
|
|
65
71
|
import { defineValidationSchema, validate, validateModel } from '@reformer/core/validation';
|
|
66
72
|
import { required, min, max, email } from '@reformer/core/validators';
|
|
67
73
|
|
|
@@ -69,7 +75,7 @@ type Loan = { email: string; age: number | null; amount: number | null };
|
|
|
69
75
|
const model = createModel<Loan>({ email: '', age: null, amount: null });
|
|
70
76
|
|
|
71
77
|
// layout-схема createForm НЕ несёт validators — только привязка поля к сигналу + компонент
|
|
72
|
-
const form =
|
|
78
|
+
const form = createFormFromModel({
|
|
73
79
|
model,
|
|
74
80
|
schema: {
|
|
75
81
|
email: { value: model.$.email, component: Input },
|
|
@@ -256,7 +262,7 @@ const behavior = defineFormBehavior<MyForm>(({ model, form }) => {
|
|
|
256
262
|
});
|
|
257
263
|
});
|
|
258
264
|
|
|
259
|
-
const form =
|
|
265
|
+
const form = createFormFromModel({ model, schema, behavior });
|
|
260
266
|
```
|
|
261
267
|
|
|
262
268
|
DSL-операторы: `compute` (auto-tracking, без явного списка источников), `computeFrom`, `copyFrom`,
|
|
@@ -25,7 +25,7 @@ layout-схеме НЕТ — правила живут в отдельной с
|
|
|
25
25
|
### Primitive Fields
|
|
26
26
|
|
|
27
27
|
```typescript
|
|
28
|
-
import { createModel,
|
|
28
|
+
import { createModel, createFormFromModel } from '@reformer/core';
|
|
29
29
|
import { defineValidationSchema, validate } from '@reformer/core/validation';
|
|
30
30
|
import { required } from '@reformer/core/validators';
|
|
31
31
|
import { Input, InputNumber, SelectAsync, CheckboxWithLabel } from '@reformer/ui-kit';
|
|
@@ -66,7 +66,7 @@ const schema = {
|
|
|
66
66
|
},
|
|
67
67
|
};
|
|
68
68
|
|
|
69
|
-
const form =
|
|
69
|
+
const form = createFormFromModel<MyForm>({ model, schema });
|
|
70
70
|
```
|
|
71
71
|
|
|
72
72
|
### Nested Objects
|
|
@@ -111,7 +111,7 @@ const schema = {
|
|
|
111
111
|
|
|
112
112
|
```typescript
|
|
113
113
|
// M1: данные из модели + схема (+ опциональный декларативный behavior)
|
|
114
|
-
const form =
|
|
114
|
+
const form = createFormFromModel<MyForm>({
|
|
115
115
|
model, // FormModel<MyForm> — обязателен
|
|
116
116
|
schema, // дерево узлов, привязанных к сигналам
|
|
117
117
|
behavior: myBehavior, // опционально: defineFormBehavior(...) из @reformer/core/behaviors
|
|
@@ -126,12 +126,12 @@ model.items.push({ id: '1', name: 'Item' }); // операции над масс
|
|
|
126
126
|
> **Тип `schema` в M1 — `unknown`, не `FormSchema<T>`.** Это осознанно: интерпретатор обходит
|
|
127
127
|
> произвольную структуру и собирает листья `{ value: signal, component?, ... }`, поэтому обёртки
|
|
128
128
|
> (`{ children: [...] }`, wizard/steps) компилируются. Строгий `FormSchema<T>` (keyed-map по полям
|
|
129
|
-
> `T`) применяется только на legacy-пути `
|
|
129
|
+
> `T`) применяется только на legacy-пути `createLegacyForm(schema)` без модели.
|
|
130
130
|
|
|
131
131
|
### createForm Returns a Proxy
|
|
132
132
|
|
|
133
133
|
```typescript
|
|
134
|
-
const form =
|
|
134
|
+
const form = createFormFromModel<MyForm>({ model, schema });
|
|
135
135
|
|
|
136
136
|
form.email; // FieldNode<string> — TypeScript знает тип
|
|
137
137
|
form.address.city; // FieldNode<string> — вложенный доступ
|
package/docs/llms/10-arrays.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
элемента из его под-модели (`FormModel<Item>`).
|
|
7
7
|
|
|
8
8
|
```typescript
|
|
9
|
-
import { createModel,
|
|
9
|
+
import { createModel, createFormFromModel, type FormModel } from '@reformer/core';
|
|
10
10
|
import { Input, InputNumber } from '@reformer/ui-kit';
|
|
11
11
|
|
|
12
12
|
type Item = { id: string; name: string; price: number };
|
|
@@ -25,13 +25,62 @@ const schema = {
|
|
|
25
25
|
items: { array: model.items, item: itemSchema },
|
|
26
26
|
};
|
|
27
27
|
|
|
28
|
-
const form =
|
|
28
|
+
const form = createFormFromModel<MyForm>({ model, schema });
|
|
29
29
|
```
|
|
30
30
|
|
|
31
31
|
> **Type constraint:** тип элемента `Item` объявляй через `type`-alias (не `interface`) — иначе
|
|
32
32
|
> он не совместим с `Record<string, FormValue>` и `ArrayNode<Item>` его отвергнет.
|
|
33
33
|
> См. `30-type-safety-recipes.md`.
|
|
34
34
|
|
|
35
|
+
### Массив как ОДНО значение поля
|
|
36
|
+
|
|
37
|
+
Массив бывает не набором под-форм, а одним значением: мультивыбор, теги, список файлов. Чем он
|
|
38
|
+
является, решает **схема**, а не данные: `{ array, item }` — набор под-форм, а узел поля
|
|
39
|
+
`{ value: model.$.<путь>, component }` — одно значение. Массив, к которому в схеме ничего не
|
|
40
|
+
привязано, в форму не попадает.
|
|
41
|
+
|
|
42
|
+
```typescript
|
|
43
|
+
type MyForm = { tags: string[] };
|
|
44
|
+
|
|
45
|
+
const model = createModel<MyForm>({ tags: [] });
|
|
46
|
+
|
|
47
|
+
const schema = {
|
|
48
|
+
tags: { value: model.$.tags, component: SelectMulti }, // поле над массивом целиком
|
|
49
|
+
};
|
|
50
|
+
const form = createFormFromModel<MyForm>({ model, schema });
|
|
51
|
+
|
|
52
|
+
form.tags.setValue(['a', 'b']); // FieldNode<string[]>
|
|
53
|
+
model.$.tags.value = ['c']; // ручка значения: заменяет массив целиком
|
|
54
|
+
validate(model.$.tags, [required(), maxLength(3)]); // правило получает массив
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
- `model.$.<массив>` — записываемая ручка значения (`PathAwareSignal`): подходит для `value:` в
|
|
58
|
+
схеме, `validate`/`cross`, `enableWhen`/`copyFrom` и для `model.signalAt(path)`.
|
|
59
|
+
- Узел-массив хранит только массив: запись `null`/`undefined` даёт `[]`. Нужно отличать «не
|
|
60
|
+
выбирали» от «выбрали ничего» — объявляй поле `T[] | null` с начальным `null`: в рантайме это
|
|
61
|
+
лист, и привязывается он так же.
|
|
62
|
+
- Запись в узел-ГРУППУ (`model.$.<группа>.value = …`) — ошибка: группа пишется по полям или через
|
|
63
|
+
`model.patch(...)`.
|
|
64
|
+
|
|
65
|
+
### Массив в группе и в строке другого массива
|
|
66
|
+
|
|
67
|
+
Узел `{ array, item }` стоит на любой глубине: массив в группе получает ноду `form.<группа>.<массив>`,
|
|
68
|
+
массив в строке — `form.<массив>.at(i).<массив>`. Под-схема строки объявляет свои массивы так же.
|
|
69
|
+
|
|
70
|
+
```typescript
|
|
71
|
+
const contactItem = (contact: FormModel<Contact>) => ({
|
|
72
|
+
name: { value: contact.$.name, component: Input },
|
|
73
|
+
phones: { array: contact.phones, item: phoneItem }, // массив в строке массива
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const schema = {
|
|
77
|
+
hotlines: { array: model.details.hotlines, item: phoneItem }, // массив в группе
|
|
78
|
+
contacts: { array: model.contacts, item: contactItem },
|
|
79
|
+
};
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Поведение строк вложенного массива — `applyEach` внутри схемы строки, см. `21-array-operations.md`.
|
|
83
|
+
|
|
35
84
|
### Один массив — три слоя (три разных движка)
|
|
36
85
|
|
|
37
86
|
Одна и та же коллекция описывается **тремя разными формами** — по одной на движок. Их легко
|
|
@@ -43,7 +92,7 @@ const form = createForm<MyForm>({ model, schema });
|
|
|
43
92
|
`model.$.properties`.
|
|
44
93
|
|
|
45
94
|
```typescript
|
|
46
|
-
// узел схемы для
|
|
95
|
+
// узел схемы для createFormFromModel({ model, schema })
|
|
47
96
|
properties: { array: model.properties, item: propertyItem },
|
|
48
97
|
```
|
|
49
98
|
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
```tsx
|
|
21
21
|
import { useMemo } from 'react';
|
|
22
|
-
import { createModel,
|
|
22
|
+
import { createModel, createFormFromModel } from '@reformer/core';
|
|
23
23
|
import { FormField, Input, SelectAsync, CheckboxWithLabel, Button } from '@reformer/ui-kit';
|
|
24
24
|
|
|
25
25
|
type RegistrationForm = {
|
|
@@ -54,7 +54,7 @@ function RegistrationPage() {
|
|
|
54
54
|
componentProps: { label: 'I agree to terms' },
|
|
55
55
|
},
|
|
56
56
|
};
|
|
57
|
-
return
|
|
57
|
+
return createFormFromModel<RegistrationForm>({ model, schema });
|
|
58
58
|
}, []);
|
|
59
59
|
|
|
60
60
|
return (
|
|
@@ -56,7 +56,7 @@ export const creditBehavior = defineFormBehavior<CreditForm>(({ model }) => {
|
|
|
56
56
|
- **Все поля должны быть материализованы в модели** (`createModel`), даже те, что показываются
|
|
57
57
|
только под условием. `enableWhen`/`compute`/`copyFrom` принимают сигналы `model.$.field`, а не
|
|
58
58
|
строковые пути; сигнала для несуществующего поля нет.
|
|
59
|
-
- Поведение подключается к форме через `
|
|
59
|
+
- Поведение подключается к форме через `createFormFromModel({ model, schema, behavior })` — форма владеет
|
|
60
60
|
жизненным циклом, DSL-операторы регистрируют свой cleanup сами (ручной массив cleanup'ов не нужен).
|
|
61
61
|
- Таргетом может быть и **группа**: `enableWhen(model.$.residenceAddress, () => model.sameAsRegistration === false)`
|
|
62
62
|
проходит по поддереву; без `resetOnDisable` значение группы сохраняется (удобно, когда оно копируется
|
|
@@ -76,6 +76,32 @@ const behavior = defineFormBehavior<MyForm>(({ model }) => {
|
|
|
76
76
|
});
|
|
77
77
|
```
|
|
78
78
|
|
|
79
|
+
### Вложенные области — массив в группе и в строке массива
|
|
80
|
+
|
|
81
|
+
Узел `{ array, item }` и операторы `applyEach` / `apply` / `enableWhen` работают на любой глубине:
|
|
82
|
+
цель оператора — **ручка** `model.$.…`, под-модель и нода находятся по её идентичности, а не по
|
|
83
|
+
пути.
|
|
84
|
+
|
|
85
|
+
```typescript
|
|
86
|
+
const phoneBehavior = defineFormBehavior<Phone>(({ model: phone }) => {
|
|
87
|
+
enableWhen(phone.$.number, () => phone.primary);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const contactBehavior = defineFormBehavior<Contact>(({ model: contact }) => {
|
|
91
|
+
applyEach(contact.$.phones, phoneBehavior); // массив внутри строки
|
|
92
|
+
enableWhen(contact.$.address, () => contact.quantity > 0); // группа внутри строки
|
|
93
|
+
apply(contact.$.address, addressBehavior); // под-схема получает настоящую под-модель
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const behavior = defineFormBehavior<MyForm>(({ model }) => {
|
|
97
|
+
applyEach(model.$.contacts, contactBehavior);
|
|
98
|
+
});
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Ручка → value-фасад: `modelOf(model.$.address)` — под-модель (`get` / `set` / `patch`),
|
|
102
|
+
`modelOf(model.$.phones)` — фасад массива (`push` / `removeAt` / `at`). Нода формы по ручке —
|
|
103
|
+
`getNodeForSignal(model.$.address)`.
|
|
104
|
+
|
|
79
105
|
### Aggregate write — aggregateInto
|
|
80
106
|
|
|
81
107
|
Агрегатная запись в строки (например, «последняя строка = 100 − Σ остальных»):
|
|
@@ -48,7 +48,7 @@ form.<field>.clearErrors();
|
|
|
48
48
|
|
|
49
49
|
```tsx
|
|
50
50
|
import { useMemo } from 'react';
|
|
51
|
-
import { createModel, createForm } from '@reformer/core';
|
|
51
|
+
import { createModel, createForm, createFormFromModel } from '@reformer/core';
|
|
52
52
|
import { defineValidationSchema, validate, validateModel } from '@reformer/core/validation';
|
|
53
53
|
import { required, email, minLength } from '@reformer/core/validators';
|
|
54
54
|
|
|
@@ -66,7 +66,7 @@ function RegistrationForm() {
|
|
|
66
66
|
const { model, form } = useMemo(() => {
|
|
67
67
|
const m = createModel<RegistrationFormData>({ username: '', email: '', password: '' });
|
|
68
68
|
// schema здесь — layout-дерево нод (компоненты/раскладка), без валидаторов.
|
|
69
|
-
return { model: m, form:
|
|
69
|
+
return { model: m, form: createFormFromModel({ model: m, schema: buildLayout(m) }) };
|
|
70
70
|
}, []);
|
|
71
71
|
|
|
72
72
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
@@ -198,7 +198,7 @@ const handleSubmit = async (e) => {
|
|
|
198
198
|
|
|
199
199
|
```typescript
|
|
200
200
|
// ❌ Валидаторы в layout-дереве, отдаваемом в createForm — layout не несёт правил
|
|
201
|
-
|
|
201
|
+
createFormFromModel({ model, schema: { username: { value: '', validators: [required()] } } });
|
|
202
202
|
|
|
203
203
|
// ✅ Валидация — отдельная схема, прогоняется раннером
|
|
204
204
|
const schema = defineValidationSchema<T>(({ model }) => {
|
|
@@ -30,7 +30,7 @@ Initial values задаются в `createModel(initial)`; `reset()` возвр
|
|
|
30
30
|
### Initial values в модели
|
|
31
31
|
|
|
32
32
|
```typescript
|
|
33
|
-
import { createModel,
|
|
33
|
+
import { createModel, createFormFromModel } from '@reformer/core';
|
|
34
34
|
import { Input, SelectAsync, CheckboxWithLabel } from '@reformer/ui-kit';
|
|
35
35
|
|
|
36
36
|
type ProfileForm = { username: string; language: 'ru' | 'en'; marketing: boolean };
|
|
@@ -51,7 +51,7 @@ const schema = {
|
|
|
51
51
|
},
|
|
52
52
|
marketing: { value: model.$.marketing, component: CheckboxWithLabel, componentProps: { label: 'Рассылка' } },
|
|
53
53
|
};
|
|
54
|
-
const form =
|
|
54
|
+
const form = createFormFromModel({ model, schema });
|
|
55
55
|
// model.get() === { username: '', language: 'ru', marketing: true }
|
|
56
56
|
// после правок: model.reset() возвращает к этим значениям
|
|
57
57
|
```
|
|
@@ -138,14 +138,14 @@ export const addressBehavior = defineFormBehavior<AddressForm>(({ model, form })
|
|
|
138
138
|
// ❌ model/form пересоздаются на каждый рендер → preload запускается каждый раз
|
|
139
139
|
function MyForm() {
|
|
140
140
|
const model = createModel<T>(initial); // КАЖДЫЙ рендер!
|
|
141
|
-
const form =
|
|
141
|
+
const form = createFormFromModel({ model, schema });
|
|
142
142
|
}
|
|
143
143
|
|
|
144
144
|
// ✅ Стабильные ссылки через useMemo
|
|
145
145
|
function MyForm() {
|
|
146
146
|
const { model, form } = useMemo(() => {
|
|
147
147
|
const m = createModel<T>(initial);
|
|
148
|
-
return { model: m, form:
|
|
148
|
+
return { model: m, form: createFormFromModel({ model: m, schema: buildSchema(m) }) };
|
|
149
149
|
}, []);
|
|
150
150
|
}
|
|
151
151
|
```
|
|
@@ -72,7 +72,7 @@ function createFormValidation<T>(
|
|
|
72
72
|
`markAsTouched` не нужен.
|
|
73
73
|
|
|
74
74
|
```tsx
|
|
75
|
-
import { createModel,
|
|
75
|
+
import { createModel, createFormFromModel, useFormValidation } from '@reformer/core';
|
|
76
76
|
import { defineValidationSchema, validate } from '@reformer/core/validation';
|
|
77
77
|
import { required, email, minLength } from '@reformer/core/validators';
|
|
78
78
|
|
|
@@ -87,7 +87,7 @@ const registrationValidation = defineValidationSchema<RegistrationData>(({ model
|
|
|
87
87
|
|
|
88
88
|
function RegistrationForm() {
|
|
89
89
|
const model = useMemo(() => createModel<RegistrationData>({ username: '', email: '', password: '' }), []);
|
|
90
|
-
const form = useMemo(() =>
|
|
90
|
+
const form = useMemo(() => createFormFromModel({ model }), [model]);
|
|
91
91
|
|
|
92
92
|
const { submit, isValidating } = useFormValidation({
|
|
93
93
|
model,
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
# i18n — локаль, загрузка словарей и провайдер
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
Подпуть `@reformer/core/i18n` — общий рантайм локализации для всех пакетов `@reformer/*`: объект
|
|
6
|
+
локали, загрузка словарей, React-провайдер, перевод и форматирование. Язык задаётся провайдером,
|
|
7
|
+
подгружается динамически и переключается на лету; узлы формы при этом не пересоздаются.
|
|
8
|
+
|
|
9
|
+
Без провайдера всё работает на английском: у каждого пакета есть встроенная английская таблица.
|
|
10
|
+
|
|
11
|
+
> Импорт всегда из `@reformer/core/i18n`. Из главной бочки `@reformer/core` этот модуль не
|
|
12
|
+
> реэкспортируется: React-контекст локализации обязан существовать в одном экземпляре.
|
|
13
|
+
|
|
14
|
+
## Локаль — это данные
|
|
15
|
+
|
|
16
|
+
```typescript
|
|
17
|
+
interface FormLocale {
|
|
18
|
+
code: string; // BCP 47: 'en', 'ru', 'pt-BR' — по нему работают Intl и plural
|
|
19
|
+
messages: Record<string, string>; // плоский словарь: ключ → сообщение ICU
|
|
20
|
+
weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
|
|
21
|
+
dateLocale?: unknown; // необязательно: объект локали react-day-picker / date-fns
|
|
22
|
+
dateFormat?: string; // необязательно: токены date-fns для DatePicker
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Функций в локали нет, поэтому её можно положить JSON-файлом на сервер и получить по сети:
|
|
27
|
+
|
|
28
|
+
```json
|
|
29
|
+
{ "code": "ru", "weekStartsOn": 1, "messages": { "profile.title": "Профиль" } }
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`extendLocale(base, patch)` возвращает новую локаль: словари сливаются (ключи из `patch` важнее),
|
|
33
|
+
остальные поля заменяются.
|
|
34
|
+
|
|
35
|
+
## Формат сообщений — подмножество ICU
|
|
36
|
+
|
|
37
|
+
| Конструкция | Пример |
|
|
38
|
+
| ------------------- | ------------------------------------------------------------------------- |
|
|
39
|
+
| подстановка | `Открыт файл {name}` |
|
|
40
|
+
| множественные формы | `{count, plural, one{# файл} few{# файла} many{# файлов} other{# файла}}` |
|
|
41
|
+
| точное значение | `{count, plural, =0{файлов нет} other{# файлов}}` |
|
|
42
|
+
| выбор | `{gender, select, male{он} female{она} other{оно}}` |
|
|
43
|
+
| экранирование | `'{'` → `{`, `'}'` → `}`, `''` → `'` |
|
|
44
|
+
|
|
45
|
+
- Ветка `other` обязательна. `#` внутри `plural` — число, отформатированное по языку.
|
|
46
|
+
- Типы `number`, `date`, `time`, `selectordinal` не поддерживаются: числа и даты форматируйте
|
|
47
|
+
отдельно (`useI18n().number`, `.date`) и передавайте готовой строкой.
|
|
48
|
+
- Категории (`one`/`few`/`many`) берутся из `Intl.PluralRules` языка локали.
|
|
49
|
+
|
|
50
|
+
## Загрузка локалей
|
|
51
|
+
|
|
52
|
+
Откуда брать словарь, решает приложение: источник — функция `(code) => локаль | словарь`.
|
|
53
|
+
|
|
54
|
+
```typescript
|
|
55
|
+
import { createLocaleLoader, fetchMessages } from '@reformer/core/i18n';
|
|
56
|
+
import { loadCoreLocale } from '@reformer/core/locale';
|
|
57
|
+
|
|
58
|
+
export const loadLocale = createLocaleLoader([
|
|
59
|
+
loadCoreLocale, // встроенные словари ядра — чанк нужного языка
|
|
60
|
+
fetchMessages((code) => `/locales/${code}.json`), // ключи приложения и правки — с сервера
|
|
61
|
+
]);
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
- Источники опрашиваются параллельно, сливаются по порядку: поздний перекрывает ранний.
|
|
65
|
+
- Источник отдаёт локаль целиком (`{ code, messages, … }`) или голый словарь (`{ "a.b": "…" }`);
|
|
66
|
+
`null` означает «для этого языка у меня ничего нет» и ошибкой не считается.
|
|
67
|
+
- Результат кэшируется по коду языка, идущий запрос не дублируется.
|
|
68
|
+
- Отказ любого источника — отказ загрузки. Источник, которому можно промахнуться, оборачивайте:
|
|
69
|
+
`(code) => source(code).catch(() => null)`.
|
|
70
|
+
- `loadLocale.peek(code)` — уже загруженная локаль или `undefined`; `loadLocale.preload(code)` — то
|
|
71
|
+
же, что вызов загрузчика.
|
|
72
|
+
|
|
73
|
+
Ядро отдаёт свои локали в трёх видах:
|
|
74
|
+
|
|
75
|
+
| Вид | Импорт | Для чего |
|
|
76
|
+
| ----------------- | ------------------------------------------- | ----------------------------------------------- |
|
|
77
|
+
| загрузчик | `loadCoreLocale` из `@reformer/core/locale` | основной путь: чанк языка по запросу |
|
|
78
|
+
| JSON-файл | `@reformer/core/locale/ru.json` | выложить на свой сервер, править без пересборки |
|
|
79
|
+
| синхронный модуль | `ru` из `@reformer/core/locale/ru` | SSR и тесты |
|
|
80
|
+
|
|
81
|
+
Встроенные языки ядра — `en` и `ru`. Словарь ядра несёт тексты ошибок валидации
|
|
82
|
+
(`validation.<code>`) и единицы размера файла (`format.fileSize.b|kb|mb|gb|tb`).
|
|
83
|
+
|
|
84
|
+
Пакеты выше отдают локаль накопительно и в тех же трёх видах, поэтому источник встроенных словарей
|
|
85
|
+
в загрузчике всегда один — самого верхнего из используемых пакетов:
|
|
86
|
+
|
|
87
|
+
| Пакеты приложения | Источник | Что в локали |
|
|
88
|
+
| ------------------------------- | -------------------------------------------- | --------------------------------- |
|
|
89
|
+
| только ядро | `loadCoreLocale` из `@reformer/core/locale` | ошибки валидации, размер файла |
|
|
90
|
+
| ядро + `@reformer/cdk` | `loadCdkLocale` из `@reformer/cdk/locale` | то же плюс строки cdk (`cdk.*`) |
|
|
91
|
+
| ядро + cdk + `@reformer/ui-kit` | `loadKitLocale` из `@reformer/ui-kit/locale` | то же плюс подписи кита (`kit.*`) |
|
|
92
|
+
|
|
93
|
+
## Провайдер
|
|
94
|
+
|
|
95
|
+
```tsx
|
|
96
|
+
import { I18nProvider } from '@reformer/core/i18n';
|
|
97
|
+
|
|
98
|
+
function App() {
|
|
99
|
+
const [lang, setLang] = useState('ru');
|
|
100
|
+
return (
|
|
101
|
+
<I18nProvider lang={lang} load={loadLocale}>
|
|
102
|
+
<LangSwitch value={lang} onChange={setLang} />
|
|
103
|
+
<CreditForm />
|
|
104
|
+
</I18nProvider>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
| Проп | Назначение |
|
|
110
|
+
| --------------- | ------------------------------------------------------------------------ |
|
|
111
|
+
| `lang` + `load` | провайдер грузит локаль сам |
|
|
112
|
+
| `fallback` | что показать, пока не загружена самая первая локаль; по умолчанию ничего |
|
|
113
|
+
| `onError` | отказ загрузки `(error, lang)` |
|
|
114
|
+
| `locale` | готовый объект вместо `lang` + `load` — SSR, тесты |
|
|
115
|
+
|
|
116
|
+
Поведение при `lang` + `load`:
|
|
117
|
+
|
|
118
|
+
- при смене `lang` на экране остаётся прежний язык, пока новый не загрузится;
|
|
119
|
+
`useI18n().pending` — для индикатора;
|
|
120
|
+
- при быстром переключении применяется только последний запрос;
|
|
121
|
+
- ошибка загрузки язык не меняет и попадает в `useI18n().error` и `onError`;
|
|
122
|
+
- если локаль уже в кэше (`await loadLocale.preload(lang)` до монтирования), первый рендер
|
|
123
|
+
синхронный и `fallback` не показывается.
|
|
124
|
+
|
|
125
|
+
SSR: эффекты на сервере не выполняются, поэтому там передавайте готовый объект —
|
|
126
|
+
`<I18nProvider locale={ru}>` — либо заранее вызовите `preload`.
|
|
127
|
+
|
|
128
|
+
Вложенный провайдер заменяет локаль для своего поддерева целиком.
|
|
129
|
+
|
|
130
|
+
## Хуки
|
|
131
|
+
|
|
132
|
+
```typescript
|
|
133
|
+
const { code, locale, pending, error, t, has, number, date, fileSize } = useI18n();
|
|
134
|
+
|
|
135
|
+
t('profile.title'); // ключ приложения
|
|
136
|
+
t('cart.items', { count: 3 }); // с подстановками
|
|
137
|
+
t('cart.empty', undefined, 'Your cart is empty'); // английский текст на случай промаха
|
|
138
|
+
number(1234.5); // '1 234,5' в ru
|
|
139
|
+
date(new Date(), { dateStyle: 'long' }); // '15 января 2024 г.' в ru
|
|
140
|
+
fileSize(1536); // '1,5 КБ' в ru, '1.5 KB' без провайдера
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`useMessages(builtinEn)` — для авторов пакетов и китов: ищет ключ в локали провайдера, а при
|
|
144
|
+
промахе берёт встроенную английскую таблицу пакета. Тип ключа выводится из таблицы.
|
|
145
|
+
|
|
146
|
+
```tsx
|
|
147
|
+
import en from './locale/en.json';
|
|
148
|
+
|
|
149
|
+
function ClearButton() {
|
|
150
|
+
const t = useMessages(en);
|
|
151
|
+
return <button aria-label={t('kit.select.clear')} />;
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## Тексты ошибок валидации
|
|
156
|
+
|
|
157
|
+
Правила из `@reformer/core/validators` несут только `code` и `params`; готового текста у них нет
|
|
158
|
+
(`message` по умолчанию — пустая строка). Строку даёт резолвер при отображении ошибки:
|
|
159
|
+
|
|
160
|
+
1. `messageKey` автора правила — если такой ключ есть в активной локали;
|
|
161
|
+
2. явное `message` автора правила;
|
|
162
|
+
3. `validation.<code>` из словаря локали;
|
|
163
|
+
4. `validation.<code>` из встроенной английской таблицы ядра;
|
|
164
|
+
5. сам код — для своих кодов, которых нет ни в одном словаре.
|
|
165
|
+
|
|
166
|
+
```typescript
|
|
167
|
+
validate(model.$.email, [
|
|
168
|
+
required(), // «Обязательное поле» в ru, английская фраза без провайдера
|
|
169
|
+
minLength(5, { message: 'Слишком коротко' }), // явный текст: важнее словаря, но языком не переключается
|
|
170
|
+
email({ messageKey: 'profile.email.invalid' }), // ключ словаря приложения: переключается вместе с языком
|
|
171
|
+
]);
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
- Текст получается при отображении, а не при проверке: смена языка переводит уже показанные
|
|
175
|
+
ошибки без повторной валидации.
|
|
176
|
+
- `FormField` (`@reformer/cdk`, `@reformer/ui-kit`) резолвит ошибку сам. В своём компоненте —
|
|
177
|
+
`useValidationMessage()`; вне React — `resolveValidationError(error, createI18n(locale))`.
|
|
178
|
+
- Свой код ошибки локализуется ключом `validation.<свой_код>` в словаре приложения либо
|
|
179
|
+
`messageKey` в самом правиле.
|
|
180
|
+
- Встроенный текст меняется тем же ключом в словаре приложения:
|
|
181
|
+
`extendLocale(ru, { messages: { 'validation.required': 'Заполните поле' } })`.
|
|
182
|
+
- Параметры готовятся до подстановки: `Date` — по языку (`15 янв. 2030 г.`), массив — перечнем,
|
|
183
|
+
размеры в байтах (`maxFileSize`, `minFileSize`, `actualSize`, `maxTotalFileSize`, `actualTotal`) —
|
|
184
|
+
строкой с единицей (`5 МБ`). Числа остаются числами, поэтому в сообщении работает `plural`.
|
|
185
|
+
|
|
186
|
+
```tsx
|
|
187
|
+
function FieldErrors({ control }: { control: FieldNode<string> }) {
|
|
188
|
+
const message = useValidationMessage();
|
|
189
|
+
const { errors, shouldShowError } = useFormControl(control);
|
|
190
|
+
return shouldShowError ? <span role="alert">{message(errors[0])}</span> : null;
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Смонтированный `ValidationMessagesProvider` из `@reformer/cdk` заменяет этот резолвер целиком:
|
|
195
|
+
локаль для ошибок в его поддереве не участвует.
|
|
196
|
+
|
|
197
|
+
## Что показывается, когда ключа нет
|
|
198
|
+
|
|
199
|
+
| Ключ | Нет в локали | Сообщение не разбирается |
|
|
200
|
+
| -------------------------- | --------------------------------------------------------------- | ----------------------------------------------------- |
|
|
201
|
+
| пакета (`useMessages`) | встроенный английский пакета | встроенный английский, `console.error` вне production |
|
|
202
|
+
| приложения (`useI18n().t`) | `defaultMessage`, затем сам ключ; `console.warn` вне production | то же, `console.error` |
|
|
203
|
+
|
|
204
|
+
Пропущенная подстановка выводится как `{name}`. Форма не падает ни в одном из случаев: неполный
|
|
205
|
+
словарь, пришедший по сети, — законный сценарий.
|
|
206
|
+
|
|
207
|
+
## Проверка словаря в тестах
|
|
208
|
+
|
|
209
|
+
```typescript
|
|
210
|
+
import { validateLocale } from '@reformer/core/i18n';
|
|
211
|
+
|
|
212
|
+
expect(validateLocale(ruJson, enJson)).toEqual([]);
|
|
213
|
+
// находит: syntax (не разбирается), missing / extra (ключи), arguments (потерянная подстановка)
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
## Описатели сообщений (низкий уровень)
|
|
217
|
+
|
|
218
|
+
`msg(key, values?, defaultMessage?)` создаёт описатель сообщения, `defineMessages(appEn)` — `msg` с
|
|
219
|
+
типизированными ключами, `resolveLocalized(value, i18n)` раскрывает описатели в объекте пропсов.
|
|
220
|
+
|
|
221
|
+
> ⚠️ Рендереры и `FormField` описатели сами пока НЕ раскрывают. Не кладите `msg(...)` в
|
|
222
|
+
> `componentProps` схемы: компонент получит объект вместо строки. До появления раскрытия в
|
|
223
|
+
> рендерерах вызывайте `resolveLocalized` вручную или используйте `useI18n().t`.
|
|
224
|
+
|
|
225
|
+
## Anti-patterns
|
|
226
|
+
|
|
227
|
+
```typescript
|
|
228
|
+
// ❌ Импорт из главной бочки — i18n там нет, а второй путь импорта дал бы вторую копию контекста
|
|
229
|
+
import { I18nProvider } from '@reformer/core';
|
|
230
|
+
|
|
231
|
+
// ✅ Единственный путь импорта — подпуть i18n
|
|
232
|
+
import { I18nProvider } from '@reformer/core/i18n';
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
```tsx
|
|
236
|
+
// ❌ Новый загрузчик на каждый рендер: кэш теряется, локаль грузится заново, первый кадр — fallback
|
|
237
|
+
<I18nProvider lang={lang} load={createLocaleLoader([loadCoreLocale])}>
|
|
238
|
+
|
|
239
|
+
// ✅ Загрузчик — константа модуля: один кэш на приложение
|
|
240
|
+
const loadLocale = createLocaleLoader([loadCoreLocale]);
|
|
241
|
+
<I18nProvider lang={lang} load={loadLocale}>
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
```typescript
|
|
245
|
+
// ❌ Число или дата типами ICU внутри сообщения — не поддерживается, сообщение не разберётся
|
|
246
|
+
const message = 'Итого: {total, number}';
|
|
247
|
+
|
|
248
|
+
// ✅ Отформатировать отдельно и передать готовой строкой
|
|
249
|
+
t('cart.total', { total: number(total, { style: 'currency', currency: 'RUB' }) });
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
```tsx
|
|
253
|
+
// ❌ Ошибка выводится полем message: у правил без своего текста оно пустое — на экране ничего
|
|
254
|
+
<span>{errors[0].message}</span>;
|
|
255
|
+
|
|
256
|
+
// ✅ Текст ошибки — через резолвер: он знает словарь локали, messageKey и явное сообщение
|
|
257
|
+
const message = useValidationMessage();
|
|
258
|
+
<span>{message(errors[0])}</span>;
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
```typescript
|
|
262
|
+
// ❌ Настройки локали без словаря: объект без поля messages принимается за словарь сообщений
|
|
263
|
+
const source = () => ({ weekStartsOn: 1 });
|
|
264
|
+
|
|
265
|
+
// ✅ Часть локали узнаётся по полю messages — отдавайте его хотя бы пустым
|
|
266
|
+
const source = () => ({ weekStartsOn: 1, messages: {} });
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
## See also
|
|
270
|
+
|
|
271
|
+
- [28-submit-and-reset.md](./28-submit-and-reset.md) — жизненный цикл формы; смена языка его не затрагивает
|
|
272
|
+
- [33-validation-strategy.md](./33-validation-strategy.md) — когда запускается валидация
|