React: form and table from one model
A form should not declare a second time which fields a request has and which
of them are required. That is already said in the object of the model that the
server from the previous chapter checks. This chapter covers how to get a form
schema from flang ast, how to pick a control by field type, and why a preview
in the browser does not replace the recomputation on the server.
Minimal example
модуль «Заявки на доставку»
тип «Способ доставки»
вариант «Курьер»
вариант «Пункт выдачи»
вариант «Почта»
объект «Заявка»
«получатель»: строка
«вес»: неотрицательное
«способ»: «Способ доставки»
«хрупкое»: признак
комментарий иногда является строкой
тотальная функция «Нужна упаковка»
принимает заявка: «Заявка»
возвращает признак
обеспечивает «хрупкое всегда упаковываем» если заявка.«хрупкое» то результат равен да иначе да
пример «хрупкое почтой — в упаковке»
дано заявка равно запись «Заявка» с «получатель» равным "Ольга" и «вес» равным 2 и «способ» равным (вариант «Почта») и «хрупкое» равным да
ожидается да
пример «нехрупкое курьером — без упаковки»
дано заявка равно запись «Заявка» с «получатель» равным "Ольга" и «вес» равным 2 и «способ» равным (вариант «Курьер») и «хрупкое» равным нет
ожидается нет
если заявка.«хрупкое»
то да
иначе разбор заявка.«способ»
случай «Почта»
то да
случай «Курьер»
то нет
случай «Пункт выдачи»
то нет
The model lives in static/spec/models/delivery-form.flang. It has four kinds
of fields that the form draws differently: a string, a non-negative number, a
flag and a field whose type is the variant type «Способ доставки» (delivery
method). The fifth field, комментарий, is declared with the words
иногда является — it is optional.
The function «Нужна упаковка» (packaging needed) shows that the rule lives next to the data. The mail example does without a comment: the field is optional, and the record is built without it.
What the compiler does
flang ast prints the parsed program as JSON. For a form, the objects and the
variant types matter. This is how the fields of «Заявка» look (one per line,
shortened):
{"name": "получатель", "type": {"kind": "string"}, "fts": "Строка", "span": {"line": 9, "column": 3}}
{"name": "вес", "type": {"kind": "named", "name": "неотрицательное"}, "fts": "неотрицательное", "span": {"line": 10, "column": 3}}
{"name": "способ", "type": {"kind": "named", "name": "Способ доставки"}, "fts": "Способ доставки", "span": {"line": 11, "column": 3}}
{"name": "хрупкое", "type": {"kind": "flag"}, "fts": "Признак", "span": {"line": 12, "column": 3}}
{"name": "комментарий", "type": {"kind": "string"}, "fts": "Строка | undefined", "span": {"line": 13, "column": 3}, "optional": true}
And this is the variant type:
{"kind": "sum", "name": "Способ доставки", "variants": [{"name": "Курьер", "fields": [], "span": {"line": 4, "column": 3}}, {"name": "Пункт выдачи", "fields": [], "span": {"line": 5, "column": 3}}, {"name": "Почта", "fields": [], "span": {"line": 6, "column": 3}}], "span": {"line": 3, "column": 1}}
This is plain data, nothing specific to React. A form schema, table columns or a JSON Schema come out of it. Take it at build time: the browser gets ready JSON, not the compiler.
flangFormSchema: a form schema from an object
examples/spec/form-schema/schema.mjs turns a model object into a form
description that is not tied to a framework:
const CONTROLS = {
flag: 'checkbox',
number: 'number',
string: 'text',
неотрицательное: 'number',
сотых: 'money',
};
function typeName(type) {
return type.kind === 'named' ? type.name : type.kind;
}
export function controlFor(type, ast = { types: [] }) {
const name = typeName(type);
if (ast.types.some((item) => item.kind === 'sum' && item.name === name)) return 'select';
return CONTROLS[name] ?? 'text';
}
A field whose type is a variant type becomes a choice among the variants. An unknown type deliberately becomes a text field rather than an error: the model evolves faster than the control table, and a new type must not break the form.
The function itself builds the schema:
export function flangFormSchema(ast, objectName) {
const records = ast.types.filter((item) => item.kind === 'record');
const record = records.find((item) => item.name === objectName.replace(/^«|»$/gu, ''));
if (!record) {
throw new Error(`в модели нет объекта «${objectName}» (есть: ${records.map((item) => item.name).join(', ')})`);
}
return {
id: `${ast.module}.${record.name}`,
title: record.name,
fields: record.fields.map((field) => {
const control = controlFor(field.type, ast);
const entry = { name: field.name, label: field.name, type: typeName(field.type), control, required: !field.optional };
if (control === 'select') {
entry.options = ast.types.find((item) => item.name === entry.type).variants.map((variant) => variant.name);
}
if (entry.type === 'неотрицательное') entry.min = 0;
return entry;
}),
};
}
Asking for an object that does not exist, on the contrary, throws and names the available ones. A typo in the name is better seen at once than turned into an empty form:
$ node examples/spec/form-schema/schema.mjs Покупка static/spec/models/delivery-form.flang
Error: в модели нет объекта «Покупка» (есть: Заявка)
The whole «Заявка» schema:
node examples/spec/form-schema/schema.mjs Заявка static/spec/models/delivery-form.flang
{
"id": "Заявки на доставку.Заявка",
"title": "Заявка",
"fields": [
{
"name": "получатель",
"label": "получатель",
"type": "string",
"control": "text",
"required": true
},
{
"name": "вес",
"label": "вес",
"type": "неотрицательное",
"control": "number",
"required": true,
"min": 0
},
{
"name": "способ",
"label": "способ",
"type": "Способ доставки",
"control": "select",
"required": true,
"options": [
"Курьер",
"Пункт выдачи",
"Почта"
]
},
{
"name": "хрупкое",
"label": "хрупкое",
"type": "flag",
"control": "checkbox",
"required": true
},
{
"name": "комментарий",
"label": "комментарий",
"type": "string",
"control": "text",
"required": false
}
]
}
иногда является → an optional field
The field комментарий иногда является строкой is marked by the compiler in
flang ast with "optional": true. That is the only source of optionality:
flangFormSchema writes required: !field.optional. In the form it has one
specific effect — the <input> gets no required attribute.
The course test checks it directly:
test('«иногда является» делает поле необязательным', async () => {
const ast = await astOf('модуль «Заявки»\n\nобъект «Заявка»\n имя является строкой\n комментарий иногда является строкой\n');
assert.deepEqual(
flangFormSchema(ast, 'Заявка').fields.map((field) => [field.name, field.required, field.type]),
[
['имя', true, 'string'],
['комментарий', false, 'string'],
],
);
});
The compiler requires a mandatory field in examples too. Remove the field «хрупкое» from the example «хрупкое почтой — в упаковке», and the check refuses:
FLANG_TYPE в файле miss.flang, строка 19, столбец 3: пример «хрупкое почтой — в упаковке»: аргумент «заявка»: не задано поле «хрупкое» записи «Заявка»
Inside DiscountForm.jsx
The component from examples/spec/react-form/DiscountForm.jsx does not know in
advance that a purchase has an amount and a regular-customer flag. Both fields
come from the schema, and the calculating function is passed in from outside:
export function DiscountForm({ schema, calculate, onSubmit }) {
const [value, setValue] = useState(() =>
Object.fromEntries(schema.fields.map((field) => [field.name, field.control === 'checkbox' ? false : ''])),
);
schema is the result of flangFormSchema for «Покупка» from
order-discount.flang, taken at build time. calculate is the function
«Рассчитать скидку» from the module printed by flang emit --target js:
import { $PROGRAM } from './printed/prodazhi.js';
<DiscountForm schema={schema} calculate={$PROGRAM.functions.get('Рассчитать скидку')} />
The printed module has no dependencies and runs in the browser just as it does
in Node. The fields are drawn by a loop over schema.fields. Add a field to the
model, and the form gets a control without an edit to the JSX.
The discount preview runs right 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: calculate(input) };
} catch (error) {
return { ok: false, reason: error.message };
}
}, [calculate, schema, value]);
A broken model promise comes as an error with the code FLANG_PROPERTY. It is
an ordinary form answer: preview.ok is false, the text goes into <output>,
and the submit button is disabled through disabled={!preview.ok}. For
example, a negative amount for a regular customer:
FLANG_PROPERTY нарушено свойство «скидка постоянному не отрицательна» функции «Постоянный клиент»
The server decides on its own
The preview is there for responsiveness: the user sees the discount without a
network request. The final decision is recomputed on the server on onSubmit →
POST /discount. Code in the browser can be opened in devtools and changed on
the fly.
There is a second reason. The printed function does not check input types
itself: it computes a negative amount for an ordinary customer as 0, and NaN
from an empty field as 0 too. Checking the input against the model’s types
happens on the server (readPurchase from the previous chapter). DiscountForm
computes preview, server.mjs computes discount — two calls of the same
function, but only the second one has the right to the final decision.
A table from the same record
The same list of fields gives table columns without a second description. For
«Строка счёта» (invoice line) from invoices-table.flang:
const schema = flangFormSchema(ast, 'Строка счёта');
const columns = schema.fields.map((field) => ({
id: field.name,
header: field.label,
accessorKey: field.name,
format: field.type === 'неотрицательное' ? 'number' : 'plain',
}));
This record has six fields: номер, контрагент, сумма, валюта,
срок оплаты, просрочен. The field валюта has the variant type «Валюта»
(currency); in a table filter it becomes a list of three variants. Row
highlighting is decided not by the table but by the model function «Требует
контроля» (needs follow-up). Its promise «непросроченный счёт контроля не
требует» is proved by the compiler for every input.
What the model should not decide
The model knows that a field is a non-negative number, but not the display
format or the locale. It knows optionality through иногда является, but not
the text of a validation error or when to show it. It does not define layout,
focus order or server-side uniqueness (for example, that an order number must
not repeat). Those are decisions of the design system and the application.
There is no date type in flang: срок оплаты in the invoice model is a string.
The form cannot pick a date picker from the type; the application has to assign
it by field name or through a separate table.
Practice in the sandbox
- Find the field in the model that the form draws as a choice from a list, and name the list’s variants.
- Add the variant «Самовывоз» to «Способ доставки» and press “Check”. Read the
error
FLANG_MATCH_NOT_EXHAUSTIVE: the match in «Нужна упаковка» must name every variant. - Remove the field «хрупкое» from one example and find
не задано поле «хрупкое»in the answer. The fieldкомментарийis not set in the same examples, and the check says nothing about it: it is optional.
Common errors
FLANG_PROPERTY— the browser preview hit a broken model promise. This is the expected path ofDiscountForm:preview.okbecomesfalse, the button is disabled, the form does not crash.- A silent zero instead of an error —
Number('')gives0, and the printed function does not rejectNaNor negative numbers by type. Convert and check values inonChange, and leave the final check to the server. FLANG_MATCH_NOT_EXHAUSTIVE— a variant was added to a variant type, but the match does not cover it:
FLANG_MATCH_NOT_EXHAUSTIVE в файле nomatch.flang, строка 27, столбец 11: разбор «Способ доставки» не покрывает «Почта»
Checklist
- The form gets its list of fields from
flang ast, taken at build time, and does not duplicate it in JSX. - The control is picked by field type; a variant type becomes a choice among variants; an unknown type does not break the form.
иногда являетсяis the only source ofrequired: false.- The browser preview runs the printed model function and is wrapped in
try/catch. - The final decision is always recomputed on the server, after the input is checked against types.