Перейти к основному содержимому
Версия: 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 — проекция формы, своя у каждого контейнера CUSTOM REACT: в ней только то, чем владеет этот контейнер, поэтому второй React-контейнер формы получает отдельную проекцию. Каждое проецируемое свойство, группа и контейнер — это объект в data: значение читается как .value, его атрибуты — как соседние поля, а атрибуты колонки списочного свойства — из data.<group>.<prop>. Она содержит группу, только если бокс этой группы вложен в пользовательский контейнер, который отрисовывает представление: props.data.<g> равно { list, byKey, keys, count, options } для каждого такого SID группового объекта g, где list — массив строк в порядке отображения, byKey сопоставляет строковому ключу строки тот же объект строки, а keys — массив этих строковых ключей в том же порядке. list, byKey и keys присутствуют всегда: когда у группы нет строк — панельная группа или группа до прихода её первых строк — они пусты, поэтому представление читает props.data.<g>.list напрямую, без проверки на отсутствующее поле. Группа, чей бокс находится вне контейнера — или удалён из дизайна через REMOVE, — отсутствует в props.data (props.data.<g> равно undefined), поэтому, чтобы передать данные группы в представление, держите её бокс внутри пользовательского контейнера. Панельные свойства группы являются полями самой группы по интеграционному SID, а каждое свойство уровня формы (без группы) — полем props.data по своему интеграционному SID. Атрибуты колонки списочного свойства тоже являются полем группы, в data.<g>.<prop> — по одной записи на колонку, — а его построчное значение и атрибуты ячейки лежат в каждой строке. Действия проецируются так же, как свойства: действие, добавленное на группу, является полем каждой строки (списочное действие) или узла группы (панельное действие) — объектом своих атрибутов, как свойство, — поэтому controller.changeProperty('<group>.<action>', row) его выполняет; поле value у него тоже есть, но ничего не несёт. count и options — собственные опции отображения группы. Каждая строка содержит:

ПолеЗначение
keyСтабильный публичный идентификатор строки — используется как ключ React
isCurrentЯвляется ли строка текущей (выделенной)
<integrationSID>Каждое списочное свойство — объект { value, ...атрибуты ячейки } по интеграционному SID этого свойства; значение читается как .value
objectsНепрозрачный дескриптор строки, по которому контроллер адресует строку
background, foreground, selectedСобственные опции отображения строки: её цвета фона и текста и признак выделения

Свойство, сгруппированное по колонкам (COLUMNS), не проецируется вовсе: у него нет ни записи колонки, ни записи ячейки, и никакого сообщения об этом не выдаётся — его значения адресуются ключом «строка и колонка», для которого в проекции нет места. Если представлению нужны эти значения, объявите обычное свойство.

key, isCurrent, objects, background, foreground и selected — зарезервированные имена полей строки; list, byKey, keys, count и options зарезервированы в группе. Объекта meta нет нигде. Форма, в которой проецируемый интеграционный SID занимает зарезервированное имя или два проецируемых элемента занимают одно имя на одном уровне данных, отклоняется с явной ошибкой при её построении.

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

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

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

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

Представление, раскладывающее строки в их собственном порядке — поток карточек, лента, — может вместо этого сохранить страницу и вести её за прокруткой, как это делает стандартная таблица. useSeekOnScroll(controller.<группа>) возвращает функцию, которой помечается элемент каждой строки; хук следит, какие из них видны на экране — где бы ни происходила прокрутка, в собственном блоке компонента или в платформенном контейнере над ним, — и следует правилам самой таблицы: пока текущая запись на экране, прокрутка ничего не меняет; когда она выходит за экран — пересаживается на видимую кромку, через которую вышла, что и запрашивает следующую страницу; при смене страницы пересаженная строка сохраняет свою позицию на экране, так что под взглядом ничего не прыгает; текущая запись, изменённая извне — кликом или программным переходом, — наоборот, докручивается в видимость. Пакт держится на том же контракте, на котором живёт сама таблица, и он на стороне разработчика: страница должна вмещать больше строк, чем видно на экране, — задайте соответствующий PAGESIZE в блоке OBJECTS. Тогда окно всегда простирается на страницу дальше текущей записи, и она выходит за экран — и пересаживается, подтягивая следующую страницу, — раньше, чем прокрутке станет некуда двигаться у края загруженного:

const seekRef = useSeekOnScroll(controller.o, { enabled: follow });
...
{rows.map(row => <div key={row.key} ref={seekRef(row)}>...</div>)}

Опции: enabled (по умолчанию true) приостанавливает слежение, threshold (0.6) — какая часть элемента должна быть видна, чтобы считаться на экране, settle (250 мс) — сколько прокрутка должна стоять на месте, onSeek(row) вызывается при каждом переходе. Используйте один useSeekOnScroll на прокручиваемый элемент. Строки, считанные для новой позиции, заменяют list, и всё построенное из него — значения и размещённые представления lsFusion — следует за ними.

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

Чтение проекции

Компонент читает проекцию одним из двух способов, и выбор — про то, кто перерисовывается при изменении данных.

props.data — снапшот целиком. Компонент перерисовывается — со свежим data — при любом изменении своей области и рисует всё, что рисует, из нового снапшота. Для небольшого представления это вся история: никаких хуков, чистая функция от данных — примеры выше написаны именно так. Это же единственный способ прочитать область целиком: перечислить её верхние записи, прочитать контейнеры.

useFormData(selector) подписывает компонент на срез: он перерисовывается, только когда меняется ссылка selector(data), — поэтому селектор должен возвращать то, что в проекции уже есть (узел группы, строку, запись, значение), и никогда не собирать внутри себя новый объект или массив: такой результат отличается при каждом чтении и приводит к бесконечной перерисовке. Благодаря структурному переиспользованию — не изменившийся узел, строка или запись сохраняют прежнюю ссылку — именно это позволяет компоненту платить только за то, что он читает: корень, подписанный на узел своей группы (useFormData(s => s.o)), не замечает остальную область, компонент строки, подписанный на свою строку (useFormData(s => s.o.byKey[rowKey])), — остальные строки. useFormController() возвращает тот же controller, который корень получает параметром, — его идентичность не меняется за жизнь формы, поэтому он безопасен в зависимостях и замыканиях, и передавать его вниз параметром так же правильно.

Эти два способа складываются в ступени, и каждая следующая нужна лишь тогда, когда перерисовка предыдущей становится ценой:

  1. небольшое представление — props.data, рисуется всё;
  2. список подлиннее — props.data плюс компоненты строк, мемоизированные на уровне модуля (React.memo отсекает по стабильным ссылкам не изменившихся строк), или List, который делает ровно это;
  3. большая доска — подписки вниз по дереву: корень не подписан ни на что, колонка — на свою ячейку, строка — на себя, и одно изменённое значение перерисовывает одну карточку.

Опции отображения

Для каждого свойства, добавленного на форму, платформа вычисляет смысловые опции отображения: заголовок, изображение, цвет фона и цвет текста, состояние «только чтение» или «отключено», комментарий, текст в пустой ячейке, всплывающую подсказку. Они берутся из дизайна свойства и из зависящих от данных опций блока свойств и действий (HEADER, IMAGE, BACKGROUND, FOREGROUND, READONLYIF, OPTIONS и остальных), поэтому опция, зависящая от данных, вычисляется заново для каждой строки. Классы элементов и шрифты стандартного клиента не проецируются: компонент React сам определяет свои CSS-стили. background и foreground проецируются — это выделение из BACKGROUND / FOREGROUND, зависящий от данных бизнес-сигнал, а не тема, которой должны владеть собственные CSS-стили компонента. Для свойства, которое контейнер React отрисовывает сам, смысловой результат проецируется в data соседними полями рядом со значением свойства, поэтому представлению не нужно вычислять его заново.

Проекция следует тому, что описывает опция, — колонку целиком, одну ячейку или строку:

ГдеЧто содержит
data.<g>.<integrationSID>Для свойства, показываемого в таблице, — атрибуты его колонки, по одной записи на всю колонку: caption, image, footer, comment, tooltip, defaultValue. Для панельного свойства группы — value свойства и его атрибуты, вычисленные для текущего объекта
data.<g>.list[i].<integrationSID>Одна ячейка свойства таблицы — её value для этой строки и атрибуты ячейки, вычисленные для этой строки: readOnly, disabled, background, foreground и остальные
data.<g>.list[i]Собственные опции строки, прямо на строке: background, foreground, selected
data.<g>count — сколько строк прочитано; options — пользовательские опции группы
data.<integrationSID>Свойство уровня формы (без группы) — его value и атрибуты
data.<containerSID>caption и image контейнера, объявленного в дизайне (NEW <имя>) или помеченного lsf (например, BOX(o)), по идентификатору компонента дизайна этого контейнера — всегда на верхнем уровне, во что бы контейнер ни был вложен. Контейнер присутствует всегда, когда он объявлен в дизайне (NEW <имя>) или помечен lsf, — {} при отсутствии подписи и картинки. Остальные контейнеры, которые платформа создаёт для формы и её групп (TOOLBAR(g), PANEL(g), …), не проецируются

Каждый атрибут присылается в одном месте, уже вычисленным: сервер сводит статическое значение из дизайна свойства и его построчный результат BACKGROUND / READONLYIF / … в одно действующее значение, поэтому представление читает атрибут прямо оттуда, где он лежит, и никогда не объединяет запись колонки с записью строки. Атрибуты списочного свойства на всю колонку — caption, image, footer, comment, tooltip, defaultValue — находятся в узле его колонки data.<g>.<prop>; его построчное значение и атрибуты ячейки, которые могут меняться по строкам, — в ячейке data.<g>.list[i].<prop>:

const caption = props.data.o.sum.caption;   // атрибут колонки, один на всю колонку
const cell = row.sum; // { value, background, readOnly, ... } для этой строки

Динамический IMAGE свойства сервер вычисляет один раз для ключа колонки с использованием текущего объекта, а динамический IMAGE действия — для каждой строки. Проекция сохраняет это различие: изображение свойства находится в узле его колонки data.<g>.<prop>, а изображение действия — в ячейке его строки data.<g>.list[i].<action>.

Опция, для которой платформа ничего не вычислила, отсутствует: у свойства без BACKGROUND нет background в записи его ячейки, а у строки, для которой не вычислено ни одной опции строки, нет background, foreground и selected.

SHOWIF управляет самим свойством. Когда он скрывает колонку таблицы, запись этого свойства отсутствует в ячейке каждой строки и в узле колонки группы. Когда он скрывает панельное свойство или свойство уровня формы, его запись отсутствует в его объекте. Если нужно отличить скрытое свойство от свойства со значением value, равным null, проверяйте запись свойства через Object.hasOwn().

ОпцияЧто этоЗначение в JS
captionЗаголовок свойствастрока
imageИзображение свойствастрока с HTML изображения
footerЗначение подвала колонкипреобразуется как значение ячейки
readOnlyЯчейка доступна только для чтения — по значению, пришедшему из READONLYIF. В остальных случаях отсутствует: статически READONLY свойство сюда не проецируется, так как представление его и не редактируетtrue / отсутствует
disabledЯчейка отключена — по значению, пришедшему из DISABLEIF. В остальных случаях отсутствует. В одной ячейке readOnly и disabled никогда не приходят вместеtrue / отсутствует
background, foregroundЦвет фона и цвет текста ячейкистрока с цветом
commentКомментарий, показываемый рядом со значениемстрока
placeholderТекст, показываемый в пустой ячейкестрока
patternШаблон, по которому отображается значениестрока
regexp, regexpMessageРегулярное выражение, которому должно соответствовать вводимое значение, и сообщение при несоответствиистрока
tooltip, valueTooltipВсплывающие подсказки свойства и его значениястрока
optionsПользовательские опции свойстваразобранное значение JSON
defaultValueЗначение, с которого начинается редактированиестрока

Из них caption, image, footer, comment, tooltip и defaultValue — собственные атрибуты колонки, в узле колонки data.<g>.<prop> (по одной на колонку); остальные (background, foreground, readOnly, disabled, placeholder, pattern, regexp, regexpMessage, valueTooltip, options) — атрибуты ячейки, в ячейке каждой строки data.<g>.list[i].<prop> рядом с её value. Сама строка содержит background и foreground — цвета строки целиком — и selected, равное true, когда строка выделена, — прямо на строке, а не внутри какой-либо записи свойства.

function Row(props) {
const r = props.row;
const sum = r.sum; // { value, readOnly, disabled, background, foreground, ... }
return (
<div className="order" style={{ background: r.background }}>
<span>{r.number.value}</span>
<input value={sum.value} readOnly={!!sum.readOnly} disabled={!!sum.disabled}
style={{ background: sum.background, color: sum.foreground }} />
</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 — вместе с rowKey, index и любыми другими свойствами, переданными в List, — и отрисовывает каждую строку через мемоизированную обёртку, привязанную к этой строке, поэтому при изменении перерисовываются только реально изменившиеся строки. Простая альтернатива отображает список напрямую:

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/> или, по умолчанию для каждого List, установкой window.lsfusion.listSimple = true. Он вместо этого отображает список и мемоизирует компонент строки, полагаясь на то, что проекция повторно использует ссылку на не изменившуюся строку; компонент строки получает те же 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.value}</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.value);
const rows = props.data.boardEmployees.value; // например, предразобранное JSON-свойство: [{ id, ... }]
return (
<BucketScope group="ss" bucketDeps={[]}
bucketOf={s => dateKey(s.date.value) + '|' + (s.assignedTo.value ?? '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.value} component={Column} />

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

Возврат к lsFusion

custom — переход от платформы к React; lsf = TRUE — переход обратно. По умолчанию компонент рисует всё поддерево контейнера по props.data. Дочерний компонент может вместо этого сохранить своё представление lsFusion: на нём задаётся lsf = TRUE, и компонент не рисует его, а размещает через <Lsf sid/>.

FORM orders 'Orders'
OBJECTS o = Order
PROPERTIES(o) READONLY number, date, sum
PROPERTIES() comment = orderComment // свойство уровня формы, поэтому его запись — data.comment
;

DESIGN orders {
board {
custom = 'Board';
MOVE BOX(o) { lsf = TRUE; } // штатная таблица, размещаемая компонентом
MOVE PROPERTY(comment) { lsf = TRUE; }
}
}

Дочерний компонент с lsf не попадает в props.data — платформа строит его представление, передаёт в него значения свойств и отрисовывает его так же, как в обычном контейнере. Компонент определяет только его место. Исключение — его заголовок и изображение: они попадают в компонент в data, а не в собственное представление дочернего компонента. Свойство с lsf проецирует только { caption, image } — у него нет .value, так как значение платформа рисует вместе с остальным оформлением дочернего компонента. Эта запись есть и тогда, когда у него нет ни того, ни другого: {}. Контейнер с lsf проецируется так же, как любой объявленный в дизайне: компонент читает его caption и image из data и решает, куда поместить представление платформы.

Запись содержит caption и image и находится в том же месте data, что и опции отображения, под тем же именем, по которому лежит значение дочернего компонента:

Дочерний компонент с lsfГде его записьКлюч
Свойство группы объектовdata.<g>.<integrationSID>, рядом с остальными атрибутами колонок группыИнтеграционный SID свойства, qty
Свойство уровня формы (без группы)data.<integrationSID>Интеграционный SID свойства, note
Контейнерdata.<containerSID>, всегда на верхнем уровнеИдентификатор компонента дизайна контейнера, BOX(o)

Контейнер адресуется идентификатором дизайна, потому что другого имени у него нет; свойство адресуется своим интеграционным SID — тем именем, по которому лежит его значение, а не идентификатором дизайна PROPERTY(qty). sid, передаваемый в <Lsf>, — это другое имя: это идентификатор дизайна дочернего компонента в контейнере, поэтому свойство с lsf размещается как <Lsf sid="PROPERTY(note)"/>, а читается как data.note.

Запись в data получает каждый контейнер, которым область React владеет или который она размещает, если он объявлен в дизайне (NEW <имя>) или помечен lsf, — кроме контейнера внутри lsf-поддерева, которое платформа рисует целиком и внутрь которого компонент не заглядывает. Сгенерированная коробка, которую компонент не размещает и которую автор не называл (TOOLBAR(g), PANEL(g), …), записи не получает. Запись входит в проецируемые data, поэтому динамический заголовок или изображение перерисовывают компонент как любое другое изменение данных.

Lsf и useLsf — глобальные переменные времени выполнения, как и List, поэтому перед использованием в примерах ниже их нужно связать с локальными именами: const { Lsf, useLsf } = window.lsfusion;.

Компонент называет каждый размещаемый дочерний компонент и сам рисует заголовок там, где ему нужно:

export function Board(props) {
const data = props.data;
return <div className="board">
<h3>{data['BOX(o)'].caption}</h3>
<Lsf sid="BOX(o)"/>
<h3>{data.comment.caption}</h3>
<Lsf sid="PROPERTY(comment)"/>
</div>;
}

Размещение lsf-компонента

Представление дочернего компонента с lsf переносится в узел размещения — узел DOM, которым владеет React и в который он никогда не отрисовывает дочерние элементы. React размещает представление относительно узла, которым владеет, и владеть им обязан, чтобы продолжать отрисовывать окружающее дерево. Какой это узел — единственное различие между двумя способами размещения:

// узел создаёт платформа — <div> внутри секции
<section className="board-panel"><Lsf sid="BOX(o)"/></section>

// узлом размещения становится собственный элемент компонента — на один узел меньше
<section className="board-panel" ref={useLsf('BOX(o)')}/>

<Lsf> короче. useLsf(sid) возвращает ref-колбэк — для элемента, который компонент отрисовывает и так: панели, карточки, ячейки сетки, — и представление попадает прямо в него.

Всё остальное у них общее. Платформа помечает узел размещения классом lsf-view и атрибутом data-lsf-sid, кто бы его ни создал. Любой узел размещения стилизуется так, что представление его заполняет, каким бы ни был дочерний компонент, поэтому компонент задаёт размер узлу, а представление следует за ним:

.board > .lsf-view[data-lsf-sid="BOX(o)"] { height: 260px; }

Размер задаёт компонент, так как атрибуты width, height, fill и выравнивания дочернего компонента с lsf не применяются: они задают его положение внутри штатного контейнера, а здесь окружающий элемент — это разметка самого компонента. Его caption и image дочерний компонент тоже не рисует: они передаются компоненту в data, поэтому компонент, размещающий дочерние компоненты сам, рисует их там, где ему нужно, — иначе их не нарисует никто:

<section className="slot">
<h3><span dangerouslySetInnerHTML={{ __html: props.data['BOX(o)'].image }}/>
{props.data['BOX(o)'].caption}</h3>
<Lsf sid="BOX(o)"/>
</section>

image — строка с HTML изображения, поэтому вставляется как HTML; caption — обычный текст.

Размещение ограничено следующими правилами:

  • Свойство, отрисовываемое в панели группы объектов, которую рисует компонент, пометить lsf нельзя: у такой группы нет представления lsFusion, в которое его можно поместить. Вместо этого lsf ставится на BOX этой группы. (Свойство, отрисовываемое в таблице такой группы, — это построчный случай ниже, ровно то, для чего нужен LSF.)
  • Дочерний компонент контейнера, который компонент рисует сам, пометить lsf тоже нельзя: у такого контейнера нет собственного представления, и разместить дочерний было бы негде. Пометьте lsf и сам контейнер — тогда представление у него появится.
  • Каждый дочерний компонент с lsf размещается не более чем одним узлом. Не размещённый ни одним узлом дочерний компонент не показывается; повторный узел сообщает о себе на странице и в консоли, а первый сохраняет за собой дочерний компонент.
  • Узел, который отрисовывает <Lsf>, содержит представление lsFusion, поэтому он должен оставаться пустым: ему задаются класс или стиль, но не дочерние элементы.
  • lsf можно ставить только на непосредственный дочерний компонент контейнера CUSTOM REACT; в любом другом месте форма отвергается при сборке.

Размещение, которое не может сработать, сообщает об этом в самом узле, а не только в консоли: sid, не называющий ни одного дочернего компонента контейнера, дочерний компонент без lsf, второй узел для того же дочернего компонента, sid, не являющийся свойством таблицы с LSF, и row, не являющийся строкой, — каждый случай выводит своё сообщение в узел и помечает его классом lsf-view-error.

О дочернем компоненте, который компонент перестал отрисовывать, сообщается серверу как о непоказываемом, и сервер перестаёт читать его данные — так же, как для неактивной вкладки или свёрнутого контейнера. Его группа перестаёт читаться, только если это было последнее место её отображения на форме. Поэтому компонент, показывающий по одному дочернему компоненту, отрисовывает только его, а не прячет остальные средствами CSS: скрытый через CSS компонент для сервера остаётся показываемым и продолжает читаться. По той же причине видимость дочернего компонента с lsf принадлежит только компоненту: его атрибут collapsible игнорируется, а COLLAPSE / EXPAND из скрипта над ним приводит к ошибке.

Живой редактор в каждой строке

Дочерний компонент с lsf рисуется один раз. Свойство таблицы, помеченное в FORM как LSF, рисуется один раз на строку, поэтому компонент может поместить настоящий редактор lsFusion в каждую отрисовываемую им строку — вместо того чтобы показывать значение и строить редактирование самому:

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

DESIGN orders {
NEW board {
custom = 'OrderBoard';
MOVE BOX(o); // строки рисует React, по data.o
MOVE PROPERTY(quantity); // построчные редакторы размещает компонент
MOVE PROPERTY(note);
}
}

LSF говорит, что свойство является компонентом, а не значением, а MOVE — какой контейнер его размещает. Компонент называет свойство и строку:

{data.o.list.map(row => (
<tr key={row.key}>
<td>{row.number.value}</td>
<td><Lsf sid="quantity" row={row}/></td>
<td><Lsf sid="note" row={row}/></td>
</tr>
))}

Передавайте объект строки из проецируемых данных — по одному ключу строку восстановить нельзя. Одно и то же свойство размещается один раз на строку, и только один раз.

Что автор получает и что остаётся за ним:

  • Редактор принадлежит платформе со всем, что из этого следует: редактирование, READONLYIF, BACKGROUND и остальные опции ячейки, — и всё это вычисляется для этой строки.
  • Свойство уходит из строк: теперь это компонент, поэтому row.quantity там нет (запись колонки остаётся, с подписью). Если значение нужно ещё и как данные, объявите второе, обычное свойство.
  • Заголовок в строке не рисуется. Как и у дочернего компонента с lsf, он приходит в узле колонки группы — data.o.quantity.caption, — поэтому компонент размещает его там, где нужно, обычно один раз, в шапке.
  • Отрисовщик существует для каждой строки, которую группа показывает сейчас, независимо от того, отрисовывает компонент эту строку или нет. Строка, ушедшая за пределы прокрутки, свой редактор сохраняет; теряет его только строка, покидающая этот набор.

Объявление отклоняется с указанием свойства, когда работать оно не может: на свойстве таблицы, группа которого не отрисовывается контейнером CUSTOM REACT (построчные редакторы было бы некому размещать), на свойстве таблицы, сгруппированном в колонки (построчный редактор не может адресовать ячейку на пересечении строки и колонки), и на панельном свойстве группы, которую рисует сам компонент (его негде разместить — lsf ставится на бокс группы).

Расширение контейнера с lsf-компонентами

Другой модуль добавляет в контейнер дочерний компонент из DESIGN:

EXTEND FORM orders PROPERTIES(o) rating;
DESIGN orders {
board { MOVE PROPERTY(rating) { lsf = TRUE; } }
}

Сам по себе компонент его не подхватит. Каждый дочерний компонент с lsf размещается тем <Lsf>, который называет его sid, поэтому дочерний компонент, не названный ни одним <Lsf>, не показывается, и такое добавление требует правки JSX: расширение DESIGN объявляет дочерний компонент, а компонент решает, где он окажется. Расположение из DESIGN тоже не расширяется: композицию React там изменить нельзя. Чтобы изменить расположение, компонент заменяется целиком через custom = 'OtherBoard', а дочерние компоненты остаются объявленными в DESIGN.

Выбор между компонентом React и HTML-шаблоном

Компонент, который только размещает дочерние компоненты с lsf и ничего не читает из props.data, делает то же, что и классический пользовательский контейнер: HTML-шаблон располагает те же дочерние компоненты по своим слотам [sID], без рантайма React и без узла-заглушки на каждый из них. Если lsf стоит на всех дочерних компонентах контейнера, в props.data нет ни значений групп, ни значений свойств, поскольку компонент с lsf не проецируется, — только записи { caption, image } дочерних компонентов в props.data.

И тот и другой называют свои дочерние компоненты, поэтому ни один из них не расширяется одним только DESIGN: чтобы добавить дочерний компонент, нужно изменить JSX или строку шаблона. Компонент React оправдан тогда, когда расположение вычисляется — дочерние компоненты размещаются по условию, сетка выводится из данных, прочитанных через useFormData, или разметка берётся из библиотеки компонентов, — а строка шаблона принадлежит объявившему её модулю и заменяется только целиком.

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

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