Перейти к основному содержимому
Версия: 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 — при любом изменении своей области и рисует всё, что рисует, из нового снапшота. Для небольшого представления это вся история: никаких хуков, чистая функция от данных — примеры выше написаны именно так. Это же единственный способ прочитать область целиком: перечислить её верхние записи, прочитать контейнеры.

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

useData и useController читают тот корень, внутри которого находится компонент, и о формах ничего не знают. Lsf и useLsf помещают то, что платформа рисует сама, поэтому одинаково служат и контейнеру формы, и окну навигатора. А построенное на групповой и строчной форме проекции — List, BucketScope, useBucket, Buckets, useSeekOnScroll — существует только для форм: оно обращается к data.<группа>.list / .byKey / .keys, чего у корня другого вида нет.

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

  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. Привязка выполняется там же, где выполняется модуль, а для собранного модуля и для ресурса .jsx это происходит до какой-либо отрисовки, поэтому её можно писать в начале модуля. В ресурсе, написанном сразу как .js, который отдаётся как есть, компоненты и хуки платформы читаются внутри компонента: модуль выполняется раньше, чем монтируется первое представление, а устанавливаются они при этом монтировании:

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, useData } = window.lsfusion;

const Shift = React.memo(({ rowKey }) => {
const s = useData(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) возвращает массив ключей строк, находящихся сейчас в этой ячейке, в порядке отображения группы, и подписывает компонент только на эту ячейку. Вызывайте его один раз в компоненте ячейки, с её фиксированным ключом (обычные правила хуков). Пустая ячейка всегда возвращает один и тот же замороженный пустой массив. Компонент ячейки превращает каждый ключ строки в компонент строки, который подписывается на свою строку через useData(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 name/>.

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). name, передаваемый в <Lsf>, — это другое имя: это идентификатор дизайна дочернего компонента в контейнере, поэтому свойство с lsf размещается как <Lsf name="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 name="BOX(o)"/>
<h3>{data.comment.caption}</h3>
<Lsf name="PROPERTY(comment)"/>
</div>;
}

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

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

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

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

<Lsf> короче. useLsf(name) возвращает 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="board-panel">
<h3><span dangerouslySetInnerHTML={{ __html: props.data['BOX(o)'].image }}/>
{props.data['BOX(o)'].caption}</h3>
<Lsf name="BOX(o)"/>
</section>

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

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

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

Размещение, которое не может сработать, сообщает об этом в самом узле, а не только в консоли: имя, не называющее ни одного дочернего компонента контейнера, дочерний компонент без lsf, второй узел для того же дочернего компонента, имя, не являющееся свойством таблицы с 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 name="quantity" row={row}/></td>
<td><Lsf name="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>, который его называет, поэтому дочерний компонент, не названный ни одним <Lsf>, не показывается, и такое добавление требует правки JSX: расширение DESIGN объявляет дочерний компонент, а компонент решает, где он окажется. Расположение из DESIGN тоже не расширяется: композицию React там изменить нельзя. Чтобы изменить расположение, компонент заменяется целиком через custom = 'OtherBoard', а дочерние компоненты остаются объявленными в DESIGN.

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

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

Шаблону не даётся никаких данных. Он размещает то, что рисует платформа, по имени, а всё остальное в нём написано вручную: он не может прочитать ни заголовок потомка, ни его значок, ни заголовок самого контейнера, и не может вставить их в свою разметку. Размещённое несёт свой заголовок само — его рисует платформа внутри размещённого представления, — а заголовок и значок самого контейнера платформа рисует рядом с контейнером, а не внутри шаблона. Поэтому собственный заголовок, который должен показать вычисляемое приложением значение, — это та граница, за которой шаблона уже не хватает.

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

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

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

Компонент React может рисовать и окно навигатора — само меню — вместо стандартной панели. Имя компонента задаётся WINDOW, а данными компонента становятся элементы, помещённые в это окно:

WINDOW appMenu VERTICAL POSITION(0, 6, 20, 94) HIDETITLE CUSTOM 'AppMenu';

NAVIGATOR {
NEW FOLDER sales 'Продажи' WINDOW appMenu PARENT {
NEW orders;
NEW invoices;
}
}
function AppMenu({ data, controller }) {
return <nav>{data.root.map(name => {
const e = data.byName[name];
return <button key={name} className={e.selected ? 'on' : ''}
onClick={ev => controller.activate(name, ev.nativeEvent)}>{e.caption}</button>;
})}</nav>;
}

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

EXTEND WINDOW System.toolbar CUSTOM 'AppMenu';

props.data — собственные элементы этого окна по каноническому имени; канонические имена содержат точки, поэтому они являются ключами отображения, а не полями:

ПолеЗначение
rootКанонические имена элементов, рисуемых на верхнем уровне, в порядке отображения
byNameКаждый элемент по каноническому имени

а каждый элемент содержит:

ПолеЗначение
nameСобственное каноническое имя, чтобы компонент, получивший одну запись, мог обратиться к элементу
caption, elementClassЗаголовок и CSS-класс, текущие — выражение HEADER или CLASS применяется по мере изменения
imageЗначок, рисуется через <Image value={e.image}/> — см. ниже — либо null
folderЯвляется ли элемент папкой, а не действием
hiddenСкрыт ли он сейчас через SHOWIF. В отличие от стандартной панели, которая такой элемент просто не рисует, проекция его сохраняет, и компонент сам решает, как с ним поступить
selectedЯвляется ли он выбранным элементом этого окна
childrenКанонические имена его потомков, которые рисует это окно

Элемент, объявленный LSF, рисует платформа, поэтому то, что она рисует, не проецируется: в его записи остаются name, caption, image, children и lsf: true, и больше ничего.

Окно получает те элементы, за которые отвечает: элемент, ушедший в другое окно, и поддерево, отсечённое потому, что его родитель не выбран, отсутствуют — по тому же правилу, по которому определяется содержимое стандартной панели. Всё, что это правило оставило, проекция сохраняет, скрытое оно или нет. props.controller — контроллер навигатора, чей activate делает то же, что нажатие на элемент: выбирает папку либо выполняет действие.

Компонент рисует только веб-клиент на компьютере. Мобильный веб-клиент и десктоп-клиент отрисовывают для этого окна своё стандартное меню, поэтому навигатор остаётся рабочим в каждом из них.

Стандартная кнопка

Элемент, который компонент не хочет рисовать сам, помещается через <Lsf name/> — тот тег, которым на форме размещается потомок дизайна, где name — каноническое имя элемента. Платформа ставит внутрь свою кнопку, со своим значком, заголовком, подсказкой и поведением при нажатии; сам узел остаётся таким, каким его нарисовал компонент, без пометок, которые получает узел на форме:

const { Lsf } = window.lsfusion;

function AppMenu({ data, controller }) {
return <nav>
<Lsf name="Sale.sales" className="menu-main"/>
{data.root.filter(name => name !== 'Sale.sales').map(name =>
<button key={name} onClick={ev => controller.activate(name, ev)}>{data.byName[name].caption}</button>)}
</nav>;
}

Элемент должен быть объявлен LSF в NAVIGATOR — переход обратно объявляется на стороне навигатора так же, как lsf = TRUE объявляет его на форме:

NAVIGATOR {
Sale.sales LSF;
}

Элемент с LSF, которого не назвал ни один <Lsf>, не показывается — так же, как на форме. Четыре ошибки сообщаются прямо на странице: имя, которого нет ни у одного элемента навигатора, имя без LSF — такой элемент компонент должен рисовать сам, по проекции, — имя элемента, который рисует другое окно, и имя, которое размещает второй <Lsf>. Имя, которое окно сейчас просто не рисует — элемент, скрытый SHOWIF, отсечённый потому, что его родитель не выбран, — ошибкой не является и не сообщается: место остаётся пустым и ждёт, а заполняется, как только окно начнёт рисовать этот элемент. То, в каком окне рисуется элемент, к таким случаям не относится: ждать кнопку, которой владеет другое окно, пришлось бы вечно.

Почти стандартное меню пишется так: по одному <Lsf> на каждый элемент, который рисует платформа, и разметка только для тех, которые компонент рисует сам.

Окну, меню которого нужна только своя разметка, вместо имени компонента задаётся HTML-шаблон — тот же, который принимает контейнер custom, где место <Lsf:name> принимает стандартную кнопку элемента. Имя разрешается так же, как любое другое имя в модуле, поэтому писать его полностью не нужно, а место пишется открытым — закрыть его нельзя:

EXTEND WINDOW System.toolbar CUSTOM
'<div class="menu"><div class="menu-head">Справочники</div><Lsf:items><Lsf:partners></div>';

Элемент должен быть объявлен раньше шаблона, который даёт ему место, — блок NAVIGATOR, создающий его, идёт выше. Имя, которое ни во что не разрешается, — ошибка при чтении модуля, поэтому опечатка останавливает приложение, а не оставляет меню без пункта.

Элемент, которому шаблон не дал места, не рисуется, поэтому шаблон называет всё, что показывает, и LSF ни на одном из них не нужен — шаблон размещает только отрисовки самой платформы.

Шаблон можно и вычислять: если вместо литерала задано свойство, он пересчитывается по мере изменения значения, и окно рисуется заново по новой разметке, поэтому меню может перестраиваться и без компонента. Вычисляемый шаблон ничто не разрешает, поэтому его места называют элементы полностью, каноническим именем. Компонент остаётся нужен тогда, когда разметка зависит от того, что компонент читает сам.

Окно форм

Компонент React может рисовать и окно, в котором открываются формы, вместо стандартных вкладок. Окну WINDOW System.forms задаётся имя компонента, а его данными становятся открытые формы:

EXTEND WINDOW System.forms CUSTOM 'FormsBoard';
const { Lsf } = window.lsfusion;

function FormsBoard({ data, controller }) {
const current = data.open.find(name => data.byName[name].selected);
return <div className="board">
<div className="board-bar">{data.open.map(name => {
const e = data.byName[name];
return <span key={name} className={e.selected ? 'on' : ''}>
<a onClick={() => controller.select(name)}>{e.caption || '...'}</a>
{!e.blocked && <a onClick={() => controller.close(name)}>x</a>}
</span>;
})}</div>
{current && <Lsf key={current} name={current} className="board-current"/>}
</div>;
}

Форму компонент не рисует никогда: он рисует место для той формы, которую хочет показать, а платформа переносит туда её собственное представление. Этим местом и является <Lsf name/>, где name — имя, которое проекция даёт форме. Представление формы он размещает так же, как размещает дочерний элемент дизайна и элемент навигатора, и ошибки здесь выводятся те же.

props.data — открытые формы и состояние самого окна:

ПолеЗначение
openИмена открытых форм в том порядке, в котором их показывает стандартная полоса
byNameКаждая форма по имени
editModeИмя выбранного режима редактирования — того, который выбирает кнопка режима на панели платформы и который сохраняется между сеансами. Режим, включённый удержанием Ctrl, Shift или Alt, действует, пока клавиша нажата, и editMode не меняет
editModesВсе режимы редактирования в том порядке, в котором их предлагает кнопка режима
fullScreenРазвёрнуто ли окно форм на весь экран

и каждая форма несёт:

ПолеЗначение
nameСобственное имя, чтобы компонент, которому передали только запись, всё равно мог обратиться к форме
canonicalNameКаноническое имя формы — для компонента, который рисует конкретную форму по-своему
caption, imageЗаголовок и значок, текущие: заголовок, который показывает вкладка, и его значок. Рисуются через <Caption value={e.caption}/> и <Image value={e.image}/>
selectedЯвляется ли форма текущей — той, в которой работает клавиатура
loadingНе пришла ли форма ещё с сервера. Пока не пришла, caption — тот, который нёс запрос на её открытие, а image пуст; остальные поля записи на месте, как всегда
blockedОткрыта ли поверх этой формы форма, вызванная из неё. Такую форму закрыть нельзя — поэтому стандартная вкладка выключает у неё кнопку закрытия

а каждый режим редактирования в editModes — то, чем его показывает кнопка режима:

ПолеЗначение
nameСобственное имя режима — то, которое принимает setEditMode, и то, с которым сравнивается editMode
caption, imageНазвание режима и его значок. Рисуются через <Caption value={m.caption}/> и <Image value={m.image}/>

props.controller делает то, что с открытой формой может сделать только платформа: select(name) делает её текущей — так же, как щелчок по её вкладке, — а close(name) просит её закрыться, как её кнопка закрытия. Закрытие — это просьба: форма с несохранёнными изменениями сначала спросит пользователя и может остаться открытой, поэтому форма исчезает из open тогда, когда действительно закрылась, а не когда был вызван close.

Форма, которую компонент никуда не поместил, остаётся открытой и скрытой — ровно то, чем уже является фоновая вкладка: стандартная полоса просто прячет формы, которые не показывает. Поэтому компонент, помещающий только текущую форму, даёт приложение без вкладок, в котором все открытые ранее формы никуда не делись, и, поместив форму снова, её видно в том состоянии, в котором её оставили. Закрывается форма только через close(name) или closeAll() — либо так же, как она закрывается без компонента.

Собственная панель платформы — режим редактирования, полный экран, мобильное меню — расположена в стандартной полосе вкладок, поэтому окно, которое рисует компонент, её не показывает. То, что она делает с самим окном, компонент делает через контроллер: setEditMode(name) выбирает режим редактирования — так же, как кнопка режима, — где name — имя режима из editModes, а toggleFullScreen() разворачивает окно форм на весь экран и возвращает обратно — так же, как кнопка полного экрана и ALT+F11. Отсюда же доступно и то, что предлагает контекстное меню стандартной вкладки: closeAll() просит закрыться каждую открытую форму, начиная с последней в этом порядке; закрытие каждой из них — такая же просьба, поэтому форма, которая не закрылась, остаётся открытой, а остальные закрываются. Выбранный режим и полный экран платформа знает сама, поэтому она их и проецирует: режим, выбранный компонентом, и полный экран, включённый по ALT+F11, компонент видит одинаково — в props.data. Мобильное меню компоненту не нужно: на мобильном веб-клиенте окно форм рисует платформа.

Компонент рисует только десктопный веб-клиент. Мобильный веб-клиент и десктоп-клиент оставляют своё стандартное окно форм.

Окно сообщений

Компонент React может рисовать и окно, в котором появляются сообщения пользователю, вместо стандартной панели. Окну WINDOW System.log задаётся имя компонента, а его данными становятся выведенные сообщения:

EXTEND WINDOW System.log CUSTOM 'MessageLog';
const { Lsf } = window.lsfusion;

function MessageLog({ data, controller }) {
return <div className="log">
<a className="log-pin" onClick={() => controller.togglePin()}>pin</a>
{data.messages.map(name => {
const m = data.byName[name];
return <div key={name} className={m.failed ? 'log-failed' : 'log-ok'}>
<div className="log-line">{new Date(m.time).toLocaleTimeString()} {m.caption}</div>
<Lsf name={name}/>
</div>;
})}
</div>;
}

Сообщение компонент не рисует никогда: сообщение — это разметка, которую построила платформа (текст со значком или таблица, выведенная действием рядом с ним), поэтому компонент рисует место для каждого сообщения, которое хочет показать, а платформа переносит туда эту разметку. Этим местом и является <Lsf name/>, где name — имя, которое проекция даёт сообщению. Разметку сообщения он размещает так же, как размещает дочерний элемент дизайна, элемент навигатора и открытую форму.

props.data — выведенные сообщения:

ПолеЗначение
messagesИмена выведенных сообщений, начиная с последнего — в том порядке, в котором их показывает стандартная панель
byNameКаждое сообщение по имени

и каждое сообщение несёт:

ПолеЗначение
nameСобственное имя, чтобы компонент, которому передали только запись, всё равно мог поместить сообщение
captionЗаголовок действия, которое вывело сообщение; рисуется через <Caption value={m.caption}/>
timeВремя появления сообщения в миллисекундах — то, которое стандартная панель печатает в строке с датой
failedЯвляется ли сообщение ошибкой — единственное различие между двумя сообщениями, которое делает панель, и то, по которому компонент их оформляет

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

props.controller делает то, что с окном сообщений может сделать только платформа: togglePin() переключает режим закрепления окна так же, как кнопка закрепления платформы. Закрепление — настройка среды отображения, общая с десктоп-клиентом, поэтому компонент не может выполнить её сам; текущий режим ему тоже не передаётся, потому что его не знает и клиент: незакреплённое окно делает незакреплённым класс, который вычисляет сервер.

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

Само окно остаётся окном платформы: компонент рисует внутри него, поэтому окно так же скрывается, выдвигается поверх формы и показывается при появлении сообщения, как и раньше.

Компонент рисует только десктопный веб-клиент. Десктоп-клиент оставляет свою стандартную панель сообщений, а на мобильный веб-клиент, в раскладке которого окна сообщений нет вовсе, это не влияет.

Отрисовка изображения

Изображение, которое проецирует платформа, — свойство класса изображения, значок элемента навигатора или действия — рисуется компонентом Image:

const { Image } = window.lsfusion;

<Image value={e.image} className="menu-icon"/>
<Image value={row.photo.value} className="avatar"/>

Проецируемое значение — это либо адрес, либо готовый элемент, поскольку у шрифтового значка адреса нет вовсе, и Image рисует то, что ему передали. Представлению, которому нужен сам адрес — например, для фона в CSS, — он по-прежнему доступен в значении; Image существует для того, чтобы разметку платформы не приходилось вставлять руками. Отсутствующее изображение не рисуется.

Отрисовка заголовка

Заголовок, который проецирует платформа, — заголовок формы, элемента навигатора или сообщения — рисуется компонентом Caption:

const { Caption } = window.lsfusion;

<Caption value={e.caption} className="tab-caption"/>

Заголовок — это либо обычный текст, либо разметка, построенная платформой, и Caption рисует то, что ему передали, по тому же правилу, по которому платформа рисует собственный заголовок: значение считается разметкой, если тег встречается в нём где угодно. Напечатанный как текст, такой заголовок показал бы собственную разметку, поэтому представление, которое печатает {e.caption} само, право только пока заголовок — обычный текст. Отсутствующий заголовок не рисуется.