Исполняемые спецификации на flang React: форма и таблица из одной модели
0%

React: форма и таблица из одной модели

React: форма и таблица из одной модели

Форма не должна второй раз объявлять, какие поля есть у заявки и какие из них обязательны. Это уже сказано в объекте модели, которую проверяет сервер из прошлой главы. В этой главе — как из flang ast получить схему формы, как выбрать контрол по типу поля и почему предпросчёт в браузере не отменяет пересчёт на сервере.

Минимальный пример

модуль «Заявки на доставку»

тип «Способ доставки»
  вариант «Курьер»
  вариант «Пункт выдачи»
  вариант «Почта»

объект «Заявка»
  «получатель»: строка
  «вес»: неотрицательное
  «способ»: «Способ доставки»
  «хрупкое»: признак
  комментарий иногда является строкой

тотальная функция «Нужна упаковка»
  принимает заявка: «Заявка»
  возвращает признак
  обеспечивает «хрупкое всегда упаковываем» если заявка.«хрупкое» то результат равен да иначе да
  пример «хрупкое почтой — в упаковке»
    дано заявка равно запись «Заявка» с «получатель» равным "Ольга" и «вес» равным 2 и «способ» равным (вариант «Почта») и «хрупкое» равным да
    ожидается да
  пример «нехрупкое курьером — без упаковки»
    дано заявка равно запись «Заявка» с «получатель» равным "Ольга" и «вес» равным 2 и «способ» равным (вариант «Курьер») и «хрупкое» равным нет
    ожидается нет
  если заявка.«хрупкое»
    то да
    иначе разбор заявка.«способ»
      случай «Почта»
        то да
      случай «Курьер»
        то нет
      случай «Пункт выдачи»
        то нет

Модель лежит в static/spec/models/delivery-form.flang. В ней четыре вида полей, которые форма рисует по-разному: строка, неотрицательное число, признак и поле с типом-вариантом «Способ доставки». Пятое поле, комментарий, объявлено словами иногда является — оно необязательное.

Функция «Нужна упаковка» показывает, что правило живёт рядом с данными. Пример с почтой обходится без комментария: поле необязательное, запись без него собирается.

Что делает компилятор

flang ast печатает разобранную программу JSON-ом. Для формы в нём важны объекты и типы-варианты. Так выглядят поля «Заявки» (каждое — одной строкой, сокращено):

{"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}

А так — тип-вариант:

{"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}}

Это обычные данные, ничего специфичного для React. Из них получаются схема формы, колонки таблицы или JSON Schema. Снимать их нужно при сборке: в браузер едет готовый JSON, а не компилятор.

flangFormSchema: схема формы из объекта

examples/spec/form-schema/schema.mjs переводит объект модели в описание формы, не привязанное к фреймворку:

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';
}

Поле с типом-вариантом становится выбором из вариантов. Незнакомый тип осознанно становится текстовым полем, а не ошибкой: модель развивается быстрее, чем таблица контролов, и новый тип не должен ронять форму.

Сама функция собирает схему:

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;
    }),
  };
}

Запрос несуществующего объекта, наоборот, бросает исключение и называет доступные имена. Опечатку в названии лучше увидеть сразу, чем получить пустую форму:

$ node examples/spec/form-schema/schema.mjs Покупка static/spec/models/delivery-form.flang
Error: в модели нет объекта «Покупка» (есть: Заявка)

Схема «Заявки» целиком:

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
    }
  ]
}

иногда является → необязательное поле

Поле комментарий иногда является строкой компилятор отмечает в flang ast признаком "optional": true. Это единственный источник необязательности: flangFormSchema пишет required: !field.optional. В форме это один конкретный эффект — у <input> не ставится атрибут required.

Тест курса проверяет это напрямую:

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'],
    ],
  );
});

Обязательное поле компилятор требует и в примерах. Уберите из примера «хрупкое почтой — в упаковке» поле «хрупкое», и проверка откажет:

FLANG_TYPE в файле miss.flang, строка 19, столбец 3: пример «хрупкое почтой — в упаковке»: аргумент «заявка»: не задано поле «хрупкое» записи «Заявка»

Разбор DiscountForm.jsx

Компонент из examples/spec/react-form/DiscountForm.jsx не знает заранее, что у покупки есть сумма и признак постоянного клиента. Оба поля приходят из схемы, а функцию расчёта ему передают снаружи:

export function DiscountForm({ schema, calculate, onSubmit }) {
  const [value, setValue] = useState(() =>
    Object.fromEntries(schema.fields.map((field) => [field.name, field.control === 'checkbox' ? false : ''])),
  );

schema — результат flangFormSchema для «Покупки» из order-discount.flang, снятый при сборке. calculate — функция «Рассчитать скидку» из модуля, который напечатал flang emit --target js:

import { $PROGRAM } from './printed/prodazhi.js';
<DiscountForm schema={schema} calculate={$PROGRAM.functions.get('Рассчитать скидку')} />

Напечатанный модуль без зависимостей и работает в браузере так же, как в Node. Поля рисуются циклом по schema.fields. Добавили в модель поле — форма получила контрол без правки JSX.

Предпросчёт скидки идёт прямо в браузере:

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]);

Нарушенное обещание модели приходит ошибкой с кодом FLANG_PROPERTY. Это обычный ответ формы: preview.ok равно false, текст уходит в <output>, а кнопка отправки заблокирована через disabled={!preview.ok}. Например, отрицательная сумма у постоянного клиента:

FLANG_PROPERTY нарушено свойство «скидка постоянному не отрицательна» функции «Постоянный клиент»

Сервер решает сам

Предпросчёт нужен для отклика: пользователь видит скидку без сетевого запроса. Финальное решение пересчитывается на сервере при onSubmit → POST /discount. Код в браузере можно открыть в devtools и поменять на лету.

Есть и вторая причина. Напечатанная функция сама типы входа не сверяет: отрицательную сумму у обычного клиента она посчитает как 0, а NaN из пустого поля — тоже как 0. Сверка входа с типами модели стоит на сервере (readPurchase из прошлой главы). DiscountForm считает preview, server.mjs считает discount — это два вызова одной функции, но только второй имеет право на финальное решение.

Таблица из той же записи

Тот же список полей даёт колонки таблицы без второго описания. Для «Строки счёта» из 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',
}));

У этой записи шесть полей: номер, контрагент, сумма, валюта, срок оплаты, просрочен. Поле валюта — тип-вариант «Валюта», в фильтре таблицы оно станет списком из трёх вариантов. Подсветку строки решает не таблица, а функция модели «Требует контроля». Её обещание «непросроченный счёт контроля не требует» компилятор доказал для всех входов.

Чего модель не должна решать

Модель знает, что поле — неотрицательное число, но не знает формат отображения и локаль. Она знает обязательность через иногда является, но не текст ошибки валидации и не момент его показа. Она не определяет вёрстку, порядок фокуса и серверную уникальность (например, что номер заказа не должен повторяться). Это решения дизайн-системы и приложения.

Типа «дата» во flang нет: срок оплаты в модели счетов — строка. Контрол выбора даты форма по типу не узнает; его придётся назначить по имени поля или отдельной таблицей в приложении.

Практика в песочнице

  1. Найдите в модели поле, которое форма нарисует выбором из списка, и назовите варианты списка.
  2. Добавьте в «Способ доставки» вариант «Самовывоз» и нажмите «Проверить». Прочитайте ошибку FLANG_MATCH_NOT_EXHAUSTIVE: разбор в «Нужна упаковка» обязан назвать все варианты.
  3. Уберите из одного примера поле «хрупкое» и найдите в ответе не задано поле «хрупкое». Поле комментарий в тех же примерах не задано, и проверка о нём молчит: оно необязательное.

Типичные ошибки

  • FLANG_PROPERTY — предпросчёт в браузере наткнулся на нарушенное обещание модели. Это ожидаемый путь DiscountForm: preview.ok становится false, кнопка блокируется, форма не падает.
  • Тихий ноль вместо ошибки — Number('') даёт 0, а NaN и отрицательные числа напечатанная функция по типу не отвергает. Приводите и проверяйте значения в onChange, а окончательную проверку оставьте серверу.
  • FLANG_MATCH_NOT_EXHAUSTIVE — в тип-вариант добавили вариант, а разбор его не покрывает:
FLANG_MATCH_NOT_EXHAUSTIVE в файле nomatch.flang, строка 27, столбец 11: разбор «Способ доставки» не покрывает «Почта»

Чек-лист

  • Форма получает список полей из flang ast, снятого при сборке, а не дублирует его в JSX.
  • Контрол выбирается по типу поля; тип-вариант становится выбором из вариантов; незнакомый тип не роняет форму.
  • иногда является — единственный источник required: false.
  • Предпросчёт в браузере идёт напечатанной функцией модели и обёрнут в try/catch.
  • Финальное решение всегда пересчитывается на сервере, после сверки входа с типами.

Кейсы каталога по этой теме

Дальше: DDD

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов
Дальше