FTS in React: form and table from one model
The form should not declare for the second time what fields are present in the purchase and which of them are required — this is already stated in the model object that the server checks from the previous chapter. In this chapter — how to get the form schema from FtsDocument, how to choose a control based on the field type, and why pre-calculation in the browser does not cancel recalculation on the server.
Minimal example
категория «Продажи»
объект Покупка
сумма является деньгами
«постоянный клиент» является признаком
утилита «Рассчитать скидку»
принимает Покупка
возвращает деньги
начинает с 0
правило «Большая покупка»
если сумма не меньше 10000
и сумма не больше 100000
то добавить 10 процентов от поля сумма
правило «Постоянный клиент»
если «постоянный клиент» равен да
и сумма больше 0
и сумма не больше 100000
то добавить 5 процентов от поля сумма
правило «Очень крупная покупка»
если сумма больше 100000
то добавить 15000
свойство «Скидка ограничена»
результат не больше 15000
пример «Обычная покупка»
дано сумма равна 5000
дано «постоянный клиент» равен нет
ожидается результат равен 0
пример «Постоянный клиент на пять тысяч»
дано сумма равна 5000
дано «постоянный клиент» равен да
ожидается результат равен 250
пример «Большая покупка постоянного клиента»
дано сумма равна 20000
дано «постоянный клиент» равен да
ожидается результат равен 3000
пример «Покупка на потолок скидки»
дано сумма равна 100000
дано «постоянный клиент» равен да
ожидается результат равен 15000
пример «Очень крупная покупка»
дано сумма равна 200000
дано «постоянный клиент» равен нет
ожидается результат равен 15000
It is the same объект Покупка as in the previous two chapters. DiscountForm.jsx from
examples/fts/react-form draws the form exactly according to this structure, and
server.mjs from examples/fts/discount-api calculates the discount using it as well.
What the compiler does
compile in the browser — the same parser as on the server, but without modules
that rely on Node.js crypto: certification and verification remain the server’s
responsibility, while compile and executeUtility work the same in both
environments. From the result of document.structures.find(...) you can extract a list
of object fields with a name and canonical type (Строка, Число, Деньги,
Дата, Признак, or the same type with | undefined for an optional field).
Further, this is just regular data, not something specific to React — it can be
converted into a schema for a form, into table columns, or into JSON Schema.
ftsFormSchema: form schema from object
examples/fts/form-schema/schema.mjs translates the model structure into a neutral
description that is not tied to a framework:
const CONTROLS = [
[/^Признак$|^Boolean$/u, 'checkbox'],
[/^Деньги$|^Money$/u, 'money'],
[/^Число$|^Number$/u, 'number'],
[/^Дата$|^Date$/u, 'date'],
];
export function controlFor(type) {
const found = CONTROLS.find(([pattern]) => pattern.test(type));
return found ? found[1] : 'text';
}
export function ftsFormSchema(document, objectName) {
const structure = document.structures.find((item) => item.name === objectName);
if (!structure) {
const known = document.structures.map((item) => item.name).join(', ');
throw new Error(`в модели нет объекта «${objectName}» (есть: ${known})`);
}
return {
id: `${document.category}.${structure.name}`,
title: structure.name,
fields: structure.fields.map((field) => {
const optional = field.type.includes('undefined');
const type = field.type.replace(/\s*\|\s*undefined\s*/gu, '');
return { name: field.name, label: field.name, type, control: controlFor(type), required: !optional };
}),
};
}
An unknown type consciously becomes a text field rather than an error: the domain model evolves faster than the control mapping table, and a new type should not break the form. A request for a non-existent object, on the other hand, raises an exception and lists available names — a typo in the object name is better detected immediately rather than receiving an empty form.
иногда является → optional field
объект Покупка
сумма является деньгами
«постоянный клиент» является признаком
комментарий иногда является строкой
The compiler reflects иногда является ... as a union with undefined in
the canonical field type. This is the only source of optionality: schema.mjs
checks field.type.includes('undefined') and sets required: false.
In form this means one specific effect — the required attribute on <input>
is not set, and not more complex field display logic. The test from the course checks
this directly:
test('«иногда является» делает поле необязательным', () => {
const document = compile('категория «Заявки»\n\n объект Заявка\n имя является строкой\n комментарий иногда является строкой\n');
const schema = ftsFormSchema(document, 'Заявка');
assert.deepEqual(
schema.fields.map((field) => [field.name, field.required, field.type]),
[['имя', true, 'Строка'], ['комментарий', false, 'Строка']],
);
});
Walkthrough DiscountForm.jsx
A component from examples/fts/react-form/DiscountForm.jsx does not know in advance that
a purchase has an amount and a returning customer flag — both fields come from
ftsFormSchema:
const document = useMemo(() => compile(source), [source]);
const schema = useMemo(() => ftsFormSchema(document, objectName), [document, objectName]);
const [value, setValue] = useState(() =>
Object.fromEntries(schema.fields.map((field) => [field.name, field.control === 'checkbox' ? false : ''])),
);
compile and the schema building are wrapped in useMemo: they recalculate only when the model’s source text or the object’s name change, not on every keystroke. Fields are drawn in a loop over schema.fields — adding a new field to the model gives the form a control without a single JSX edit.
Discount pre-calculation is done by the same compiler directly in the browser:
const preview = useMemo(() => {
try {
const input = Object.fromEntries(
schema.fields.map((field) => [
field.name,
field.control === 'checkbox' ? Boolean(value[field.name]) : Number(value[field.name] || 0),
]),
);
return { ok: true, amount: executeUtility(document, utility, input) };
} catch (error) {
return { ok: false, reason: error.message };
}
}, [document, schema, utility, value]);
A violated model property turns into a regular form response — preview.ok
equals false, and the error message goes to <output> next to the fields, — not
into an unhandled React exception. The submit button is blocked via
disabled={!preview.ok} until the pre-calculation converges. It is the same diagnostic language
that the developer would see in the terminal or an HTTP API client from the
previous chapter: one and the same model, one and the same semantics of rules
and properties, three different execution locations.
Server authority
Web compilation is needed for UI response and pre-calculation — the user
sees an approximate discount without a network request. The final decision is still
recalculated on the server during onSubmit → POST /discount: client-side
JavaScript can be opened in devtools and modified on the fly, so
the browser result is never the sole source of truth for the amount
that goes to payment. The difference is explicitly recorded: DiscountForm calculates
preview, server.mjs calculates discount — these are two calls of the same utility, but
only the second has the right to the final decision.
Table from the same structure
The same list structure.fields gives columns for the table without the second
description:
const structure = document.structures.find(({ name }) => name === 'Строка счёта');
const columns = structure.fields.map((field) => ({
id: field.name,
header: field.name,
accessorKey: field.name,
format: field.type === 'Деньги' ? 'currency' : 'plain',
}));
The invoices-table.fts model in the sandbox is set up exactly like this: the «Account line» object defines the shape of the table row, while просрочен as a state controls the highlighting through a separate morphism — this is no longer about the shape, but about a provable state, the topic of the next chapter.
What the Model Should Not Solve
FTS knows that the field is a date, but does not know the date picker locale. It knows the required status through иногда является, but not the validation error text and not the moment to display it. It does not define responsive layout, focus management, and server-side uniqueness (for example, that an order number should not be repeated) — these are decisions of the design system and the application, not the domain model.
Practice in the sandbox
- On the tab
modelfind the objectПокупкаand determine which control will get the fieldсумма, and which —«постоянный клиент», if to applycontrolForto their types. - Open
credit-limit.fts(tabmodel) and find the field that is a state, not a scalar. Discuss whyftsFormSchemawill not be able to draw for it a regular<input>without additional control. - Mentally (or in a fork) add to the object
Покупкаan optional fieldкомментарий иногда является строкойand check on the tabrun, that execution of the utility does not require its presence in input data.
Common Fallacies
FTS_UTILITY_PROPERTY— precomputation in the browser encountered a violated model property (e.g., the user entered an amount where the discount exceeds the limit). This is an expectedDiscountFormpath:preview.okbecomesfalse, the button is disabled, the error text goes to<output>— the form does not crash and does not show a white screen.FTS_UTILITY_INPUT_TYPE— precomputation received a value of the wrong type (e.g.,Number('')givesNaN, but a numeric field does not acceptNaN). Check type coercion in theonChangehandler before callingexecuteUtility, rather than relying on the HTML control to return the correct JS type.FTS_NATURAL_DECLARATION— if the model is edited directly in the sandbox (tabmodel) and a typo appears in a keyword of an object or utility,compilewill throw this error before the form has a chance to render. The compiler message is the same in both the browser and Node.js, because the parser is shared.
Checklist
- The form receives the list of fields from
document.structures, rather than duplicating it in JSX manually. - The control is chosen based on the canonical field type, and an unfamiliar type does not break the form.
иногда являетсяis the single source ofrequired: false.- Precomputation in the browser is wrapped in
try/catchand turns a violated property into a form state, not an unhandled exception. - The final decision is always recalculated on the server by the same
executeUtility— the browser result is used only for UI feedback.