Перейти к основному содержимому
Версия: 7.0

How-to: Пользовательские представления формы на React

Контейнер DESIGN может отрисовываться компонентом React вместо стандартной раскладки. Компонент получает проекцию состояния формы и сам рисует всё поддерево контейнера.

Это возможность только веб-клиента. Десктоп-клиент десериализует контейнер и отрисовывает его обычное (не React) поддерево, поэтому дизайн остаётся рабочим в обоих клиентах.

Выбор компонента

В DESIGN атрибуту custom контейнера присваивается имя компонента строковым литералом, соответствующим [A-Z][A-Za-z0-9_$]* (простой идентификатор, начинающийся с заглавной буквы):

FORM orders 'Orders'
OBJECTS o = Order
PROPERTIES(o) READONLY number, date, sum
;

DESIGN orders {
BOX(o) {
custom = 'OrderBoard';
}
}

Форма значения выбирает отрисовщик: строковый литерал, соответствующий [A-Z][A-Za-z0-9_$]*, задаёт компонент React, а пустая строка '', строка с HTML-шаблоном или свойство дают классический (не React) пользовательский контейнер, описанный в How-to: Пользовательские компоненты (объекты). Здесь объект o отрисовывается компонентом React OrderBoard вместо стандартной таблицы.

Компонент

OrderBoard — именованный экспорт из модуля .jsx в каталоге src/main/web; как этот модуль компилируется и регистрируется, описано в How-to: Пользовательские клиентские JS-модули. Примеры здесь используют JSX. Для проекта без сборки поместите тот же компонент в файл .jsx — он преобразуется на сервере при отдаче; import там недоступен, поэтому компонент работает с предоставляемым платформой window.React — либо напишите его через React.createElement в обычном .js. Файл кладётся в src/main/resources/web/init (автозагрузка) или в src/main/resources/web с регистрацией через onWebClientInit.

Компонент — это обычная функция, получающая props.data и props.controller:

export function OrderBoard(props) {
const orders = props.data.o.list;
return <div className="order-board">{orders.length} orders</div>;
}

props.data — проекция формы. Она содержит группу, только если бокс этой группы вложен в пользовательский контейнер, который отрисовывает представление: props.data.<g> равно { list, byKey } для каждого такого SID группового объекта g, где list — массив строк в порядке отображения, а byKey сопоставляет строке её строковый ключ с тем же объектом строки. Оба поля присутствуют всегда: когда у группы нет строк — панельная группа или группа до прихода её первых строк — list является пустым массивом, а byKey — пустым объектом, поэтому представление читает props.data.<g>.list напрямую, без проверки на отсутствующее поле. Группа, чей бокс находится вне контейнера — или удалён из дизайна через REMOVE, — отсутствует в props.data (props.data.<g> равно undefined), поэтому, чтобы передать данные группы в представление, держите её бокс внутри пользовательского контейнера. Каждая строка содержит:

ПолеЗначение
keyСтабильный публичный идентификатор строки — используется как ключ React
isCurrentЯвляется ли строка текущей (выделенной)
<integrationSID>Значение каждого свойства формы по интеграционному SID этого свойства
objectsНепрозрачный дескриптор строки, по которому контроллер адресует строку

key, isCurrent и objects — зарезервированные имена полей строки, поэтому интеграционный SID свойства формы не должен совпадать ни с одним из них.

Значение свойства в строке преобразуется в значение JS в зависимости от класса свойства:

Класс свойстваЗначение в JS
BOOLEANtrue / false (false вместо NULL)
TBOOLEANtrue / false / null
числовые классычисло
пользовательские классычисло — внутренний идентификатор объекта
классы даты и времениDate
JSONразобранное значение JSON
файловые классыстрока со ссылкой для скачивания
изображениястрока с адресом или HTML изображения
остальные классыстрока

Кроме BOOLEAN, значение NULL преобразуется в null.

list содержит не все строки группы, а только считанную страницу. Вид представления группы, отрисовываемой контейнером React, остаётся таблицей, и группа читается постранично, но, поскольку сама таблица не отображается, размер страницы не подбирается под видимые строки — действует серверный размер страницы по умолчанию (50 объектов). Представлению, которое показывает все строки группы — календарю, доске, карте, — задайте в блоке OBJECTS опцию PAGESIZE 0 (читать все объекты) либо явный размер страницы.

function Row(props) {
const r = props.row;
return (
<div className={r.isCurrent ? "order order-current" : "order"}>
<span>{r.number}</span>
<span>{r.sum}</span>
</div>
);
}

Отрисовка строк

Для отрисовки строк группы с экономией перерисовки по строкам используется window.lsfusion.List. Это глобальная переменная времени выполнения, поэтому, чтобы записать её как JSX-тег, сначала привяжите её к локальному имени с заглавной буквы; без псевдонима вызывайте через React.createElement:

const List = window.lsfusion.List;
// ...
<List data={props.data.o} component={Row} />
// либо, без псевдонима:
React.createElement(window.lsfusion.List, { data: props.data.o, component: Row })

Отрисовывайте List как компонент — через JSX или React.createElement, — а не вызывая его как обычную функцию: каждая строка отрисовывается компонентом, использующим хуки, поэтому он работает только когда его монтирует React.

List задаёт каждой строке ключ row.key, передаёт строку в компонент как props.row и отрисовывает каждую строку через мемоизированную обёртку, привязанную к этой строке, поэтому при изменении перерисовываются только реально изменившиеся строки. Простая альтернатива отображает список напрямую:

props.data.o.list.map(r => <Row key={r.key} row={r} />)

Зачем нужна экономия перерисовки по строкам. При изменении любой одной строки props.data.<g>.list пересоздаётся как новая ссылка на массив, но проекция сохраняет прежнюю ссылку на объект для каждой не изменившейся строки (структурное разделение) — новый объект строки получают только строки, содержимое которых изменилось. Простой list.map(r => <Row row={r}/>) пересоздаёт элемент Row для каждой записи при изменении любой одной строки, поэтому React перерисовывает их все. Ключ React key этого не меняет: он позволяет React сохранять идентичность элемента строки, её DOM и состояние компонента между перерисовками, но не отменяет саму перерисовку. Компилятор React тоже не помогает — он мемоизирует .map как одну реактивную область по ссылке на массив, которая только что изменилась, и не оборачивает дочерние строки в React.memo, поэтому все строки всё равно перерисовываются.

window.lsfusion.List добавляет недостающую отмену перерисовки по строкам: каждая строка отрисовывается через стабильную мемоизированную обёртку, которая следит за этой одной строкой, поэтому изменение значения перерисовывает только изменившуюся строку. Сам список не обходится заново при изменении значения строки — только при добавлении, удалении или перестановке строк, — поэтому стоимость обновления не растёт с числом строк. Чтобы получить отмену перерисовки по строкам вручную без window.lsfusion.List, объявите мемоизированный компонент строки один раз на уровне модуля и задавайте ключ row.key:

const MRow = React.memo(Row);
// ...
props.data.o.list.map(r => <MRow key={r.key} row={r} />)

React.memo(Row), создаваемый внутри компонента при каждой перерисовке, каждый раз является новым типом компонента, что сводит мемоизацию на нет и перерисовывает все строки.

Доступен более простой вариант window.lsfusion.List<List simple/> или, глобально, window.lsfusion.listSimple. Он вместо этого отображает список и мемоизирует компонент строки, полагаясь на то, что проекция повторно использует ссылку на не изменившуюся строку; компонент строки получает те же props.

Раскладка строк по ячейкам

Когда представление раскладывает строки группы не списком, а матрицей — календарь, канбан-доска, расписание, схема рассадки, — каждая строка попадает в производную ячейку (день × сотрудник, колонка статуса и т. п.). window.lsfusion.BucketScope поддерживает индекс ячейка → строки по одной группе, и каждая ячейка подписывается только на свой состав:

const { BucketScope, useBucket, useFormData } = window.lsfusion;

const Shift = React.memo(({ rowKey }) => {
const s = useFormData(d => d.ss.byKey[rowKey]); // подписка на свою строку
return s ? <button>{s.intervalS}</button> : null;
});

const Cell = React.memo(({ ck }) => {
const rowKeys = useBucket(ck); // подписка на свою ячейку
return <div className="cell">{rowKeys.map(k => <Shift key={k} rowKey={k} />)}</div>;
});

export function Board(props) {
// обеими осями владеет представление: дни показанной недели и одна строка доски на сотрудника
const days = weekOf(props.data.dates.scheduleFrom);
const rows = props.data.boardEmployees; // например, предразобранное JSON-свойство: [{ id, ... }]
return (
<BucketScope group="ss" bucketDeps={[]}
bucketOf={s => dateKey(s.date) + '|' + (s.assignedTo ?? '0')}>
<div className="grid">
{rows.map(row => days.map(d =>
<Cell key={row.id + '/' + dateKey(d)} ck={dateKey(d) + '|' + row.id} />))}
</div>
</BucketScope>
);
}

<BucketScope group bucketOf bucketDeps> оборачивает разметку сетки. group — SID группы объектов. bucketOf(row, rowKey) вычисляет ключ ячейки строки из значений её свойств — строку (любое значение приводится к строке), массив ключей, чтобы поместить строку в несколько ячеек, или null, чтобы никуда не помещать. bucketDeps перечисляет внешние значения, которые захватывает bucketOf, — как массив зависимостей хука, индекс перестраивается при их изменении; длина массива должна оставаться постоянной.

useBucket(cellKey) возвращает массив ключей строк, находящихся сейчас в этой ячейке, в порядке отображения группы, и подписывает компонент только на эту ячейку. Вызывайте его один раз в компоненте ячейки, с её фиксированным ключом (обычные правила хуков). Пустая ячейка всегда возвращает один и тот же замороженный пустой массив. Компонент ячейки превращает каждый ключ строки в компонент строки, который подписывается на свою строку через useFormData(d => d.<g>.byKey[rowKey]), как выше.

Раскладка остаётся за представлением: оно задаёт ключи ячеек — поэтому пустые ячейки существуют и отрисовываются, например как цели перетаскивания, — и разметку ячейки. За платформой — индекс и экономия перерисовки: перемещение строки между ячейками перерисовывает только старую и новую ячейку; изменение значения, не меняющее ячейку строки, перерисовывает только компонент самой строки; все остальные ячейки сохраняют прежнюю ссылку на массив, и их React.memo пропускает перерисовку. Простая альтернатива — самостоятельно группировать data.<g>.list по ячейкам на каждом рендере — каждый раз пересоздаёт массив каждой ячейки, поэтому любое изменение перерисовывает всю доску.

Когда ячейки образуют плоский список и вся разметка ячейки живёт в одном компоненте, форма <Buckets group cells bucketOf component/> выполняет отображение сама, как List для строк: по одной мемоизированной обёртке на каждый ключ из cells, а компонент ячейки получает cellKey, rowKeys, index и транзитные props. Явную разметку <BucketScope> + useBucket оставляйте, когда сетку размечает само представление — двумерная матрица, заголовки осей, закреплённые колонки:

const { Buckets } = window.lsfusion;
const STATUSES = ['new', 'inProgress', 'done'];

// Card подписывается на свою строку, как Shift выше
const Column = ({ cellKey, rowKeys }) => (
<div className="column">{rowKeys.map(k => <Card key={k} rowKey={k} />)}</div>
);

<Buckets group="t" cells={STATUSES} bucketOf={t => t.status} component={Column} />

Используйте раскладку по ячейкам, чтобы помещать строки одной группы в производные ячейки, когда важен только состав, — сводные таблицы, календари, канбан-доски, расписания, сетки с перетаскиванием. Она не вычисляет агрегаты по ячейкам: useBucket возвращает ключи строк, а не суммы или количества, и компонент ячейки перерисовывается, только когда меняется массив ключей строк этой ячейки, — живые агрегаты даёт вид представления сводная таблица. Для обычного списка строк один к одному используйте List; группировка работает только по собственным спроецированным значениям группы.

Интерактивность

Чтобы читать и изменять состояние формы из компонента — выбирать строку, изменять свойство, вызывать действия — используется props.controller. Его методы описаны в How-to: Контроллер пользовательского представления.