@reformer/core 10.2.0 → 11.0.0-beta.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.
- package/dist/behaviors.js +134 -134
- package/dist/form/form-submitter.d.ts +0 -4
- package/dist/form/hooks/types.d.ts +3 -3
- package/dist/form/index.d.ts +0 -3
- package/dist/form/nodes/array-node.d.ts +0 -84
- package/dist/form/nodes/form-node.d.ts +0 -14
- package/dist/form/nodes/group-node.d.ts +0 -4
- package/dist/form/status-machine.d.ts +0 -38
- package/dist/form/types/contracts.d.ts +0 -6
- package/dist/form/types/deep-schema.d.ts +0 -9
- package/dist/form/types/index.d.ts +4 -38
- package/dist/form/types/schema-node.d.ts +13 -16
- package/dist/form/types/validation-schema.d.ts +0 -25
- package/dist/form/unique-id.d.ts +3 -11
- package/dist/form/validators.d.ts +8 -3
- package/dist/form-model-DhW2HZOB.js +323 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +448 -636
- package/dist/runtime-token.d.ts +23 -0
- package/dist/state/form-model.d.ts +14 -0
- package/dist/state/index.d.ts +2 -1
- package/dist/state/types.d.ts +39 -11
- package/dist/state.js +18 -18
- package/dist/validation.js +1 -1
- package/dist/validators.d.ts +0 -4
- package/dist/validators.js +88 -122
- package/docs/llms/01-api-reference.md +63 -0
- package/docs/llms/02-quick-start.md +143 -0
- package/docs/llms/03-api-signatures.md +273 -0
- package/docs/llms/04-common-patterns.md +202 -0
- package/docs/llms/05-common-mistakes.md +167 -0
- package/docs/llms/06-troubleshooting.md +11 -0
- package/docs/llms/07-complete-import.md +99 -0
- package/docs/llms/08-form-types.md +47 -0
- package/docs/llms/09-formschema.md +143 -0
- package/docs/llms/10-arrays.md +161 -0
- package/docs/llms/11-async-watchfield.md +53 -0
- package/docs/llms/12-array-cleanup.md +31 -0
- package/docs/llms/13-multi-step.md +91 -0
- package/docs/llms/14-extended-mistakes.md +98 -0
- package/docs/llms/15-project-structure.md +125 -0
- package/docs/llms/16-ui-components.md +189 -0
- package/docs/llms/17-nonexistent-api.md +93 -0
- package/docs/llms/18-conditional-fields.md +183 -0
- package/docs/llms/19-reading-values.md +88 -0
- package/docs/llms/20-compute-vs-watch.md +147 -0
- package/docs/llms/21-array-operations.md +113 -0
- package/docs/llms/22-cycle-detection.md +86 -0
- package/docs/llms/23-copy-from.md +141 -0
- package/docs/llms/24-sync-fields.md +114 -0
- package/docs/llms/25-reset-when.md +115 -0
- package/docs/llms/26-transform-value.md +136 -0
- package/docs/llms/27-revalidate-when.md +132 -0
- package/docs/llms/28-submit-and-reset.md +259 -0
- package/docs/llms/29-async-preload.md +193 -0
- package/docs/llms/30-type-safety-recipes.md +159 -0
- package/docs/llms/31-async-validator-debounce.md +135 -0
- package/docs/llms/32-async-options-loading.md +108 -0
- package/docs/llms/33-validation-strategy.md +193 -0
- package/llms.txt +219 -1533
- package/package.json +33 -10
- package/dist/derived-registry-CluZCQ7U.js +0 -16
- package/dist/form-model-DYCzGGKF.js +0 -286
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
## 10. ASYNC REACTION — onChange (CRITICALLY IMPORTANT)
|
|
2
|
+
|
|
3
|
+
Для async-реакции на изменение поля (загрузка зависимых опций, справочников) используй
|
|
4
|
+
`onChange` из `@reformer/core/behaviors`. Колбэк выполняется ВНЕ effect-контекста (можно
|
|
5
|
+
безопасно писать сигналы/ноды), а 2-й аргумент — `{ signal }` (AbortSignal): при следующей
|
|
6
|
+
смене значения предыдущий вызов аннулируется — передавай `signal` в `fetch`.
|
|
7
|
+
|
|
8
|
+
```typescript
|
|
9
|
+
import { defineFormBehavior, onChange } from '@reformer/core/behaviors';
|
|
10
|
+
|
|
11
|
+
const behavior = defineFormBehavior<MyForm>(({ model, form }) => {
|
|
12
|
+
// CORRECT — async onChange со всеми safeguards
|
|
13
|
+
onChange(
|
|
14
|
+
model.$.parentField,
|
|
15
|
+
async (value, { signal }) => {
|
|
16
|
+
if (!value) {
|
|
17
|
+
form.dependentField.updateComponentProps({ options: [] });
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
try {
|
|
21
|
+
const data = await fetchData(value, { signal }); // отмена устаревших запросов
|
|
22
|
+
form.dependentField.updateComponentProps({ options: data });
|
|
23
|
+
} catch (error) {
|
|
24
|
+
if ((error as Error).name === 'AbortError') return;
|
|
25
|
+
form.dependentField.updateComponentProps({ options: [] });
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
{ debounce: 300 } // не fetch на каждое нажатие
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### Опции onChange
|
|
34
|
+
|
|
35
|
+
- `debounce: 300` — не дёргать сеть на каждый keystroke (300–500 мс рекомендуется).
|
|
36
|
+
- `immediate: true` — вызвать колбэк сразу при регистрации (по умолчанию `false`).
|
|
37
|
+
- Guard-clause — пропустить пустое значение.
|
|
38
|
+
- try/catch + проверка `AbortError` — обработка отмены и ошибок.
|
|
39
|
+
|
|
40
|
+
### Низкоуровневый примитив watchField
|
|
41
|
+
|
|
42
|
+
`watchField` из `@reformer/core` — базовая подписка на изменение сигнала (без debounce и
|
|
43
|
+
AbortSignal). `onChange` построен поверх него. Для простых синхронных реакций:
|
|
44
|
+
|
|
45
|
+
```typescript
|
|
46
|
+
import { watchField } from '@reformer/core';
|
|
47
|
+
|
|
48
|
+
// вызывается при каждом изменении (по умолчанию НЕ на инициализации)
|
|
49
|
+
const stop = watchField(model.$.country, (country) => {
|
|
50
|
+
model.city = ''; // сброс зависимого поля
|
|
51
|
+
});
|
|
52
|
+
// stop() — отписаться
|
|
53
|
+
```
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
## 11. ARRAY CLEANUP PATTERN
|
|
2
|
+
|
|
3
|
+
Очистка массива при выключении флага — через `onChange` на сигнале флага + `clear()` на
|
|
4
|
+
модели-массиве. Колбит выполняется вне effect-контекста, поэтому мутировать массив безопасно.
|
|
5
|
+
|
|
6
|
+
```typescript
|
|
7
|
+
import { defineFormBehavior, onChange } from '@reformer/core/behaviors';
|
|
8
|
+
|
|
9
|
+
const behavior = defineFormBehavior<MyForm>(({ model }) => {
|
|
10
|
+
// при снятии флага — очистить массив
|
|
11
|
+
onChange(model.$.hasItems, (hasItems) => {
|
|
12
|
+
if (!hasItems) model.items.clear();
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Переиспользуемый оператор (как в монорепо):
|
|
18
|
+
|
|
19
|
+
```typescript
|
|
20
|
+
import { onChange } from '@reformer/core/behaviors';
|
|
21
|
+
import type { ReadonlySignal } from '@reformer/core/behaviors';
|
|
22
|
+
|
|
23
|
+
function clearWhenOff(flag: ReadonlySignal<boolean>, array: { clear(): void }): void {
|
|
24
|
+
onChange(flag, (on) => {
|
|
25
|
+
if (!on) array.clear();
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// в схеме поведения:
|
|
30
|
+
clearWhenOff(model.$.hasProperty, model.properties);
|
|
31
|
+
```
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
## 12. MULTI-STEP FORM VALIDATION
|
|
2
|
+
|
|
3
|
+
Каждый шаг — своя `ValidationSchema<Form>` (обычная функция `({ model }) => void`, обёрнутая
|
|
4
|
+
`defineValidationSchema`). Переход к следующему шагу проверяется `validateModel(model, stepSchema)`;
|
|
5
|
+
полный submit — по общей схеме (композиция шагов через `apply(...)`). `validateModel` сам разносит
|
|
6
|
+
ошибки по нодам формы (`getNodeForSignal(sig).setErrors(...)`), поэтому UI подсветит проблемные поля
|
|
7
|
+
текущего шага автоматически. Валидация живёт ОТДЕЛЬНО от layout: сам `RenderNode`/JSON-узел валидаторов
|
|
8
|
+
не несёт — схема инъектируется в рантайме и роутит ошибки по тем же нодам.
|
|
9
|
+
|
|
10
|
+
```typescript
|
|
11
|
+
import { type FormModel } from '@reformer/core';
|
|
12
|
+
import {
|
|
13
|
+
validate,
|
|
14
|
+
apply,
|
|
15
|
+
defineValidationSchema,
|
|
16
|
+
validateModel,
|
|
17
|
+
type ValidationSchema,
|
|
18
|
+
} from '@reformer/core/validation';
|
|
19
|
+
import { required, min } from '@reformer/core/validators';
|
|
20
|
+
|
|
21
|
+
// Под-схема шага — обычная функция ({ model }) => void. Значения проверяет оператор validate(sig, [rules]).
|
|
22
|
+
const step1Schema = defineValidationSchema<Form>(({ model }) => {
|
|
23
|
+
validate(model.$.loanType, [required()]);
|
|
24
|
+
validate(model.$.loanAmount, [required(), min(50000)]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const step2Schema = defineValidationSchema<Form>(({ model }) => {
|
|
28
|
+
validate(model.$.personalData.firstName, [required()]);
|
|
29
|
+
validate(model.$.personalData.lastName, [required()]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Карта шагов + полная схема (композиция под-схем через apply — заменяет пошаговую группировку деревом)
|
|
33
|
+
const STEP_SCHEMAS: readonly ValidationSchema<Form>[] = [step1Schema, step2Schema];
|
|
34
|
+
const fullSchema = defineValidationSchema<Form>(() => apply(...STEP_SCHEMAS));
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
```typescript
|
|
38
|
+
// Переход к следующему шагу. validateModel возвращает Promise<boolean> (true = нет блокирующих ошибок;
|
|
39
|
+
// severity:'warning' не блокирует). Устаревшие прогоны той же (model, schema) отменяются автоматически.
|
|
40
|
+
const goToNextStep = async () => {
|
|
41
|
+
const ok = await validateModel(model, STEP_SCHEMAS[currentStep - 1]);
|
|
42
|
+
if (!ok) return; // ошибки уже проставлены в ноды текущего шага
|
|
43
|
+
setCurrentStep(currentStep + 1);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// Полный submit — по общей схеме
|
|
47
|
+
const handleSubmit = async () => {
|
|
48
|
+
const ok = await validateModel(model, fullSchema);
|
|
49
|
+
if (ok) {
|
|
50
|
+
await onSubmit(model.get());
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Слой-потребитель (`FormWizard`) обычно оборачивает это в конфиг с per-step и полной валидацией:
|
|
56
|
+
|
|
57
|
+
```typescript
|
|
58
|
+
function makeValidationConfig(model: FormModel<Form>) {
|
|
59
|
+
return {
|
|
60
|
+
validateStep: (n: number): Promise<boolean> => validateModel(model, STEP_SCHEMAS[n - 1]),
|
|
61
|
+
validateAll: (): Promise<boolean> => validateModel(model, fullSchema),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Multi-Step Component Example
|
|
67
|
+
|
|
68
|
+
```tsx
|
|
69
|
+
function MultiStepForm() {
|
|
70
|
+
const [step, setStep] = useState(1);
|
|
71
|
+
|
|
72
|
+
const nextStep = async () => {
|
|
73
|
+
const ok = await validateModel(model, STEP_SCHEMAS[step - 1]);
|
|
74
|
+
if (ok) setStep(step + 1);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<div>
|
|
79
|
+
{step === 1 && <Step1Fields form={form} />}
|
|
80
|
+
{step === 2 && <Step2Fields form={form} />}
|
|
81
|
+
|
|
82
|
+
<button onClick={() => setStep(step - 1)} disabled={step === 1}>
|
|
83
|
+
Back
|
|
84
|
+
</button>
|
|
85
|
+
<button onClick={step === 2 ? handleSubmit : nextStep}>
|
|
86
|
+
{step === 2 ? 'Submit' : 'Next'}
|
|
87
|
+
</button>
|
|
88
|
+
</div>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
```
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
## 13. EXTENDED COMMON MISTAKES
|
|
2
|
+
|
|
3
|
+
### Reuse via apply / applyEach — не дублируй код
|
|
4
|
+
|
|
5
|
+
```typescript
|
|
6
|
+
import { defineFormBehavior, apply, applyEach, compute } from '@reformer/core/behaviors';
|
|
7
|
+
|
|
8
|
+
// ✅ apply — одна под-схема на несколько групп
|
|
9
|
+
const addressBehavior = defineFormBehavior<Address>(({ model }) => {
|
|
10
|
+
compute(model.$.full, () => `${model.city}, ${model.street}`);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const behavior = defineFormBehavior<Form>(({ model }) => {
|
|
14
|
+
apply([model.$.homeAddress, model.$.workAddress], addressBehavior);
|
|
15
|
+
|
|
16
|
+
// ✅ applyEach — под-схема на КАЖДЫЙ элемент массива (реагирует на add/remove)
|
|
17
|
+
applyEach(
|
|
18
|
+
model.$.items,
|
|
19
|
+
defineFormBehavior<Item>(({ model: row }) => {
|
|
20
|
+
compute(row.$.lineTotal, () => row.qty * row.price);
|
|
21
|
+
})
|
|
22
|
+
);
|
|
23
|
+
});
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### Идемпотентность transformValue
|
|
27
|
+
|
|
28
|
+
```typescript
|
|
29
|
+
// ❌ неидемпотентный transformer — бесконечный цикл setValue → callback → setValue
|
|
30
|
+
transformValue(model.$.field, (v) => `prefix-${v}`); // f(f(x)) ≠ f(x)
|
|
31
|
+
|
|
32
|
+
// ✅ guard «уже преобразовано»
|
|
33
|
+
transformValue(model.$.field, (v) => (v?.startsWith('prefix-') ? v : `prefix-${v}`));
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### compute для производных полей вместо ручной синхронизации
|
|
37
|
+
|
|
38
|
+
```typescript
|
|
39
|
+
// ❌ ручной onChange + запись — легко зациклить/забыть кейс
|
|
40
|
+
onChange(model.$.firstName, () => { model.fullName = `${model.firstName} ${model.lastName}`; });
|
|
41
|
+
|
|
42
|
+
// ✅ compute: цель не входит в источники → цикла нет, запись идемпотентна (peek-guard)
|
|
43
|
+
compute(model.$.fullName, () => [model.firstName, model.lastName].filter(Boolean).join(' '));
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Расходящийся цикл compute (Cycle detected)
|
|
47
|
+
|
|
48
|
+
Взаимные `compute`/`computeFrom` без стабилизации preact обрывает как «Cycle detected».
|
|
49
|
+
DSL перехватывает это и бросает понятную ошибку с именем поля. Решение — разорвать цикл
|
|
50
|
+
условием `when` или читать одну сторону через `peek()`:
|
|
51
|
+
|
|
52
|
+
```typescript
|
|
53
|
+
// ❌ взаимный пересчёт без стабилизации
|
|
54
|
+
compute(model.$.a, () => model.b + 1);
|
|
55
|
+
compute(model.$.b, () => model.a + 1); // расходится → Cycle detected
|
|
56
|
+
|
|
57
|
+
// ✅ добавь стабилизирующее условие или однонаправленную зависимость
|
|
58
|
+
compute(model.$.total, () => model.price * model.qty); // одно направление
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Cross-field валидация — через `cross(sig, fn)`
|
|
62
|
+
|
|
63
|
+
Cross-field живёт в схеме валидации (`@reformer/core/validation`), а не в layout: правило вешается на
|
|
64
|
+
ПОЛЕ-НОСИТЕЛЬ ошибки оператором `cross(sig, fn)`, а `fn` читает снапшот текущего scope (`model.get()`).
|
|
65
|
+
|
|
66
|
+
```typescript
|
|
67
|
+
import { defineValidationSchema, validate, cross, validateModel } from '@reformer/core/validation';
|
|
68
|
+
import { required } from '@reformer/core/validators';
|
|
69
|
+
import { revalidateWhen, type ValidationError } from '@reformer/core';
|
|
70
|
+
|
|
71
|
+
// ❌ старое (УДАЛЕНО): дерево { value, validators }, ModelValidator (value, scope, root), validateFormModel
|
|
72
|
+
const legacyRule: ModelValidator<number, unknown, Form> = (_value, _scope, root) =>
|
|
73
|
+
root.field1 > root.field2 ? { code: 'error', message: 'Invalid' } : null;
|
|
74
|
+
const legacySchema = { field1: { value: model.$.field1, component: Input, validators: [legacyRule] } };
|
|
75
|
+
validateFormModel(model, legacySchema);
|
|
76
|
+
|
|
77
|
+
// ✅ новое: cross-правило — обычная функция над снапшотом (model.get()), не читает scope/root
|
|
78
|
+
const field1LessThanField2 = (f: Form): ValidationError | null =>
|
|
79
|
+
f.field1 > f.field2 ? { code: 'error', message: 'Invalid' } : null;
|
|
80
|
+
|
|
81
|
+
const schema = defineValidationSchema<Form>(({ model }) => {
|
|
82
|
+
validate(model.$.field1, [required()]);
|
|
83
|
+
cross(model.$.field1, field1LessThanField2); // правило на поле-носителе ошибки
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// Прогон — ТОЛЬКО внешним раннером; ошибки сами доезжают до нод формы.
|
|
87
|
+
// ⚠️ form.validate()/submit() схему валидации больше НЕ прогоняют.
|
|
88
|
+
await validateModel(model, schema);
|
|
89
|
+
|
|
90
|
+
// Перезапуск правила при изменении соседнего поля — мост «поведение → валидация»:
|
|
91
|
+
revalidateWhen([model.$.field2], () => void validateModel(model, schema));
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
> **Актуальный vs удалённый API.** Операторы валидации `validate`/`validateAsync`/`validateWhen`/`cross`/`each`/`apply`
|
|
95
|
+
> ТЕПЕРЬ существуют — в `@reformer/core/validation` с новыми сигнатурами (ambient, активны только внутри прогона
|
|
96
|
+
> `validateModel`). УДАЛЕНЫ: `applyWhen`, `validateGroup`/`validateTree`/`validateForm`/`validateFormModel`,
|
|
97
|
+
> типы `FieldPath`/`ValidationSchemaFn`/`BehaviorSchemaFn` и `ctx.form.*`/`ctx.setFieldValue` (наследие
|
|
98
|
+
> path-based архитектуры). См. `17-nonexistent-api.md`.
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
## 14. PROJECT STRUCTURE (COLOCATION)
|
|
2
|
+
|
|
3
|
+
One form is one module folder. **Default layout — flat minimalist:** every concern is a single
|
|
4
|
+
file at the module root, and the whole form (entry + all wizard steps inline) lives in one
|
|
5
|
+
`index.tsx`. Filenames are flat with no prefix — **the dot-prefix (`form.` / `renderer.`) is
|
|
6
|
+
carried only by `schema` and `behavior`**, the two concerns that have both a model-layer and a
|
|
7
|
+
render-layer version (`form.schema.ts` / `form.behavior.ts` here; renderer slices add
|
|
8
|
+
`renderer.schema.*` / `renderer.behavior.ts`). No `lib/` / `schema/` / `components/steps/`
|
|
9
|
+
nesting until the form grows (see "Scaling up" below). Arrays are declared in `form.schema.ts`
|
|
10
|
+
and rendered with `FormArraySection` — no per-step component files.
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
src/
|
|
14
|
+
├── components/ui/ # App-wide reusable UI (FormField, FormArraySection, ...)
|
|
15
|
+
│
|
|
16
|
+
├── forms/
|
|
17
|
+
│ └── [form-name]/ # Form module — flat, one file per concern
|
|
18
|
+
│ ├── index.tsx # entry + whole form: createModel→createForm→<FormWizard> with all steps inline; arrays via FormArraySection
|
|
19
|
+
│ ├── types.ts # form type + enums + { value, label } option type + constant dictionaries
|
|
20
|
+
│ ├── model.ts # createModel + initial values + empty-array-element factories
|
|
21
|
+
│ ├── form.schema.ts # FormSchema: { value: model.$.x, component, componentProps }
|
|
22
|
+
│ ├── form.behavior.ts # defineFormBehavior: compute / enableWhen / hideWhen / copyFrom / onChange
|
|
23
|
+
│ ├── validation.ts # ALL validation → { validateStep, validateAll }
|
|
24
|
+
│ ├── data-sources.ts # options + async loaders (dataSources)
|
|
25
|
+
│ └── api.ts # submit + prefill/load
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Rule of thumb: **one concern → one file at the module root; the whole component tree (all steps) →
|
|
29
|
+
`index.tsx`; validation is a single `validation.ts`.** This is the working default for
|
|
30
|
+
almost every form — reach for folders only when a file stops fitting on a screen.
|
|
31
|
+
|
|
32
|
+
### Key Files
|
|
33
|
+
|
|
34
|
+
```typescript
|
|
35
|
+
// forms/credit-application/types.ts
|
|
36
|
+
export type CreditApplicationForm = {
|
|
37
|
+
loanType: LoanType;
|
|
38
|
+
loanAmount: number | null;
|
|
39
|
+
// ...
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// forms/credit-application/model.ts
|
|
43
|
+
import { createModel, type FormModel } from '@reformer/core';
|
|
44
|
+
export const createCreditApplicationModel = (): FormModel<CreditApplicationForm> =>
|
|
45
|
+
createModel<CreditApplicationForm>(createInitialCreditApplication());
|
|
46
|
+
|
|
47
|
+
// forms/credit-application/form.schema.ts
|
|
48
|
+
import type { FormModel } from '@reformer/core';
|
|
49
|
+
export const creditApplicationSchema = (model: FormModel<CreditApplicationForm>) => ({
|
|
50
|
+
loanType: { value: model.$.loanType, component: Select, componentProps: { /* ... */ } },
|
|
51
|
+
personalData: personalDataNodes(model.$.personalData),
|
|
52
|
+
properties: { array: model.properties, item: propertyItem },
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// forms/credit-application/form.behavior.ts
|
|
56
|
+
import { defineFormBehavior, compute, enableWhen } from '@reformer/core/behaviors';
|
|
57
|
+
export const creditApplicationBehavior = defineFormBehavior<CreditApplicationForm>(({ model }) => {
|
|
58
|
+
compute(model.$.monthlyPayment, () => computeMonthlyPayment(model));
|
|
59
|
+
enableWhen([model.$.propertyValue], () => model.loanType === 'mortgage', { resetOnDisable: true });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// forms/credit-application/index.tsx — entry: assembles the form, renders <FormWizard> with all steps inline
|
|
63
|
+
import { createForm } from '@reformer/core';
|
|
64
|
+
export const createCreditApplicationForm = () => {
|
|
65
|
+
const model = createCreditApplicationModel();
|
|
66
|
+
return createForm({ model, schema: creditApplicationSchema(model), behavior: creditApplicationBehavior });
|
|
67
|
+
};
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Scaling up: folders (large forms)
|
|
71
|
+
|
|
72
|
+
When the flat module gets unwieldy — steps you want in their own files, sub-forms reused across
|
|
73
|
+
steps, calc/validators that outgrow one file — promote it to a three-folder module: `lib/`
|
|
74
|
+
(domain raw material), `schema/` (the form definition), `components/` (React layout), plus the
|
|
75
|
+
entry component and `index.ts`. Keep the module root to just the entry + `index.ts`; everything
|
|
76
|
+
else lives in a folder.
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
forms/
|
|
80
|
+
└── [form-name]/ # Form module — folders (scale-up)
|
|
81
|
+
├── [FormName]Form.tsx # Entry: builds model+form, renders FormWizard + step components
|
|
82
|
+
├── index.ts # Public re-exports of the module
|
|
83
|
+
│
|
|
84
|
+
├── lib/ # Domain helpers (target-agnostic)
|
|
85
|
+
│ ├── types.ts # Form interface + field enums + { value, label } option type
|
|
86
|
+
│ ├── constants.ts # Option dictionaries (LOAN_TYPES, GENDERS, ...)
|
|
87
|
+
│ ├── calc.ts # Pure fns for derived fields (age, monthlyPayment, ...)
|
|
88
|
+
│ ├── custom-validators.ts # Reusable validator factories
|
|
89
|
+
│ └── api.ts # Data sources + submit
|
|
90
|
+
│
|
|
91
|
+
├── schema/ # The form definition
|
|
92
|
+
│ ├── model.ts # createModel factory + initial values + array-element factories
|
|
93
|
+
│ ├── schema.ts # createForm schema tree ({ value: model.$.x, component })
|
|
94
|
+
│ ├── validation.ts # defineValidationSchema + validateModel config ({ validateStep, validateAll })
|
|
95
|
+
│ ├── behavior.ts # defineFormBehavior(...)
|
|
96
|
+
│ └── create-form.ts # Assembly: createForm({ model, schema, behavior })
|
|
97
|
+
│
|
|
98
|
+
└── components/
|
|
99
|
+
├── steps/ # One component per wizard step
|
|
100
|
+
├── nested-forms/ # Reusable sub-forms (Address, PersonalData, ...)
|
|
101
|
+
└── ui/ # Form-specific helper blocks (summary, warnings, sections)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Rule of thumb for this layout: **domain raw material → `lib/`; anything describing the form →
|
|
105
|
+
`schema/`; React layout → `components/`; root = entry + `index.ts`.** In very large forms you
|
|
106
|
+
may co-locate each step's `schema.ts` / `validation.ts` / `behavior.ts` inside its
|
|
107
|
+
`steps/[Step]/` folder, keeping only the shared `model` and cross-step rules in `schema/` — see
|
|
108
|
+
the guide below.
|
|
109
|
+
|
|
110
|
+
### Scaling
|
|
111
|
+
|
|
112
|
+
| Complexity | Structure |
|
|
113
|
+
| ---------- | --------------------------------------------------------------------------------------- |
|
|
114
|
+
| Simple | Single file: `index.tsx` (model + schema + behavior + component) |
|
|
115
|
+
| **Minimalist (flat)** | **Default.** One file per concern at the module root (`types` / `model` / `form.schema` / `form.behavior` / `validation` / `data-sources` / `api`) + `index.tsx` with all steps inline |
|
|
116
|
+
| Folders (`lib/` + `schema/` + `components/`) | Large forms: split concerns into folders, one component per step, reusable `nested-forms/` |
|
|
117
|
+
|
|
118
|
+
> The leading layout is configurable: set `REFORMER_FORM_LAYOUT` (`minimalist` | `folders`)
|
|
119
|
+
> when registering the MCP server to choose which structure the generators lead with. Default
|
|
120
|
+
> is `minimalist`.
|
|
121
|
+
|
|
122
|
+
> Cross-target variants (renderer-react `renderer.schema.ts` + `renderer.behavior.ts` /
|
|
123
|
+
> renderer-json `renderer.schema.json` + `renderer.behavior.ts` + `registry.ts`), the
|
|
124
|
+
> centralized-vs-co-located choice, and the full reuse map live in the
|
|
125
|
+
> **form-directory-layout** guide (`@reformer/mcp`).
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
## 14.5 UI COMPONENT PATTERNS
|
|
2
|
+
|
|
3
|
+
> **Default rule (read first)**: для UI используй `FormField` из
|
|
4
|
+
> [`@reformer/ui-kit`](../../reformer-ui-kit/) — он покрывает 95% случаев одной
|
|
5
|
+
> строкой `<FormField control={form.x} />`. Свои field-обёртки пиши ТОЛЬКО если
|
|
6
|
+
> ui-kit не подходит (другая design system, особый low-level input).
|
|
7
|
+
>
|
|
8
|
+
> Канонический schema-driven подход:
|
|
9
|
+
>
|
|
10
|
+
> - **компонент** объявляется в схеме как `component: Input` (или `Select`, `Checkbox`, etc.)
|
|
11
|
+
> - **пропсы** компонента — в `componentProps: { label, placeholder, options, type, ... }`
|
|
12
|
+
> - **JSX рендерит**: `<FormField control={form.x} />` БЕЗ дополнительных props
|
|
13
|
+
>
|
|
14
|
+
> См. `find_recipe(package="@reformer/ui-kit", topic="form-field-integration")`
|
|
15
|
+
> для полного руководства.
|
|
16
|
+
|
|
17
|
+
### Default — FormField из ui-kit (canonical)
|
|
18
|
+
|
|
19
|
+
```tsx
|
|
20
|
+
import { useMemo } from 'react';
|
|
21
|
+
import { createModel, createForm } from '@reformer/core';
|
|
22
|
+
import { FormField, Input, Select, Checkbox, Button } from '@reformer/ui-kit';
|
|
23
|
+
|
|
24
|
+
type RegistrationForm = {
|
|
25
|
+
email: string;
|
|
26
|
+
country: string;
|
|
27
|
+
agree: boolean;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function RegistrationPage() {
|
|
31
|
+
const form = useMemo(() => {
|
|
32
|
+
const model = createModel<RegistrationForm>({ email: '', country: 'ru', agree: false });
|
|
33
|
+
const schema = {
|
|
34
|
+
email: {
|
|
35
|
+
value: model.$.email,
|
|
36
|
+
component: Input,
|
|
37
|
+
componentProps: { label: 'Email', type: 'email', placeholder: 'you@example.com' },
|
|
38
|
+
},
|
|
39
|
+
country: {
|
|
40
|
+
value: model.$.country,
|
|
41
|
+
component: Select,
|
|
42
|
+
componentProps: {
|
|
43
|
+
label: 'Country',
|
|
44
|
+
options: [
|
|
45
|
+
{ value: 'ru', label: 'Россия' },
|
|
46
|
+
{ value: 'by', label: 'Беларусь' },
|
|
47
|
+
],
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
agree: {
|
|
51
|
+
value: model.$.agree,
|
|
52
|
+
component: Checkbox,
|
|
53
|
+
componentProps: { label: 'I agree to terms' },
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
return createForm<RegistrationForm>({ model, schema });
|
|
57
|
+
}, []);
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<form>
|
|
61
|
+
<FormField control={form.email} testId="email" />
|
|
62
|
+
<FormField control={form.country} testId="country" />
|
|
63
|
+
<FormField control={form.agree} testId="agree" />
|
|
64
|
+
<Button type="submit">Register</Button>
|
|
65
|
+
</form>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`FormField` сам читает `componentProps.label`, `componentProps.placeholder`,
|
|
71
|
+
`componentProps.options` через `useFormControl(...).componentProps` и применяет
|
|
72
|
+
их к нужному `<input>`/`<select>`/etc. Error rendering, `pending` для async-валидаций,
|
|
73
|
+
`data-testid` для e2e — всё из коробки.
|
|
74
|
+
|
|
75
|
+
### Anti-patterns (не делай так)
|
|
76
|
+
|
|
77
|
+
❌ **Свои field-компоненты с label-prop'ами в JSX**:
|
|
78
|
+
|
|
79
|
+
```tsx
|
|
80
|
+
// WRONG — дублирует логику FormField, ломает schema-driven архитектуру
|
|
81
|
+
<Input control={form.email} label="Email" placeholder="..." />
|
|
82
|
+
<Select control={form.country} options={[...]} />
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
❌ **Передача компонент-пропсов через JSX вместо схемы**:
|
|
86
|
+
|
|
87
|
+
```tsx
|
|
88
|
+
// WRONG — нарушает single source of truth (схема)
|
|
89
|
+
<FormField control={form.email} label="Email" />
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
✅ Всё это в схеме:
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
{ email: { component: Input, componentProps: { label: 'Email' } } }
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
```tsx
|
|
99
|
+
<FormField control={form.email} />
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### Advanced — кастомный input через `children` slot
|
|
103
|
+
|
|
104
|
+
Когда нужен низкоуровневый input, которого нет в ui-kit (маска, особый combobox):
|
|
105
|
+
|
|
106
|
+
```tsx
|
|
107
|
+
import { FormField } from '@reformer/ui-kit';
|
|
108
|
+
import { InputMask } from 'react-input-mask';
|
|
109
|
+
|
|
110
|
+
<FormField control={form.phone} testId="phone">
|
|
111
|
+
<InputMask mask="+7 (999) 999-99-99" />
|
|
112
|
+
</FormField>;
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
`children` оборачивается в `CdkFormField.Control asChild` и получает все нужные
|
|
116
|
+
props (`value`, `onChange`, `onBlur`, `aria-invalid`).
|
|
117
|
+
|
|
118
|
+
### Advanced — write your own from scratch (rare)
|
|
119
|
+
|
|
120
|
+
Если ты не хочешь подключать `@reformer/ui-kit`, пиши свои компоненты на основе
|
|
121
|
+
`useFormControl` — но **сохраняй schema-driven подход**: читай label/placeholder
|
|
122
|
+
из `componentProps`, не из JSX-props.
|
|
123
|
+
|
|
124
|
+
```tsx
|
|
125
|
+
import type { FieldNode } from '@reformer/core';
|
|
126
|
+
import { useFormControl } from '@reformer/core';
|
|
127
|
+
|
|
128
|
+
type MyFormFieldProps<T> = { control: FieldNode<T> }; // ← ОДИН prop
|
|
129
|
+
|
|
130
|
+
function MyFormField<T>({ control }: MyFormFieldProps<T>) {
|
|
131
|
+
const { value, errors, disabled, shouldShowError, componentProps } = useFormControl(control);
|
|
132
|
+
// componentProps = { label, placeholder, type, options, ... } — из СХЕМЫ
|
|
133
|
+
const cp = (componentProps ?? {}) as Record<string, unknown>;
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
<label>
|
|
137
|
+
{cp.label && <span>{cp.label as string}</span>}
|
|
138
|
+
<input
|
|
139
|
+
type={(cp.type as string) ?? 'text'}
|
|
140
|
+
value={(value ?? '') as string}
|
|
141
|
+
placeholder={cp.placeholder as string | undefined}
|
|
142
|
+
disabled={disabled}
|
|
143
|
+
onChange={(e) => (control.setValue as (v: unknown) => void)(e.target.value)}
|
|
144
|
+
onBlur={() => control.markAsTouched()}
|
|
145
|
+
/>
|
|
146
|
+
{shouldShowError && errors[0] && <span>{errors[0].message}</span>}
|
|
147
|
+
</label>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Использование — как у `FormField`:
|
|
153
|
+
|
|
154
|
+
```tsx
|
|
155
|
+
<MyFormField control={form.email} /> // ← без label-prop
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### Integration with UI libraries (shadcn etc.)
|
|
159
|
+
|
|
160
|
+
> **Через рендерер — без обёртки.** Если ты рендеришь форму через `@reformer/renderer-react` /
|
|
161
|
+
> `@reformer/renderer-json`, вместо ручной обёртки на каждый контрол можно зарегистрировать сырой
|
|
162
|
+
> компонент и передать `settings.resolveFieldAdapter` — рендерер сам сведёт value-seam к диалекту
|
|
163
|
+
> контрола. Ручная обёртка на `useFormControl` (ниже) нужна для прямого JSX без рендерера.
|
|
164
|
+
|
|
165
|
+
Если есть существующая design system — оборачивай её компоненты в один
|
|
166
|
+
`MyFormField` (как выше) и используй один прop `control`. Не множь обёртки на
|
|
167
|
+
тип input'а — пусть `componentProps.type` диспатчит внутри.
|
|
168
|
+
|
|
169
|
+
```tsx
|
|
170
|
+
import { Input } from '@/components/ui/input';
|
|
171
|
+
import { Label } from '@/components/ui/label';
|
|
172
|
+
|
|
173
|
+
function ShadcnFormField({ control }: { control: FieldNode<string> }) {
|
|
174
|
+
const { value, errors, disabled, componentProps } = useFormControl(control);
|
|
175
|
+
const cp = (componentProps ?? {}) as Record<string, unknown>;
|
|
176
|
+
|
|
177
|
+
return (
|
|
178
|
+
<div className="space-y-2">
|
|
179
|
+
{cp.label && <Label>{cp.label as string}</Label>}
|
|
180
|
+
<Input
|
|
181
|
+
value={(value ?? '') as string}
|
|
182
|
+
onChange={(e) => control.setValue(e.target.value)}
|
|
183
|
+
disabled={disabled}
|
|
184
|
+
/>
|
|
185
|
+
{errors[0] && <p className="text-red-500">{errors[0].message}</p>}
|
|
186
|
+
</div>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
```
|