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

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

Контейнер DESIGN может отрисовываться компонентом React вместо стандартной раскладки. Компонент получает проекцию состояния формы и сам рисует всё поддерево контейнера. Таблицу, тулбар, управляющие элементы фильтров, группу фильтров и фильтр по свойству так же может рисовать собственный компонент React вместо их представления платформы (Собственное представление компонента).

Это возможность только веб-клиента. Десктоп-клиент десериализует контейнер и отрисовывает его обычное (не 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 вместо стандартной таблицы. Компонент, не являющийся контейнером, принимает только имя компонента React — см. Собственное представление компонента.

Форме, открываемой окном (SHOW ... FLOAT; у DIALOG окно — расположение по умолчанию), размер вычисляется по содержимому в момент открытия и дальше не меняется: измеряется первая отрисовка компонента, по первым данным формы, а то, что он рисует позже, - новые строки, данные, пришедшие после открытия, - уже нет. Поэтому контейнеру, содержимое которого растёт после открытия, задают базовый размер: size = (900, 600) либо отдельные атрибуты width и height. Без него окно сохраняет размер первой отрисовки, а нарисованное позже содержимое вытесняет кнопки OK / Закрыть за край окна. Закладке (WINDOW) размер задаёт окно форм, поэтому там базовый размер не нужен.

Другой путь — не задавать базовый размер контейнеру React, а сделать динамическим само окно: в DESIGN самой формы задать её главному контейнеру size = (-1, -1) либо только height = -1 — тогда ширина измеряется и фиксируется при открытии, а высота остаётся динамической. По каждому динамическому направлению окно следует за содержимым компонента, в том числе отрисованным позже. Это годится для формы, на которой нет таблиц (их высота перестаёт подстраиваться под строки), а высота содержимого умеренная: окно центрируется только при открытии, а его высота не ограничена экраном — окно выше экрана прокручивается целиком.

Базовый размер ограничивает и высоту самого контейнера, где бы форма ни открывалась. Без него высота контейнера — это высота того, что нарисовал компонент, и контейнеры над ним растут вместе с ним, поэтому компонент, нарисовавший больше, чем помещается на форме, растягивает всю форму, а прокручивается она целиком — в закладке её главный контейнер. Базовая высота (height либо size) отделяет размер контейнера от размера содержимого: контейнер получает её, по коэффициенту расширения (fill) расширяется из неё в свободное место формы, а не поместившееся содержимое прокручивается внутри него (overflowVert по умолчанию auto). Форму она растягивает, только когда сама в неё не помещается, — как базовый размер любого компонента.

Компонент​

OrderBoard — именованный экспорт из модуля .jsx в каталоге src/main/web. Как этот модуль компилируется и регистрируется, описано в How-to: Пользовательские клиентские JS-модули. Примеры здесь используют JSX. Для проекта без сборки поместите тот же компонент в файл .jsx — он преобразуется на сервере при отдаче. import и export там недоступны, поэтому ключевое слово export опускается, а компонент работает с предоставляемым платформой 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 — проекция формы, своя у каждого контейнера CUSTOM REACT: в ней только то, что этот контейнер рисует или размещает, поэтому второй React-контейнер формы получает отдельную проекцию, а вместе с ней и собственный контроллер.

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

Каждое свойство, группа и контейнер, которые несёт проекция, — это объект в data: значение читается как .value, его атрибуты — как соседние поля, а атрибуты колонки списочного свойства — из data.<group>.<prop>. Записи, которые контейнер несёт для свойств и компонентов, — данные дизайна: каждая из них есть с самого начала, а показывает ли её форма сейчас, говорит её поле hidden, а не то, есть ли она. data содержит группу, когда представление рисует ЧАСТЬ этой группы, и содержит ровно те части, которые рисует. Часть группы или дерева проецируется — её данные и члены контроллера, которые её меняют, — в тот React-контейнер, где находится компонент этой части, в узел её группы или дерева:

КомпонентЧастьУзел
Грид группы, GRID(<group>), или её дерево, GRID(TREE <tree>)Строки и текущий объект, а у группы вне дерева с ними и сортировкиdata.<group>
Панельное свойство — которое рисует платформа, там, где его размещает представлениеСвойство — которое рисует платформа, записью, которая его подписывает (запись действия, которое рисует платформа, говорит только, показывает ли его форма: его заголовок и изображение — лицо его кнопки)data.<group>, у пустой группы — сам data
То, что содержит FILTERS(<group>)Пользовательские фильтрыdata.<group>
FILTERCONTROLS(<group>)Управляющие элементы фильтровdata.<group>
FILTERGROUP(<filterGroup>)Группа фильтровdata.<group>
Фильтр по свойству, FILTER(<property>)Условие фильтраdata.<group>, в filter
TOOLBARSYSTEM(<group>)Тулбарdata.<group>
Дерево, GRID(TREE <tree>)Текущая строка дерева, раскрытие его текущего узла и его сортировкиdata.<tree>
То, что содержит FILTERS(TREE <tree>), FILTERCONTROLS(TREE <tree>), TOOLBARSYSTEM(TREE <tree>)Пользовательские фильтры дерева, его управляющие элементы фильтров и тулбарdata.<tree>

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

Место компонента находится подъёмом вверх от его контейнера: ответ — контейнер CUSTOM REACT, а контейнер с собственным представлением — custom = NULL или свой custom — подъём обрывает, потому что всё под ним рисует это представление. Есть ли собственное представление у самого компонента, решает только то, какая запись у него там будет — его содержимое или запись, которая его называет, — но никогда не то, где. Представление, которое рисует строки — грид лежит внутри кастомного контейнера, и ни у него самого, ни у контейнера между ними нет собственного представления, так что строки рисует React из data, а не платформа, — получает props.data.<g> равное { list, byKey, keys, options, orders, properties } для этого SID группового объекта g — у группы дерева orders нет: сортировки дерева лежат в его собственном узле, — где list — массив строк в порядке отображения, byKey сопоставляет строковому ключу строки тот же объект строки, а keys — массив этих строковых ключей в том же порядке. Ключи byKey — это данные приложения, поэтому он создаётся БЕЗ прототипа: строка с ключом __proto__ лежит в нём обычной записью, а byKey.hasOwnProperty и прочие методы Object.prototype вызвать не на чем — читайте через byKey[k], а проверяйте через k in byKey или Object.keys(byKey). Там, где представление рисует строки, list, byKey и keys присутствуют все: пока у группы нет строк — например, до прихода её первых строк, — они пусты, поэтому представление читает props.data.<g>.list напрямую, без проверки на отсутствующее поле. Грид (или дерево), который форма скрывает через SHOWIF, говорит об этом в своей записи, data['GRID(g)'].hidden — у дерева это data['GRID(TREE t)'], одна на все его группы, — а его строки остаются такими, какими сервер прислал их последний раз, как их хранит и грид самой платформы, — пока грид скрыт, сервер может их не обновлять, — поэтому рисуйте их, только пока грид не скрыт. Там, где не рисует, у узла их нет, как нет и options и orders (сортировок группы — порядка, в котором рисуются эти строки): здесь нет строк, о которых они могли бы говорить, и одним из этих имён может называться панельное свойство группы. Группа в виде PANEL не рисует строки нигде — сервер их ей не присылает, только текущий объект, — поэтому её узел такой везде, где бы ни был её грид, включая контейнер, в котором он лежит: её панельные свойства и никакого change(row). Группа, ни одной части которой представление не рисует, отсутствует в props.data целиком (props.data.<g> — undefined): контейнер, просто стоящий рядом с платформенным гридом, не получает ничего, как и тот, который лишь обрамляет его (MOVE GRID(g) { custom = NULL; } — там строки рисует платформа, поэтому контейнер получает запись, называющую то, что он размещает, и никаких строк; тот, в котором стоит и FILTERS(g), получает пользовательские фильтры группы). Запрос строк у <List>, когда их здесь нет, — ошибка в представлении, и он бросает исключение: представление показывает причину на своём месте и в консоли. Панельные свойства группы являются полями узла группы в том контейнере, в котором находится само свойство, по интеграционному SID. Верхний уровень props.data — это узел пустой группы (уровень формы): каждое свойство пустой группы является полем props.data напрямую, по своему интеграционному SID. Атрибуты колонки списочного свойства тоже являются полем группы, в data.<g>.<prop> — по одной записи на колонку, — а его построчное значение и атрибуты ячейки лежат в каждой строке. Действия, которые рисует React, проецируются так же, как свойства: действие, добавленное на группу, является полем каждой строки (списочное действие) или узла группы (панельное действие) — объектом своих атрибутов, как свойство, — поэтому controller.<group>.<action>.exec(row) его выполняет (.exec() панельного действия строку не принимает). Поле value у него тоже есть, но ничего не несёт. У действия, которое рисует платформа, есть только запись, а выполняет его кнопка платформы. options — собственные опции отображения группы, properties называет каждую запись СВОЙСТВА, которую несёт этот узел, показываемую или нет, в порядке формы, — а не всё, что на узле лежит: там есть и то, что определяет сама проекция (list, byKey, keys, options, orders), и это известно по имени, а не обнаруживается. Число строк — это list.length. Каждая строка содержит:

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

Поэтому MOVE панельного свойства и решает, какой движок его рисует: внутри кастомного контейнера его рисует React по data, вне его — платформа, там, где оно теперь стоит, и из data оно уходит.

Контейнер с собственным custom внутри того, что рисует компонент React, — другим компонентом React, HTML-шаблоном, '' — этим компонентом не рисуется: его рисует его собственное представление, а внешний компонент размещает его так же, как любой другой дочерний компонент, который рисует платформа. Так же и таблица, тулбар, управляющие элементы фильтров, группа фильтров или фильтр по свойству с собственным компонентом React.

Свойство, сгруппированное по колонкам (COLUMNS), спроецировать нельзя: его значения адресуются ключом «строка и колонка», а всё здесь — строка list, запись byKey, имя на контроллере — означает одну строку. Поэтому React-контейнер не может нести такое свойство — ни колонкой строк, которые он рисует, ни панельным свойством или свойством пустой группы, стоящим в нём: такая форма отклоняется при построении с указанием свойства. В остальной части формы оно остаётся как есть, а у React-представления просто нет для него имени: контроллер несёт то же, что и проекция, поэтому у неспроецированного нет члена. То же верно всегда, когда имя не выделяет одно свойство: два свойства спроецированной группы могут делить интеграционное имя, только если их записи попадают в разные контейнеры, и тогда каждое представление имеет в виду то, что несёт его собственный узел. В одном контейнере так нельзя — одна запись и один член не могут означать два свойства; и нигде нельзя, в том числе со свойством, которое рисует платформа, если представление рисует часть, которая называет свойство группы (Зарезервированные имена). Задайте одному из них явный EXTID. Если представлению нужны эти значения, объявите обычное свойство.

Член принимает строку только там, где рисуются строки группы, — это <group>.change(row) и член списочного свойства, который живёт там же, — и там он принимает объект строки, её дескриптор objects либо строковый ключ, который дала ей проекция: ключ ищется в byKey этого представления. Член панельного свойства и член пустой группы строку не принимают: они действуют на текущих объектах. Что означает каждый из трёх, сказано в разделе Идентификация строки.

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

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

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

Границы интервала — миллисекунды с 1970 года: граница INTERVAL[ZDATETIME] — сам момент, а остальные интервальные классы хранят свои дату и время как время UTC — new Date(from).getUTCDate() — день, с которого начинается INTERVAL[DATE]. Значение, которое представление передаёт обратно — в change члена или как value условия, — читается так, как эта таблица его записывает, поэтому значение, взятое из data, возвращается как есть — кроме файлового: ссылка не файл, — а COLOR или интервал, записанный иначе, отклоняется.

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 — следует за ними.

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

Число прочитанных строк — list.length, а не число строк, пропущенных фильтрами группы, поэтому при постраничном чтении по одной проекции «6 из 23» не показать. Итог — отдельное свойство, которое считает строки в текущем отборе формы оператором FILTER, как в How-to: Состояние таблиц, и размещено внутри контейнера, чтобы попасть в data:

filteredCount 'Orders' = GROUP SUM 1 IF [ FILTER orders.o](Order o);

EXTEND FORM orders PROPERTIES() filteredCount;
DESIGN orders { BOX(o) { MOVE PROPERTY(filteredCount()); } }
const { o, filteredCount } = props.data;
<div>{o.list.length} из {filteredCount.value ?? 0}</div>

Если вместо этого нарисовать свойство для группы (PROPERTIES() filteredCount DRAW o PANEL), MOVE не нужен: его место по умолчанию — в PANEL(o), внутри BOX(o). Оно становится членом узла группы и читается как data.o.filteredCount.value.

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

Зарезервированные имена​

Резервирование следует за тем, что несёт строка или узел:

ГдеЧто зарезервировано
Строкаkey, isCurrent, objects, background, foreground, selected — у строки дерева ещё и parent, hasChildren, expanded, loading
Узел группы, где бы он ни былproperties, change
Узел группы там, где рисуются её строкиlist, byKey, keys, options, а у группы вне дерева и orders — у группы дерева expand, collapse, toggle, expandAll, collapseAll
Узел группы в виде LIST там, где стоит FILTERS(<group>)filters, filterProperties
Узел группы в виде LIST там, где стоит FILTERCONTROLS(<group>)Имена управляющих элементов фильтров: addFilter, applyFilters, resetFilters, filtersPending, filtersManual
Узел группы в виде LIST там, где стоит TOOLBARSYSTEM(<group>), если дизайн его не прячет (visible = FALSE)Имена всех кнопок тулбара, что бы ни говорили флаги дизайна show…: manual, updatePending, update, filterControlsVisible, listView, listViews, getCount, getSum, export
Узел группы там, где стоит FILTERGROUP(<filterGroup>)Собственное имя группы фильтров, как у свойства
Узел группы там, где рисуется фильтр по её свойству, FILTER(<property>)filter
Верхний уровень, где узел пустой группы — это ещё и сам контроллерИмена собственных членов контроллера: exec, eval, evalAction, change, startEditing, stopEditing, isEditing, batch

Собственные current и expandCurrent дерева и его сортировки, там, где рисуются его строки, а также его пользовательские фильтры, управляющие элементы фильтров и тулбар пишут свои имена в его узел. В контейнере, несущем лишь панельное свойство группы, имён остальных частей нет, поэтому свойство с именем list там ничем не отклоняется. __proto__ зарезервировано везде: присваивание по этому имени не добавляет поле, а заменяет прототип объекта. Объекта meta нет нигде. Форма, в которой проецируемый интеграционный SID занимает зарезервированное имя или два проецируемых элемента занимают одно имя на одном уровне данных, отклоняется с явной ошибкой при её построении. Как и форма, в которой группа, строки или пользовательские фильтры которой рисует представление или тулбар которой с суммой рисует оно, вообще несёт одно имя на двух свойствах, в том числе на нарисованном платформой: сортировка, условие и getSum тулбара называют свойство группы этим именем. Задайте ей явный EXTID или переименуйте. Свойство, которое такой контейнер рисует или размещает, вообще должно иметь имя - представление достаёт его только по этому имени, и запись, и <Platform>: объявленное с NOEXTID отклоняется так же - задайте ему EXTID. Пустое имя или имя с точкой тоже отклоняется — составное имя, o.note, делится по последней точке, — как и группа из нескольких объектов без собственного имени, OBJECTS (d = X, t = Y), там, где представление несёт её часть: её SID — имена объектов через точку, поэтому назовите её, OBJECTS pair = (d = X, t = Y). Свойство или действие, которое рисует платформа, называется так же, как любое другое, и всё это относится и к нему.

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

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

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

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

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

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

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

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

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

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

ГдеЧто содержит
data.<g>.<integrationSID>Для свойства, показываемого в таблице, — атрибуты его колонки, по одной записи на всю колонку: caption, image, footer, comment, tooltip, defaultValue и hidden, пока форма его скрывает. Для панельного свойства группы — value свойства и его атрибуты, вычисленные для текущего объекта, и hidden, пока форма его скрывает; пока текущего объекта у группы нет, value пустое, а запись остаётся, как и в панели самой платформы. Для свойства, которое рисует платформа, — запись, которая его подписывает, без value
data.<g>.list[i].<integrationSID>Одна ячейка свойства таблицы — её value для этой строки и атрибуты ячейки, вычисленные для этой строки: readOnly, disabled, background, foreground и остальные
data.<g>.list[i]Собственные опции строки, прямо на строке: background, foreground, selected
data.<g>options — пользовательские опции группы, там, где представление рисует её строки; properties — записи СВОЙСТВ, которые несёт этот узел, показываемые или нет
data.<integrationSID>Свойство пустой группы — его value, атрибуты и hidden, пока форма его скрывает; свойство, которое рисует платформа, — запись, которая его подписывает, без value
data.<componentSID>Запись любого другого компонента, который это представление рисует или размещает, — контейнера, объявленного через NEW или сгенерированного, как PANEL(o) или TOOLBAR(o); грида, как GRID(o), или дерева, как GRID(TREE t); панели инструментов, как TOOLBARSYSTEM(o); фильтра по свойству, как FILTER(name(o)); и любого компонента, который рисует платформа, кроме свойства: его caption и image, если они у него есть, и hidden, пока форма его скрывает, по идентификатору компонента дизайна — всегда на верхнем уровне, во что бы компонент ни был вложен. Запись есть всегда; у показанного компонента без заголовка и изображения она пуста

Каждый атрибут присылается в одном месте, уже вычисленным: статическое значение из дизайна свойства и его построчный результат 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 управляет самим свойством, и меняет он поле hidden записи, а не то, есть ли запись. Когда он скрывает колонку таблицы, запись колонки говорит hidden: true, а ячейки колонки остаются в строках без value. Когда он скрывает панельное свойство или свойство пустой группы, его запись говорит hidden: true и не имеет value. Свойство, которое форма скрывает при открытии, остаётся hidden, пока не будет прислано впервые. Если нужно отличить скрытое свойство от свойства со значением value, равным null, читайте hidden. У записи, которую форма показывает, поля hidden нет вовсе — как нет и любой опции, для которой платформа ничего не вычислила.

ОпцияЧто этоЗначение в JS
typeВид, которым было преобразовано значение, — number, boolean, string, date либо json (разобранный, поэтому значением будет то, что лежало в JSON); у выбора — string: его значение составлено из представлений вариантов, а они — текст, как их показывает собственный элемент платформы. Он описывает свойство, а не одно из его значений, поэтому лежит вместе с атрибутами колонки: data.<g>.<prop>.type для свойства таблицы и в самой записи для панельного свойства или свойства пустой группы. В ячейке его нетстрока
askConfirmСпрашивает ли изменение свойства — для действия его запуск — подтверждение у пользователя: CONFIRM у действия или askConfirm = TRUE в дизайне. Редактор и кнопка платформы спрашивают сами, до отправки; вызов представления через член свойства — нет, поэтому представление, которому нужен этот вопрос, задаёт его само, до вызова. Лежит там же, где typetrue / false
askConfirmMessageТекст вопроса: askConfirmMessage дизайна или стандартный текст платформыстрока; только при askConfirm, равном true
hiddenСкрывает ли форма свойство сейчас — его SHOWIF или SHOWIF контейнера выше. Лежит в записи свойства — в записи колонки для свойства таблицы, в самой записи для панельного свойства или свойства пустой группы — и никогда в ячейкеtrue; нет, пока форма его показывает
loadingЖдёт ли ещё ответа сервера изменение ячейки, кто бы его ни отправил, — это представление через член свойства (change или exec, в пакете или вне его) или другая часть формы. Ячейка действия говорит это, пока действие выполняется, а запись кнопки «Обновить» формы — пока форма ждёт сервер. Лежит в ячейке свойства таблицы и в самой записи панельного свойства или свойства пустой группыtrue; иначе отсутствует
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Значение, с которого начинается редактированиестрока
selectТо, что добавляет выбор, — его варианты и способ выбора. Лежит в ячейке свойства таблицы и в самой записи панельного свойства или свойства пустой группыобъект

Из них caption, image, footer, comment, tooltip и defaultValue — собственные атрибуты колонки, в узле колонки data.<g>.<prop> (по одной на колонку), рядом с type. Остальные (background, foreground, readOnly, disabled, placeholder, pattern, regexp, regexpMessage, valueTooltip, options, select, loading) — атрибуты ячейки, в ячейке каждой строки 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 group="o" component={Row} />
// либо, без псевдонима:
React.createElement(window.lsfusion.List, { group: 'o', component: Row })

group — SID группы объектов. List читает строки группы из собственной проекции представления по этому имени, так же как это делает BucketScope, поэтому ему передаётся имя, а не узел. keys отрисовывает только часть строк, в заданном порядке, — любой массив ключей строк группы, например отфильтрованную или пересортированную копию props.data.o.keys:

<List group="o" keys={props.data.o.keys.filter(k => props.data.o.byKey[k].sum.value > 0)} component={Row} />

Ключ, которого в группе нет, не отрисовывает ничего. List, получивший группу, строки которой это представление не рисует, — такую, от которой у представления есть лишь панельное свойство или нет ничего вовсе, — бросает исключение: рисует ли представление строки группы, задаёт дизайн формы, а не её данные, поэтому это ошибка в представлении, и представление показывает причину на своём месте и в консоли. List рисует те строки, которые есть в проекции, а грид, который форма скрывает через SHOWIF, свои строки сохраняет, как и грид самой платформы: если у грида есть SHOWIF, рисуйте List, только пока props.data['GRID(o)'].hidden не установлен.

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

List задаёт каждой строке ключ row.key, передаёт строку в компонент как props.row — вместе с rowKey, index и любыми другими свойствами, переданными в List, кроме его собственных group, keys и component — или children, который задаёт компонент строки, когда component не задан, — и отрисовывает каждую строку через мемоизированную обёртку, привязанную к этой строке, поэтому при изменении перерисовываются только реально изменившиеся строки. Простая альтернатива отображает список напрямую:

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.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 группы объектов. Группа, строки которой это представление не рисует, приводит к исключению — так же, как в List. 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. Группировка работает только по собственным спроецированным значениям группы.

Страница из нескольких контейнеров​

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

CLASS Category;
name = DATA ISTRING[100] (Category);

CLASS Item;
name = DATA ISTRING[100] (Item);
category = DATA Category (Item);
archived = DATA BOOLEAN (Item);

CLASS Detail;
item = DATA Item (Detail);
name = DATA ISTRING[100] (Detail);
quantity = DATA INTEGER (Detail);

showArchived = DATA LOCAL BOOLEAN ();

FORM catalog 'Catalog'
OBJECTS c = Category
PROPERTIES(c) READONLY name

OBJECTS i = Item
PROPERTIES(i) READONLY name, archived, nameItem = name PANEL
PROPERTIES() showArchived
FILTERS category(i) = c, NOT archived(i) OR showArchived()
ORDERS name(i)

OBJECTS d = Detail
PROPERTIES(d) READONLY name, quantity
FILTERS item(d) = i
;

DESIGN catalog {
NEW page {
horizontal = TRUE;
NEW categories { fill = 1; custom = 'CategoryList'; MOVE BOX(c); }
NEW items { fill = 2; custom = 'ItemList'; MOVE BOX(i); MOVE PROPERTY(showArchived()); }
NEW details { fill = 1; custom = 'DetailPanel'; MOVE PROPERTY(nameItem); MOVE BOX(d); }
}
}

CategoryList, ItemList и DetailPanel — такие же компоненты, как OrderBoard, и в props.data каждого контейнера только те части, которые он рисует: CategoryList видит data.c, ItemList — data.i со строками и data.showArchived, DetailPanel — data.d и, как data.i, только перенесённое к нему панельное свойство nameItem. Часть группы проецируется туда, где стоит её компонент, — строки туда, где грид группы, панельное свойство туда, где само свойство, — поэтому имя текущего элемента попадает в панель деталей панельным свойством i, добавленным под алиасом и перенесённым MOVE в рисующий его контейнер, без строк группы. Щелчок в списке устанавливает текущий объект (controller.c.change(row)), и фильтры нижестоящих групп следуют за ним — это собственная зависимость групп платформы, поэтому общего состояния на JavaScript контейнерам не нужно. Менять текущий объект группы может только контейнер, рисующий её строки. Свойство уровня формы, которое показывают два контейнера, добавляется дважды, под двумя алиасами, по одному в каждый контейнер.

Переключатель, от которого зависит, какие строки показаны, — свойство формы, изменяемое через контроллер (controller.showArchived.change(true)) и применяемое фильтром формы, а не состояние компонента, фильтрующее data.i.list: тогда сервер читает только строки, прошедшие фильтр, а количество или итог, которые область показывает по всем прошедшим его строкам, — свойство над тем же фильтром (количество под фильтром формы), тогда как list.length — лишь уже прочитанные строки. То же верно для текста поиска или выбранного раздела. В компоненте остаётся только состояние, не решающее, что читать, — раскрытая карточка, открытая панель.

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

Возврат к lsFusion​

custom — переход от платформы к React; custom = NULL — переход обратно. По умолчанию компонент рисует всё поддерево контейнера по props.data. Дочерний компонент может вместо этого сохранить своё представление lsFusion: на нём задаётся custom = NULL — штатное представление платформы, а не пользовательское, — и компонент не рисует его, а размещает через <Platform name/>. Так же размещается дочерний компонент с собственным пользовательским представлением: контейнер со своим custom или свойство, которому FORM задаёт функцию отображения CUSTOM. NOCUSTOM — это custom = NULL, сказанное в FORM.

FORM orders 'Orders'
OBJECTS o = Order
PROPERTIES(o) READONLY number, date, sum
PROPERTIES() comment = orderComment // ниже задано `custom = NULL`; его запись — data.comment
;

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

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

Запись содержит caption, image — у свойства ещё comment — и, пока форма скрывает дочерний компонент, hidden (у действия — только hidden) — у свойства таблицы это вся запись его колонки — и находится в том же месте data, что и опции отображения, под тем же именем, по которому лежит значение дочернего компонента:

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

Свойство называется интеграционным SID — и в таблице, и в панели, и его запись, и его место. Запись лежит в узле его группы, там, где была бы запись свойства, которое рисует React, а <Platform> называет панельное свойство тем путём, по которому эта запись читается: <Platform name="o.note"/> — для data.o.note, <Platform name="note"/> — для data.note пустой группы; хост он прячет, пока эта запись говорит hidden. Рендерер свойства таблицы в строке называется так же, <Platform name="o.quantity" row={row}/> — для записи его колонки data.o.quantity, а строка говорит, какой это рендерер: она должна быть строкой этой группы — строка нижней группы дерева проходит и обрезается до пути к группе, как её принимает change(row) группы, — и запись колонки этот хост не прячет. Контейнер и любой другой компонент называется идентификатором дизайна, <Platform name="BOX(o)"/> и data['BOX(o)']. Поэтому имя, по которому компонент размещает дочерний компонент, — это путь, по которому он читает его запись.

Такую запись, data['<SID компонента>'] = { caption, image, hidden }, на верхнем уровне data этого представления получает каждый компонент, который оно рисует, кроме свойства, которое оно несёт, — контейнер, объявленный через NEW и сгенерированный (PANEL(o), TOOLBAR(o), …) одинаково, грид (GRID(o)) или дерево (GRID(TREE t)), панель инструментов (TOOLBARSYSTEM(o)), — и каждый компонент, который рисует платформа и который оно размещает, кроме свойства, — кроме того, что лежит внутри контейнера, который рисует платформа: его платформа рисует целиком, и компонент туда не заглядывает. У самого контейнера CUSTOM REACT записи в его собственных data нет: его заголовок и изображение здесь никто не рисует — их рисует платформа вокруг контейнера, а у контейнера, который размещает другое представление React, — это представление, по его записи у себя. Поле hidden записи следует за SHOWIF — собственным у компонента или у контейнера выше, — а не за тем, показывает ли представление этот компонент: у вкладки, которую представление не показывает, остаётся заголовок, а hidden нет. Пока оно есть в записи размещённого компонента, <Platform> и usePlatform скрывают узел, который его размещает, — узел получает hidden и остаётся: компонент по-прежнему размещён, поэтому продолжает читаться и возвращается вместе со своим SHOWIF. Запись входит в проецируемые data, поэтому динамический заголовок или изображение перерисовывают компонент как любое другое изменение данных.

Контейнер CUSTOM REACT подчиняется собственному SHOWIF, как любой другой компонент: платформа скрывает его блок. Внутри того, что рисует React, tabbed, collapsed, popup и activated ничего не делают: это содержимое раскладывает React, и полосы вкладок или заголовка сворачивания, к которым они бы относились, нет — вместо них у представления есть запись каждого контейнера, и показывает оно то, что хочет. ACTIVATE TAB, COLLAPSE или EXPAND из скрипта над компонентом там приводит к ошибке, как и над дочерним компонентом, который рисует платформа: его видимость принадлежит представлению.

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

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

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

Размещение дочернего компонента, который рисует платформа​

Дочерний компонент, который рисует платформа, не обязан быть непосредственным потомком контейнера: он может лежать внутри контейнеров, которые представление рисует само, на любой глубине — включая запись таблицы, которую рисует представление, то есть то, что дизайн кладет в GRID(<группа>), — и размещает его по имени всё тот же контейнер CUSTOM REACT над ним — поэтому дизайн группирует такие части так же, как их рисует представление, по страницам или секциям. Внутри контейнера, который рисует платформа, отдельно не размещается ничего: такой контейнер платформа рисует целиком, со всем, что внутри, — если только компонент не размещает контейнер CUSTOM REACT внутри этого контейнера, как свой собственный.

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

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

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

<Platform> короче. usePlatform(name, { className, row }) — для элемента, который компонент отрисовывает и так: панели, карточки, ячейки сетки, — чтобы представление попадало прямо в него: он возвращает свойства, делающие этот элемент узлом размещения, — ref, переносящий туда представление, пометки узла и, пока запись дочернего компонента говорит об этом, hidden. Передайте их элементу разворотом ({...}), а класс элемента — в usePlatform, а не самому элементу: className, записанный после разворота, заменяет тот, с которым объединены пометки.

Всё остальное у них общее. Узел размещения несёт класс lsf-platform и атрибут data-lsf-sid с именем дочернего компонента, кто бы его ни создал: <Platform> и usePlatform отрисовывают их собственными свойствами, объединёнными с классом, который компонент даёт узлу, поэтому класс, меняющийся между отрисовками, их не стирает. Любой узел размещения стилизуется так, что представление его заполняет, каким бы ни был дочерний компонент, поэтому компонент задаёт размер узлу, а представление следует за ним:

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

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

<section className="board-panel">
<h3><Image value={props.data['BOX(o)'].image}/><Caption value={props.data['BOX(o)'].caption}/></h3>
<Platform name="BOX(o)"/>
</section>

Рисуются через <Caption> и <Image> — ровно затем они и есть: заголовок не всегда обычный текст, а изображение — либо адрес, либо готовый элемент.

Именно это делает возможной полосу вкладок, и ради этого запись и существует. Компонент, показывающий по одному размещённому дочернему компоненту, всё равно должен как-то назвать те, которые он не показывает, а называть их больше нечем. Работает это благодаря двум вещам: запись не следует за размещением — она есть с самого начала у каждого дочернего компонента, которого размещает контейнер, а её hidden следует за SHOWIF, а не за тем, показывает ли его представление, поэтому у закрытой вкладки есть подпись, — и заголовок продолжает читаться, пока тело скрыто, поэтому вычисляемый заголовок закрытой вкладки не устаревает.

const TABS = ['BOX(o)', 'BOX(i)'];

export function PlacedTabs(props) {
const [active, setActive] = React.useState(TABS[0]);
return <div className="placed-tabs">
<div className="placed-tabs-strip" role="tablist">
{TABS.map(sid => <button key={sid} role="tab" aria-selected={sid === active}
onClick={() => setActive(sid)}>
<Image value={props.data[sid].image}/><Caption value={props.data[sid].caption}/>
</button>)}
</div>
<Platform key={active} name={active} className="placed-tabs-body"/>
</div>;
}

Отрисовка только активного дочернего компонента, а не сокрытие остальных, — это и есть то, из-за чего закрытые вкладки перестают читаться, см. ниже. Вкладки нужно делать над BOX группы, а не над её GRID: заголовок и изображение есть только у контейнера и у свойства, поэтому в записи размещённого GRID(o) нет заголовка — она говорит только о том, скрывает ли форма грид.

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

const TABS = ['BOX(o)', 'o.note'];   // контейнер и панельное свойство o, которое рисует платформа
const entryOf = (data, name) => {
const dot = name.lastIndexOf('.');
return name in data || dot < 0 ? data[name] : data[name.slice(0, dot)][name.slice(dot + 1)];
};
// полоса: <Caption value={entryOf(props.data, sid).caption}/>; тело: <Platform key={active} name={active}/>

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

  • Каждый дочерний компонент, который рисует платформа, размещается не более чем одним узлом. Не размещённый ни одним узлом дочерний компонент не показывается. Повторный узел сообщает о себе на странице и в консоли, а первый сохраняет за собой дочерний компонент.
  • Компонент внутри контейнера, представление которого платформа строит, хотя custom = NULL ему не задан, — перенесённая внутрь группа фильтров, которая не фильтрует ни по одному объекту, — не показывает никто, и консоль один раз сообщает об этом вместе со способом исправить. Это не касается компонента, часть которого рисует представление, — блока фильтров, управляющих элементов фильтров или тулбара группы или дерева либо группы фильтров группы: представление получает эту часть.
  • Узел должен попасть на страницу. Узел, который к концу создавшей его отрисовки так и остался вне документа, отдаёт дочерний компонент: представление возвращается в ожидание, его получает <Platform> с тем же именем, который на странице есть, а в консоль пишется, какое имя было отдано. Узел, добавляемый порталом в эффекте, к этому моменту уже на странице и размещается как обычно.
  • Узел, который отрисовывает <Platform>, содержит представление lsFusion, поэтому он должен оставаться пустым: ему задаются класс или стиль, но не дочерние элементы.
  • custom = NULL — штатное представление платформы, где бы он ни был задан: вне контейнера CUSTOM REACT или внутри контейнера, который платформа рисует целиком, компонент рисуется там, где стоит, этим представлением.

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

Представление сообщает серверу, какие из дочерних компонентов, которые рисует платформа, оно размещает, — их набор, а не монтирования и размонтирования, которые его изменили: набор вычисляется после каждого изменения размещения и один раз после первой отрисовки компонента, а отправляется только то, что изменилось. Дочерний компонент, у которого есть узел размещения — с его представлением или в ожидании его, — размещён. Дочерний компонент, который не размещает ни один <Platform>, не читается — так же, как неактивная вкладка или свёрнутый контейнер, — кроме того, что его подписывает: его заголовок, изображение и комментарий читаются и дальше, как заголовок неактивной вкладки, чтобы компонент мог по-прежнему его называть. Его группа перестаёт читаться, только если это было последнее место её отображения на форме. Поэтому компонент, показывающий по одному дочернему компоненту, отрисовывает только его, а не прячет остальные средствами CSS: скрытый через CSS компонент для сервера остаётся размещённым и продолжает читаться. По той же причине видимость размещённого дочернего компонента принадлежит только компоненту: его атрибуты collapsible, collapsed и activated игнорируются, а COLLAPSE / EXPAND или ACTIVATE TAB из скрипта над ним приводит к ошибке.

Контейнер, который представление рисует само, — страницу полосы вкладок, которую оно раскладывает, раздел, который оно сворачивает, — сервер читает как показанный, пока представление ничего о нём не говорит: что показывает представление, ему не видно. useShown(name, shown) говорит это. Контейнер, который представление не показывает, не читается, как и всё внутри него — включая размещённый там дочерний компонент, который рисует платформа, есть у него узел или нет, — так же, как неактивная вкладка, а его заголовок и изображение читаются и дальше, как заголовок неактивной вкладки: свойство там, которое за это время меняется, читается как null, пока контейнер не покажут снова, — как на неактивной вкладке. Вызывайте его там, где он остаётся смонтированным, пока контейнер появляется и исчезает, — в полосе вкладок, а не на странице, — по одному хуку на контейнер, с именем контейнера в дизайне: размонтированный, хук оставляет контейнер показанным — это сервер и предполагает о контейнере, который не называет ни один хук. shown, если его опустить, равно true. Имя, которое не называет контейнер, рисуемый самим представлением, выводится в консоль. SHOWIF контейнера остаётся за формой, и hidden его записи говорит только о нём.

// вкладка полосы: смонтирована, пока смонтирована полоса, какая бы страница ни была показана
function Tab(props) {
useShown(props.name, props.active);
return <button role="tab" aria-selected={props.active} onClick={props.onClick}>
<Caption value={props.entry.caption}/>
</button>;
}

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

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

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

DESIGN orders {
NEW board {
custom = 'OrderBoard';
MOVE BOX(o); // строки рисует React, по data.o
}
}

NOCUSTOM говорит, что свойство рисует платформа, штатным представлением: оно является компонентом, а не значением. То же говорит функция отображения CUSTOM — значение тогда платформа рисует этой функцией — и SELECT вместе с NOCUSTOM — значение тогда платформа рисует выбором из списка: один SELECT говорит, что рисуется, а не кто рисует (выбор). Дизайн говорит то же атрибутом custom свойства, как и caption: custom = NULL — это NOCUSTOM, а PROPERTY(quantity(o)) { custom = 'starRating'; } — это PROPERTIES(o) quantity CUSTOM 'starRating'. Где оно размещается, не говорится — и не может: у свойства таблицы нет собственного места в дизайне, а его отрисовщики попадают в строки, поэтому размещает их тот компонент, который эти строки рисует. Здесь это компонент, которому группу отдал MOVE BOX(o). MOVE свойства таблицы отклоняется при построении формы — с NOCUSTOM или без. Компонент называет свойство так, как ключуется запись его колонки, — с группой — и передаёт строку:

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

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

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

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

Там, где строки группы не рисует контейнер CUSTOM REACT, NOCUSTOM — просто штатное представление: свойство рисует таблица платформы, без пользовательских функций отображения и редактирования. Свойство таблицы, сгруппированное в колонки, отклоняется с указанием свойства там, где его строки рисует представление React: построчный редактор не может адресовать ячейку на пересечении строки и колонки.

Выбор​

Свойство, которому в FORM задан SELECT, показывается как выбор среди всех его возможных значений: объектов, на которые может указывать ссылка, по их представлению (статусов, которые может иметь заказ, по их именам), или, для выбора нескольких значений, объектов, которые можно отметить (меток заказа). Внутри контейнера, который рисует компонент React, такое свойство компонент рисует сам, и запись свойства несёт сразу все его варианты:

FORM orders 'Orders'
OBJECTS o = Order PANEL
PROPERTIES(o) number READONLY, nameStatus SELECT, tags SELECT
;

DESIGN orders {
NEW card FIRST { custom = 'OrderCard'; MOVE BOX(o); }
}
data.o.nameStatus = {
value: 'New', // значение выбранного варианта
type: 'string',
select: { // то, что добавляет SELECT
list: [ // все варианты, в их порядке
{ key: 581, value: 'New', selected: true },
{ key: 583, value: 'Done' },
{ key: 584, value: 'Lost' }
]
}
}
data.o.tags = {
value: 'red', // собственное значение свойства: значения отмеченных вариантов, склеенные
type: 'string',
select: {
multi: true, separator: ', ',
list: [{ key: 577, value: 'blue' }, { key: 576, value: 'green' },
{ key: 575, value: 'red', selected: true }]
}
}

select — то, что SELECT добавляет к записи, одним полем. У каждого варианта его list есть key — идентификатор объекта, которым он является (у варианта из нескольких объектов, как у выбора по группировке по нескольким объектам, — строка из их идентификаторов), один и тот же, пока вариант есть, ключ для React, — value — представление, которым он показывается, текст, — и selected, пока он выбран. У выбора одного значения value записи — значение выбранного варианта, это и есть значение свойства; если ничего не выбрано, ни один вариант не selected, а value — null. У выбора нескольких значений, select.multi: true, value — представления отмеченных вариантов в порядке списка, склеенные select.separator, и null, если ничего не отмечено. select.separator — разделитель его GROUP CONCAT, если он литерал, не локализуемый, у OVERRIDE нескольких — тот, которым склеивают они все, и запятая, ', ', иначе — если он вычисляется или локализуется либо ветки OVERRIDE склеивают разными. Для обычного tags(o) = GROUP CONCAT name(t) IF in(o, t), ', ' ORDER name(t), t это собственное значение свойства; если свойство — что-то другое над вариантами — выражение над GROUP CONCAT (upper(tags(o))), OVERRIDE, — это всё равно отмеченные варианты, склеенные: 'green, red', тогда как у свойства 'GREEN, RED'. select.notNull: true говорит, что значение должно быть, поэтому представление не даёт его очистить. Как рисовать выбор, решает само представление: тип элемента, который форма задаёт SELECT ('list', 'dropdown', ...), — это тип собственного элемента платформы, поэтому форма с ним здесь отклоняется; вместе с NOCUSTOM этот элемент рисует платформа, а представление его размещает; тип, объявленный у свойства, — лишь его умолчание, и представление его не берёт. У колонки select — в ячейке каждой строки, row.nameStatus.select: выбранное у каждой строки своё. Представление варианта рисуется через <Caption>: оно может быть HTML.

Свойство изменяется выбранным, через change его члена:

const { Caption } = window.lsfusion;

export function OrderCard(props) {
const { data, controller } = props;
return <div className="status">
{data.o.nameStatus.select.list.map(option =>
<button key={option.key} aria-pressed={!!option.selected}
onClick={() => controller.o.nameStatus.change(option)}>
<Caption value={option.value}/>
</button>)}
</div>;
}
  • У выбора одного значения change принимает вариант — элемент select.list или его значение: change('Done'), — а null очищает значение; значение, которое должно быть, не очищается. Уже выбранный вариант ничего не меняет, и ничего не отправляется.
  • У выбора нескольких значений — их значение, как его даёт value, — представления, склеенные select.separator: change('green, red'), — или массив вариантов, отмеченных с этого момента, элементов select.list или их значений: change(['red', 'green']); null, [] и '' снимают все отметки. Значение, которое разбивается на представления больше чем одним способом — представление содержит разделитель или сливается с ним на краю, либо разделитель пустой, — отклоняется: тогда передавайте массив. Отдельный вариант — элемент select.list — отмечается или снимается: противоположно тому, что ячейка показывает сейчас, с выбором, на который ещё нет ответа.
  • Значение, которого нет ни у одного варианта, отклоняется, как и значение, которое есть у нескольких, — у вариантов может быть одно представление: тогда передавайте сам вариант.

Выбор отправляется так же, как его отправляет собственный элемент платформы, по одному варианту, выбранному или больше не выбранному: записывается ссылка — status(o) получает выбранный статус, — а у выбора нескольких значений условие каждого отмеченного или снятого варианта, in(o, t). Вызов, который отмечает и снимает несколько вариантов, отправляет их одним запросом, сначала отметки. Представление показывает выбор сразу — в select.list и в value, — ячейка говорит loading до ответа, а ответ приносит заново прочитанные варианты. В пакете выбор идёт вместе с остальным.

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

Платформа строит элемент выбора и сама — для ссылки с уникальным представлением или для выбора нескольких значений; свойство, которое рисует компонент React, этого не получает: варианты в его записи есть только там, где выбор задан — SELECT в форме или в объявлении свойства. select в дизайне всегда называет элемент платформы, поэтому задаётся вместе с custom = NULL. Вместе с NOCUSTOM — PROPERTIES(o) nameStatus SELECT 'buttonGroup' NOCUSTOM — платформа рисует свой элемент выбора, а компонент размещает его через <Platform name/>, как любой дочерний компонент, который рисует платформа, построчно — в строках, которые он рисует.

Расширение контейнера размещаемыми дочерними компонентами​

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

EXTEND FORM orders PROPERTIES() rating;
DESIGN orders {
board { MOVE PROPERTY(rating) { custom = NULL; } }
}

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

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

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

Шаблону не даётся никаких данных. Он размещает то, что рисует платформа, по имени, а всё остальное в нём написано вручную: он не может прочитать ни заголовок потомка, ни его значок, ни заголовок самого контейнера. Заголовки, которые он показывает, рисует платформа: потомок-свойство рисуется по частям (если у него не задано inline = FALSE) — его заголовок и комментарий становятся отдельными местами, <Platform:PROPERTY(qty).caption> и <Platform:PROPERTY(qty).comment>, которые шаблон может поставить внутрь своей разметки, например в заголовок раздела, — любой другой потомок несёт свой заголовок внутри размещённого представления, а заголовок и значок самого контейнера платформа рисует рядом с контейнером, а не внутри шаблона. Поэтому своя разметка, которой нужен вычисляемый приложением заголовок — как собственный текст или как заголовок контейнера, — это та граница, за которой шаблона уже не хватает.

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

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

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

Компонент, который рисует собственное поле, также сообщает форме, что пользователь в нём редактирует, чтобы во время набора ENTER не переводил фокус на следующий компонент, а ESCAPE не закрывал окно — см. Объявление редактирования. Компоненту, который своих полей не рисует, объявлять нечего.

Пока форма ждёт сервер, платформа показывает это на кнопке «Обновить» формы (formRefresh), а там, где эту кнопку рисует React, об этом говорит запись кнопки: data.formRefresh.loading равно true. Дизайн переносит кнопку в контейнер представления — MOVE PROPERTY(formRefresh()), — а представление рисует по записи то, что ему нужно: саму кнопку или только индикатор. Показываются асинхронные запросы формы, какая бы её часть их ни отправила, — изменение свойства значением, смена текущего объекта, сортировок и фильтров, раскрытие узла дерева, пакет, собственные exec, eval, evalAction и change контроллера, запуск по расписанию, — с момента, когда первый из них прождал 20 миллисекунд, и до прихода ответа на последний из них. Запрос, ответ на который пришёл раньше, ничего не показывает, чтобы страница не мигала. Синхронный запрос его не выставляет — таков запуск действия через его член вне пакета, если только платформа не выполняет действие асинхронно, как выполняет выведенное с NOWAIT: он блокирует ввод, и платформа вместо этого показывает свой индикатор ожидания. Запись говорит loading и пока ждёт собственный запуск кнопки, как у любого действия. Кнопка «Обновить», которую рисует платформа, — размещённая через <Platform> или где угодно ещё на форме, — показывает это сама, а форма без кнопки «Обновить» не показывает этого нигде. Когда это меняется, заново рисуется только представление, которое рисует кнопку:

DESIGN orders {
board {
MOVE PROPERTY(formRefresh());
}
}
const { useData } = window.lsfusion;

function Busy() {
return useData(d => d.formRefresh.loading) ? <span className="spinner"/> : null;
}

Клавиши и привязки мыши​

Клавиши и привязки мыши, которые дизайн даёт тому, что рисует представление, работают так же, как в обычной форме: клавиша и привязка мыши свойства или действия (CHANGEKEY, CHANGEMOUSE), клавиши собственных кнопок формы — действий модуля System: ESCAPE закрывает форму, ctrl ENTER — OK — и клавиша фильтра группы фильтров. Платформа привязывает их сама, и представлению для них ничего делать не нужно. Клавиша работает, пока форма показывает её компонент — его запись не hidden — и показано представление, которое его рисует, в каком бы элементе формы пользователь её ни нажал, и делает то же, что клавиша платформы:

  • действие выполняется, как по нажатию его кнопки. Действие в строках, которые рисует представление, выполняется на текущей строке; без текущей строки клавиша достаётся другим привязкам. Действие с CONFIRM сначала спрашивает, окном платформы: клавиша — жест платформы, а вызов члена действия ничего не спрашивает — представление спрашивает само там, где запись говорит askConfirm;
  • свойство, чьё изменение не просит значения — его обработчик ON CHANGE выполняет действие, — выполняет этот обработчик;
  • клавиша фильтра выбирает фильтр, как это делает виджет платформы.

Клавиша действия или свойства, которое форма показывает только для чтения (READONLY, READONLYIF), пока это так, не берётся — она достаётся другим привязкам.

Привязка мыши берётся так же, как в обычной форме, — там, где позволяют её режимы: по умолчанию по нажатию в ячейке таблицы, которую рисует платформа, а не панели (режимы panel и cell). То, что рисует представление, — не ячейка платформы: нажатие там берёт привязку мыши, только если её режим cell разрешает это вне ячейки.

Режимы привязки клавиши работают как везде: с showing=all клавиша работает, пока форма показывает её компонент, видно представление на странице или нет, а клавише свойства группы объектов с group=only нужен фокус в представлении этой группы, которое рисует платформа, — то, что рисует представление, ни к какой группе не относится.

Платформа не привязывает:

  • клавишу свойства или действия, которое сразу просит значение в ячейке, — изменение по умолчанию у первичного свойства, действие, которое начинается с запроса значения (INPUT), поле, в которое печатает сканер штрихкода: платформа открывает для него редактор в ячейке, а это поле — представления. Представление, которому такая клавиша нужна, обрабатывает её само;
  • клавиши обычной таблицы — F3 и другие клавиши её фильтров, ввод, который начинает быстрый фильтр, ENTER вниз по колонке: над строками, которые рисует представление, такой таблицы нет, и представление пользуется членами своих управляющих элементов фильтров и filters.change.

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

Клавиша, нажатая в поле, которое рисует представление, доходит до формы, как любая другая. Поле, в котором пользователь набирает текст, объявляет это (startEditing), чтобы ENTER, ESCAPE и клавиши с режимом editing по умолчанию ему уступали; клавишу, нужную самому представлению, оно останавливает в своём обработчике (stopPropagation).

Живые данные​

Данные, которые меняются, пока форма открыта, — котировки, очередь, монитор — обновляет сама форма, а не таймер в компоненте. Событие SCHEDULE формы выполняет действие раз в заданное число секунд, а изменившееся платформа доставляет через props.data, как любое другое изменение:

FORM quotes 'Котировки'
OBJECTS q = Quote
PROPERTIES(q) READONLY symbol, price, changedAt
EVENTS ON SCHEDULE PERIOD 2 formRefresh()
;

DESIGN quotes {
BOX(q) {
custom = 'QuoteBoard';
}
}

System.formRefresh[] перечитывает всё, что показывает форма, поэтому ответ несёт всю форму на каждом запуске. Обновления одной группы объектов нет: System.forceUpdate[STRING] лишь применяет накопленное обновление группы в режиме ручного обновления (атрибут дизайна enableManualUpdate) и ничего не делает для группы, обновляемой автоматически. Поэтому живое табло держат отдельной формой только с теми свойствами, которые оно рисует, а период берут не короче, чем требуют данные.

Запуск по расписанию отличается от запроса, который компонент сделал бы сам. Собственный таймер компонента, вызывающий controller.refresh.exec(), опрашивает сервер так же, как нажатие на форме: синхронным запросом, блокирующим ввод до своего завершения, если действие не выведено с NOWAIT, — и, как показывает третий пункт, продолжает опрос на скрытой форме.

  • Не блокирует пользователя. Веб-клиент отправляет его асинхронным запросом, не блокируя ввод и без индикатора ожидания, который получает синхронный запрос, так что пользователь продолжает работать, пока форма перечитывается.
  • Выполняется, только пока форма на экране. Для формы в фоновой закладке, формы, которую компонент окна форм никуда не поместил, или формы под модальным диалогом запуск пропускается. Таймер продолжает отсчёт, и следующий запуск происходит, когда форма снова показана.
  • Скрытая форма остаётся смонтированной. Фоновая закладка и неразмещённая форма скрыты, а не закрыты, поэтому компонент по-прежнему смонтирован, и его собственный setInterval продолжает срабатывать — и обращаться к серверу, — пока расписание формы приостановлено. Поэтому обновление принадлежит форме, а не компоненту. Таймер, который компоненту всё же нужен (анимация, обратный отсчёт), как обычно снимается в очистке эффекта, но эта очистка выполняется при закрытии формы, а не при её скрытии: пока представление скрыто, его останавливает useVisible().

Скрытое представление​

Платформа скрывает представление, не убирая его: на невыбранной вкладке контейнера с tabbed, в свёрнутом контейнере, в контейнере с popup, пока тот не открыт, под SHOWIF — контейнера представления или контейнера выше, — а также в форме, которой нет на экране: в фоновой закладке или в форме, которую компонент окна форм никуда не поместил. Компонент остаётся смонтированным и сохраняет своё состояние, а то, что он рисует, просто не видно.

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

То, что компонент делает сам, всё это время продолжается: таймер, запрос, анимация. useVisible() говорит, видно ли представление сейчас — отображается ли элемент, в котором смонтирован его корень: есть ли он на странице и нет ли над ним элемента с display: none, которым платформа и скрывает, — и перерисовывает компонент, когда это меняется. Поэтому собственную работу компонент делает, пока хук возвращает true:

const { useVisible } = window.lsfusion;

export function Clock() {
const visible = useVisible();
const [now, setNow] = React.useState(() => new Date());
React.useEffect(() => {
if (!visible)
return undefined;
const timer = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(timer);
}, [visible]);
return <span>{now.toLocaleTimeString()}</span>;
}

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

Вкладки и сворачиваемые контейнеры вокруг представления — часть формы, и представление читает и меняет их так же, как остальную форму, — через свойства и действия, которые ему отдаёт дизайн: ACTIVE TAB говорит, выбрана ли вкладка, ACTIVATE TAB выбирает её, COLLAPSE CONTAINER и EXPAND CONTAINER сворачивают и разворачивают контейнер. Здесь lines — вкладка контейнера формы order с tabbed, а details — сворачиваемый контейнер той же формы:

linesSelected = ACTIVE TAB order.lines;
selectLines() { ACTIVATE TAB order.lines; }
collapseDetails() { COLLAPSE CONTAINER order.details; }

EXTEND FORM order PROPERTIES() linesSelected, selectLines, collapseDetails;

DESIGN order {
NEW steps FIRST {
custom = 'Steps';
MOVE PROPERTY(linesSelected());
MOVE PROPERTY(selectLines());
MOVE PROPERTY(collapseDetails());
}
}
<button aria-pressed={data.linesSelected.value === true} onClick={() => controller.selectLines.exec()}>Строки</button>

Действия выполняются на сервере, поэтому вкладка выбирается, когда придёт ответ, а не по нажатию. Свёрнут ли контейнер, свойством не узнать: события формы ON COLLAPSE и ON EXPAND выполняются, когда пользователь сворачивает или разворачивает его. У контейнера, который представление рисует само, нет ни вкладки, ни сворачивания платформы — показывает его или нет само представление, — поэтому ACTIVATE TAB, COLLAPSE CONTAINER и EXPAND CONTAINER для него — ошибка (выше).

Компонент 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Канонические имена его потомков, которые рисует это окно

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

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

Поэтому окно может получить элементы, которых модуль не объявлял. WINDOW у папки называет окно её потомков, и потомок, попавший в другое окно, рисуется там, пока папка выбрана. В штатном приложении так устроено «Администрирование»: сама папка лежит в System.system, а её разделы объявлены с WINDOW toolbar PARENT, поэтому, пока она выбрана в верхней панели, их вместе с их потомками рисует окно System.toolbar — после EXTEND WINDOW System.toolbar CUSTOM это ваш компонент. Компонент, который рисует только известные ему элементы, такие разделы потеряет. Всё, что окну досталось, обходится через root и children.

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

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

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

const { Platform } = window.lsfusion;

function AppMenu({ data, controller }) {
return <nav>
<Platform 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>;
}

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

NAVIGATOR {
Sale.sales NOCUSTOM;
}

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

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

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

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

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

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

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

Окно форм​

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

EXTEND WINDOW System.forms CUSTOM 'FormsBoard';
const { Platform, Caption } = 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)}><Caption value={e.caption || '...'}/></a>
{!e.blocked && <a onClick={() => controller.close(name)}>x</a>}
</span>;
})}</div>
{current && <Platform key={current} name={current} className="board-current"/>}
</div>;
}

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

props.data — открытые формы и состояние самого окна. Компонент отрисовывается сразу вместе с окном, до открытия какой-либо формы, — open тогда пуст, — поэтому то, что показывается, пока ничего не открыто, рисует он сам:

ПолеЗначение
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. Мобильное меню компоненту не нужно: на мобильном веб-клиенте окно форм рисует платформа.

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

Шапка приложения​

Шапка с полем поиска, которое находит записи, - это форма: ей нужны живая проекция, сессия, в которой живёт запрос, сортировка и постраничность, и способ открыть найденное. Поэтому она строится как форма и открывается в собственное окно - WINDOW ... FORMS над областью форм - оператором SHOW ... WINDOW windowName:

// окно, в котором живёт шапка: одна форма, нарисованная одна над областью форм, не выше этой формы
WINDOW header 'Шапка' FORMS AUTOSIZE POSITION(20, 6, 80, 10) HIDETITLE;

// чем шапка ищет и что находит
query 'Поиск' = DATA LOCAL ISTRING[100] ();
found (Customer c) = isISubstring(name(c), query()) OR isISubstring(city(c), query());

// что делает найденная запись: открывается обычной закладкой в System.forms
openCustomer 'Открыть' (Customer c) { SHOW customer OBJECTS c = c NOWAIT; }

// шапка - это ФОРМА: запрос - её свойство, найденное - её группа объектов, сессия - её собственная
FORM appHeader 'Шапка'
PROPERTIES() query
OBJECTS c = Customer
PROPERTIES(c) READONLY name, city
PROPERTIES(c) openCustomer
FILTERS found(c)
ORDERS name(c)
;

// рисует её компонент, по обычной проекции формы
DESIGN appHeader {
// это шапка, а не документ: никаких «Сохранить», «Отменить», «ОК», «Закрыть»
REMOVE TOOLBARBOX;
NEW search {
custom = 'Search';
fill = 1; // контейнер занимает окно, а не сжимается по содержимому
MOVE PROPERTY(query());
MOVE BOX(c);
}
}

// открывается один раз, при старте клиента; перезагрузка страницы - это снова старт клиента
onWebClientStarted() + {
SHOW appHeader WINDOW header NOWAIT;
}
// здесь ничего не запрашивается, не кэшируется и не инвалидируется: запрос и найденное принадлежат форме
const { useData, useController } = window.lsfusion;

function Search() {
const data = useData();
const controller = useController();
const query = data.query.value || '';
const rows = data.c.list;
const input = React.useRef(null);

// что поле отправило последним, пока проекция этого не показала: до тех пор проекция отстаёт от поля
const [sent, setSent] = React.useState(null);
const send = value => {
setSent(value);
controller.query.change(value);
};
// поле следует за запросом только если его изменил кто-то другой, и никогда пока пользователь в нём, -
// поэтому догоняет его, когда пользователь из поля уходит
const follow = () => {
if (input.current && sent === null && input.current.value !== query && document.activeElement !== input.current)
input.current.value = query;
};
React.useEffect(() => {
if (query === sent)
setSent(null);
follow();
}, [query, sent]);

// где рисуются результаты: под полем и следом за ним
const [box, setBox] = React.useState(null);
const place = React.useCallback(() => {
if (!input.current) return;
const r = input.current.getBoundingClientRect();
setBox({ left: r.left, top: r.bottom + 2, width: r.width });
}, []);
React.useEffect(place, [query, rows.length, place]);
React.useEffect(() => {
const onKey = e => {
if (e.key !== 'Escape') return;
if (input.current) input.current.value = ''; // пользователь в поле, поэтому follow() его не трогает
send('');
};
window.addEventListener('resize', place);
window.addEventListener('scroll', place, true);
document.addEventListener('keydown', onKey);
return () => {
window.removeEventListener('resize', place);
window.removeEventListener('scroll', place, true);
document.removeEventListener('keydown', onKey);
};
}, [place, controller]);

return <div className="search">
<input className="search-input" maxLength={100} placeholder="Поиск клиентов…" ref={input}
onChange={e => send(e.target.value)} onBlur={follow}/>
{query && box && ReactDOM.createPortal(
<div className="search-results" style={{ position: 'fixed', left: box.left, top: box.top, width: box.width, zIndex: 1000 }}>
{rows.length === 0 && <div className="search-empty">Ничего не найдено</div>}
{rows.map(row =>
<div key={row.key} className="search-row" onClick={() => controller.c.openCustomer.exec(row)}>
<b>{row.name.value}</b> <span className="search-city">{row.city.value}</span>
</div>)}
</div>, document.body)}
</div>;
}

window.lsfusion.custom.register('Search', Search, 'reactView');

Всё, что показывает компонент, приходит из проекции, которую он читает через useData(): запрос - это data.query.value, найденное - data.c.list, и платформа держит его актуальным по мере изменения запроса: controller.query.change(…) - обычное редактирование, и FILTERS формы применяются заново.

Поле ввода намеренно неуправляемое - ему не передаётся value={query} - и это единственное, что представление такого рода обязано сделать правильно. Значение, которое отправляет поле, проекция показывает сразу, потому что платформа показывает правку до ответа сервера, и держит последнее из отправленных, пока приходят ответы на предыдущие: ответ сервера заменяет его, только если сервер сохранил иное или отклонил изменение. Но проекция может измениться и пока пользователь печатает - запрос запишет кто-то другой или поправит сервер, - и управляемое React поле перерисовывалось бы из неё прямо под рукой пользователя. Поэтому набранным текстом владеет поле, а за проекцией оно следует только когда запрос изменил кто-то другой и пользователь не находится в поле, и догоняет её, когда пользователь из поля уходит. sent хранит значение, которое поле отправило последним, пока проекция его не покажет, чтобы поле не приняло своё изменение за чужое. Это рассчитано на то, что запрос сохраняется так, как набран: это свойство DATA, а maxLength не даёт ввести больше, чем вмещает его ISTRING[100]. Клик выполняет действие, нарисованное на группе, для кликнутой строки - controller.c.openCustomer.exec(row) - и запись открывается там, где форма открывается по умолчанию, - закладкой в System.forms. Шапка остаётся: окно FORMS рисует одну форму за раз, поэтому форма, которую приложение позже откроет WINDOW header, заменит её, а WINDOW без окна её не тронет.

Двумя вещами шапка перестаёт выглядеть документом. REMOVE TOOLBARBOX убирает собственную панель формы - сохранять, отменять и закрывать здесь нечего, а кнопки рисует именно этот контейнер. (POPUP и EMBEDDED получают это даром: SHOWIF каждой системной кнопки собран из свойств окружения формы, а встроенный редактор выставляет isEditing, которое эти условия отрицают. Форма, нарисованная шапкой, встроенным редактором не является, поэтому говорит о своём желании в дизайне.) Результаты рисуются порталом в тело страницы, а не внутри контейнера, и это второе, что представление такого рода обязано сделать правильно. Окно по замыслу невысокое и обрезает то, что рисует его форма: список, оставленный внутри полосы шапки, срезается после первой строки. Большой z-index тоже не спасает: элемент самого окна - это контекст наложения (position: relative; z-index: 0), поэтому слой внутри него никогда не поднимется над соседними окнами. Портал уходит от обоих ограничений. После этого слой позиционируется по getBoundingClientRect() поля и обязан следовать за ним при изменении размера окна и прокрутке, а по Escape - исчезать.

Именно AUTOSIZE у окна не даёт полосе стать высокой и пустой: шапка ровно такой высоты, как её форма, - здесь это одно поле ввода, - и при этом растягивается поперёк, а контейнер с fill = 1 внутри неё занимает эту высоту, а не сжимается по своему содержимому. Размер окна задаёт раскладка, а не форма в нём, поэтому без этой опции полоса занимает ту долю высоты, которую запрашивает POSITION, пуста она или полна. Учтите и то, что клиент запоминает размер, который пользователь потянул мышью, и восстанавливает его поверх объявленного: вернуть объявленные размеры можно действием Service.resetWindowsLayout. Про POSITION полезно знать две вещи, прежде чем шапка покажется неправильной. Числа - это не прямоугольник, в котором рисуется окно, а способ, которым раскладка делит пространство: шапка с POSITION(20, 6, 80, 10) становится полосой справа от тулбара и над System.forms. А пока навигатор не закреплён полностью, его панель - это оверлей, висящий над соседними окнами: клик, нацеленный в шапку, попадает в меню под ней и выполняет то, что выполняет этот пункт меню.

Чего маршрут через форму не даёт - свежести между соединениями: форма не видит того, что закоммитило другое соединение, пока не перечитает данные, поэтому шапка, которая должна это показывать, добавляет форме EVENTS ON SCHEDULE PERIOD n formRefresh(). И шапку рисует только десктопный веб-клиент: мобильный веб-клиент и десктоп-клиент рисуют одно окно System.forms и открывают шапку там, закладкой, поэтому приложение, которое их обслуживает, оставляет дизайн шапки пригодным и как обычной формы.

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

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

EXTEND WINDOW System.log CUSTOM 'MessageLog';
const { Platform } = 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>
<Platform name={name}/>
</div>;
})}
</div>;
}

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

props.data — выведенные сообщения, и компонент отрисовывается ещё до прихода первого из них, с пустым messages:

ПолеЗначение
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} само, право только пока заголовок — обычный текст. Отсутствующий заголовок не рисуется.

Деревья​

Группа дерева проецируется как любая другая: контейнер имеет от неё ровно то, что положили туда её собственные компоненты, — а там, где рисуются строки ДЕРЕВА, это list, byKey, keys и сами строки. И каждая такая строка говорит, где она стоит в иерархии:

{
key, isCurrent, objects, ...ячейки,
parent, // ключ строки, под которой она висит
hasChildren, // есть ли у неё дети, загружены они или нет
expanded, // раскрыта ли она
loading, // true, пока загружаются его дети
}

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

parent — ключ той строки, под которой висит эта, и null на верхнем уровне дерева; он указывает в ту группу, которой родитель принадлежит (строки дерева ключуются всем путём до них, поэтому ключи его групп не сталкиваются — кроме объекта строкового класса, в значении которого есть |). Записан он так же, как записывается key, поэтому row.parent === parentRow.key действительно выполняется, а не просто похоже выглядит. Строки приходят под загруженным родителем, поэтому названный родитель обычно на месте — пока что-нибудь его не унесёт: фильтр по верхней группе убирает строку-родителя, а её дети остаются (сервер продолжает их присылать), и с этого момента parent может называть строку, которой у клиента нет. Поэтому вид, индексирующий строки по ключу, должен решить, что делать со строкой, чьего родителя он не нашёл, а не считать, что поиск удался. Предков строки вид находит так же: идёт по parent через byKey той группы, которой принадлежит родитель, и останавливается на родителе, которого у него нет. Глубине, на которую строка сдвинута, такой обход не нужен: она получается сама, когда дерево рисуется от корней вниз, как ниже.

hasChildren — то, что сервер говорит о наличии детей, загружены они или нет; это не атрибут дизайна, а живое состояние дерева.

expanded — что узел раскрыт: его дети загружены — или вид попросил раскрыть или свернуть его, а строки ответа ещё не пришли, и тогда он говорит то, о чём попросили: так же изменённое значение показывается до ответа, и так же штатное дерево меняет значок раскрытия по щелчку. Узел сворачивается сразу, а его дети остаются в list, пока их не уберёт ответ, поэтому вид, который рисует детей узла только под expanded-узлом, как ниже, сразу их и прячет. Раскрывается только узел с детьми: узел, у которого hasChildren ложно, остаётся свёрнутым, как и в штатном дереве. Это тоже живое состояние.

loading равно true, пока загружаются дети узла: вид попросил раскрыть его, а строки ответа ещё не пришли, — так же, как loading ячейки говорит, что её изменение ждёт ответа. Сворачивание узла ничего не загружает, как и раскрытие узла, у которого hasChildren ложно; поле есть, только пока оно true.

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

Строка нижней группы несёт путь над собой, поэтому строка, ставшая текущей — через change(row) или по сообщению сервера, — делает текущими в их группах и строки на этом пути: isCurrent следует по пути вверх, как дерево платформы выделяет один узел. Группы под ней остаются без текущей строки — сервер сбрасывает их текущие объекты, когда выбрана строка выше, и ничего о них не сообщает, — поэтому ни одна их строка не говорит isCurrent, пока там снова не выберут строку.

controller.<g>.expand(row) / .collapse(row) / .toggle(row) открывают и закрывают один узел: узел — это строка группы, поэтому они групповые. Каждый принимает строку (саму строку, её objects или её ключ) и ничего не возвращает: expanded строки меняется сразу, а её дети приходят строками. toggle просит противоположное тому, что сейчас говорит expanded строки, поэтому два переключения до ответа раскрывают узел и снова сворачивают. controller.<g>.expandAll() раскрывает все узлы группы и групп под ней: у верхней группы дерева — всё дерево, у группы ниже — её узлы под раскрытыми узлами выше. controller.<g>.collapseAll() сворачивает те же узлы и все узлы под ними, поэтому узел, раскрытый после этого снова, показывает своих детей свёрнутыми. expanded их строк тоже меняется сразу. У группы вне дерева таких членов просто нет: все пять ставятся только на группу дерева и зарезервированы в узле такой группы, как и list.

FORM catalogue
TREE t c = Category PARENT parent(c), i = Item
PROPERTIES(c) name
PROPERTIES(i) name
FILTERS category(i) == c
;
// a row of the tree, the member of the group it belongs to, and what hangs under it - the tree's current row in bold
function Node({row, member, rows, current}) {
return (
<li>
{row.hasChildren && <button onClick={() => member.toggle(row)}>{row.expanded ? '−' : '+'}</button>}
<span style={{fontWeight: row.key === current ? 'bold' : 'normal'}} onClick={() => member.change(row)}>
{row.name.value}
</span>
{row.expanded && <ul>{rows.filter(r => r.row.parent === row.key).map(r =>
<Node key={r.row.key} {...r} rows={rows} current={current}/>)}</ul>}
</li>
);
}

export function Tree({data, controller}) {
const rows = [...data.c.list.map(row => ({row, member: controller.c})),
...data.i.list.map(row => ({row, member: controller.i}))];
return <div>
<button onClick={() => controller.t.expandCurrent()}>Раскрыть текущий узел</button>
<ul>{rows.filter(r => r.row.parent === null).map(r =>
<Node key={r.row.key} {...r} rows={rows} current={data.t.current}/>)}</ul>
</div>;
}

Эти глаголы — члены узла группы, поэтому контейнер имеет их ровно там, где имеет её строки: у дерева, которое рисует React. Дерево, которое рисует платформа, раскрывается её собственным значком, и ни у одного React-контейнера нет для него глаголов узла; представление, которое рисует его тулбар, раскрывает его двумя кнопками тулбара.

То, что принадлежит дереву, одно на все его группы, лежит в собственном узле дерева, data.<tree> и controller.<tree>, где <tree> — имя дерева в форме, t из TREE t (дерево без имени получает сгенерированное, поэтому дерево, часть которого рисует представление, называют): на верхнем уровне, рядом с узлами его групп. Контейнер имеет его везде, где рисует часть дерева: строки дерева — как узел группы есть там, где её строки, — с его сортировками, его пользовательские фильтры, его тулбар и управляющие элементы фильтров. Этот узел не несёт свойств, поэтому свойство группы дерева может занять любое имя, которое там занимают его части. Где представление рисует любую часть дерева, имя дерева занято там на верхнем уровне, поэтому его нельзя назвать как группу, которую несёт представление, как контейнер или компонент, который оно проецирует, как свойство пустой группы или как член контроллера.

Где представление рисует строки дерева, узел дерева говорит, какая из них текущая, и несёт единственный глагол, которому это нужно знать, — ни одна группа не знает этого сама:

в узлечленчто это
currentключ текущей строки дерева — текущей строки той группы, чей текущий объект задан последним, представлением или сервером, как выделенная строка у штатного дерева, — null, пока её нет
expandCurrent()раскрывает текущий узел и все узлы под ним

current записан так же, как key, поэтому row.key === data.t.current выполняется, как и row.parent === parentRow.key. Он null, пока у дерева нет загруженной текущей строки: пока строку не выбрали, пока группа текущего узла не стоит ни на одной строке, пока её строка не пришла или уже ушла — и никогда не подменяется строкой выше. Текущей строку делает change(row) её группы; current только говорит, какая, и своего члена у него нет. controller.<tree>.expandCurrent() читает текущий узел в момент вызова и ничего не делает, пока его нет или у него нет детей; узлы, которые он раскрывает, сразу expanded, а loading — пока не придут их строки. Внутри пакета он входит в пакет, поэтому раскрывает строку, которую раньше в том же пакете сделал текущей change(row).

Сортировки​

Представление, которое рисует строки группы вне дерева, несёт orders — сортировки группы в порядке приоритета — рядом с list и keys: сортировка это порядок, в котором рисуются строки, поэтому она проецируется туда, где они рисуются, а у контейнера, где лежит только панельное свойство группы, нет ни того, ни другого. Там, где поле есть, оно есть всегда и пусто, когда группа ничем не отсортирована. Каждая запись — объект:

ПолеЗначение
propertyИнтеграционный SID свойства, по которому сортируется, — в узле дерева вместе с именем его группы, как ниже
descСортировать ли по убыванию

Та же форма записи, которую применяет оператор ORDER ... FROM и читает ORDERS ... TO, с одной разницей: orders называет свойство так же, как остальной узел, — интеграционным SID, — а эти операторы называют его SID свойства на форме (sum(o)).

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

controller.<group>.orders.change(...) — член ровно там же и только там. Он меняет одну сортировку, не трогая остальные и их приоритет: уже отсортированное свойство сохраняет своё место при смене направления, новое добавляется в конец. Запись без desc — не сортировка, поэтому она отменяет сортировку по этому свойству. Массив — controller.<group>.orders.change(orders) — заявляет список целиком, поэтому то, что в него не попало, перестаёт сортировать группу.

Изменение через контроллер отправляется сразу, всем списком группы, — тем же запросом, что клик по штатному заголовку, — и orders показывает его тут же, ещё до того, как придут строки в новом порядке; ответ на запрос, сделанный раньше, его не отменяет.

Сортировки дерева — его собственные, как и его пользовательские фильтры: там, где представление рисует строки дерева, data.<tree>.orders перечисляет сортировки всех его групп, группа за группой, сверху вниз, — у каждой группы в её собственном порядке приоритета: сортировки одной группы не старше сортировок другой, — каждое свойство названо именем своей группы и своим, c.name; в узлах его групп orders нет. controller.<tree>.orders.change(...) меняет их так же: одна запись вливается в сортировки своей группы, а массив заявляет весь список дерева — каждая группа получает записи на своих свойствах, а группа, ни одного свойства которой он не называет, перестаёт сортироваться. Список каждой группы отправляется так же, как список группы.

export function OrderBoard(props) {
const group = props.data.o;
const sort = property => { // одна сортировка: остальные сохраняют место и приоритет
const current = group.orders.find(o => o.property === property);
props.controller.o.orders.change({ property, desc: current ? !current.desc : false });
};
const arrow = property => { // по чему группа отсортирована, читается обратно из проекции
const order = group.orders.find(o => o.property === property);
return order ? (order.desc ? ' ↓' : ' ↑') : '';
};
return (
<table>
<thead><tr>
<th onClick={() => sort('number')}>Номер {arrow('number')}</th>
<th onClick={() => sort('sum')}>Сумма {arrow('sum')}</th>
</tr></thead>
<tbody>{group.list.map(row => <tr key={row.key}><td>{row.number.value}</td><td>{row.sum.value}</td></tr>)}</tbody>
</table>
);
}

orders описывает ту сортировку, которую пользователь может менять, кто бы её ни заявил. Фиксированная сортировка в неё не входит и войти не может: ORDERS ... FIXED, а также запись ORDERS, которая является выражением, а не свойством, нарисованным на форме, применяются на сервере и на клиент не передаются вовсе. Не входит и неявная сортировка по объектам самой группы, которая делает чтение устойчивым. Поэтому строки могут быть упорядочены сильнее, чем говорит orders, и вид не может показать индикатор для той части, о которой ему не сообщили.

orders — это то, что есть у формы: её собственные ORDERS при открытии, затем каждый список, который сообщает сервер, в том числе после действия ORDER. Массив пересобирается только когда этот список меняется: он сохраняет идентичность между рендерами, в которых сортировки остаются прежними, — изменение значения свойства, строк или текущего объекта оставляет его прежним, — поэтому представление может сравнивать его между рендерами и мемоизировать по нему.

Ещё два вида сортировок не перечисляются, и при появлении любой из них об этом говорит сообщение в консоли: сортировка по свойству без интеграционного SID (NOEXTID), для которого у проекции нет имени, и сортировка по свойству, сгруппированному в колонки: сортировка здесь называет свойство, а не одну из его колонок.

Поскольку форма-массив заявляет список целиком, вернуть в неё data.<group>.orders — значит снять эти две сортировки — по свойству с NOEXTID и по свойству, сгруппированному в колонки; изменение ОДНОЙ сортировки не трогает того, чего не называет.

Каждое свойство само говорит, стоит ли виду предлагать сортировку по нему: data.<группа>.<свойство>.noSort равно true у свойства, у которого так сказано в дизайне:

DESIGN orders {
PROPERTY(number(o)) { noSort = TRUE; }
}

Заявлено оно на самом свойстве, поэтому панельное свойство отвечает на это так же, как колонка. Представление, которое рисует собственные заголовки, читает его, чтобы не делать такой заголовок кликабельным.

noSort запрещает сортировку, которую заявляет ПОЛЬЗОВАТЕЛЬ, но не ту, которую заявляет форма. Заявленное формой может назвать такое свойство — и запись ORDERS на нём, и действие ORDER, называющее его: сервер по ней сортирует, и orders об этом говорит — как и заголовок штатного грида: не предлагается только клик, который сортировал бы по такому свойству.

Просьба отсортировать ПО свойству, объявленному с noSort, отвергается, как и список, называющий одно свойство дважды, имя, которого у группы нет, и свойство, сгруппированное в колонки. Снять noSort-сортировку — значит заявить запись без desc, и это разрешено: запрещено сортировать по такому свойству, а не переставать.

Фильтры​

filters — условия пользовательского фильтра, по которым сервер фильтрует группу, в порядке применения. Это отдельная часть группы, как её строки и её панельные свойства, и она лежит в узле группы там, где её компонент, — там, где стоит FILTERS(<group>): внутри бокса группы, где строки рисует и custom на BOX(<group>), или в самом FILTERS(<group>), если у него свой custom. Там поле есть всегда и пусто, когда группа не отфильтрована; в узле группы в любом другом контейнере его нет. У группы в виде PANEL его нет нигде: платформа и сама не показывает ей пользовательских фильтров. Каждое условие — объект:

ПолеЗначение
propertyИнтеграционный SID фильтруемого свойства — единственного свойства группы с этим именем, где бы оно ни рисовалось; в узле дерева — вместе с именем его группы, как ниже
compareОперация сравнения: =, >, <, >=, <=, !=, _ (содержит), @ (совпадение)
valueЗначение, с которым сравнивается, сконвертированное как любое значение свойства
negationОтрицание условия
orПрисоединяется ли условие к следующему через ИЛИ, а не И — то есть or у первого из двух делает пару дизъюнкцией

Это та форма записи, которую читает оператор FILTER, поэтому условие описывается одинаково в .lsf и в проекции; FILTERS пишет её же, только значение — строкой, а or — лишь у условия, соединённого через OR. Отличается только имя: FILTER называет свойство его именем на форме, проекция — его integration SID, и это разные имена, если у свойства есть EXTID.

Условие называет свойство группы, а не запись узла: область фильтров не обязана быть там, где рисуется хоть одна колонка группы. Поэтому имя, которое группа рисует дважды, — колонка price и панельное свойство price в другом контейнере, — не называло бы никакого условия, и форма, пользовательские фильтры которой рисует React-представление, не строится, пока им не заданы явные EXTID.

data.<g>.properties перечисляет записи того узла, в котором лежит, поэтому в узле, где только пользовательские фильтры, он пуст: условие называет свойства группы, а не узла, — их перечисляет filterProperties.

Контроллер принимает условие в той же форме, поэтому спроецированное условие можно передать ему как есть. То, чего условие не указывает из compare, negation и or, читается так, как сказано ниже: в массиве — как у нового условия: сравнение свойства по умолчанию (его defaultCompare в filterProperties), без отрицания, И; в одиночном вызове — как у условия, место которого оно занимает. Исключение — условие на файловом свойстве: его value — ссылка на файл, а не файл, с которым сравнивает условие, поэтому такое условие со значением контроллер отклоняет, а без значения читает так же, как любое другое без значения.

controller.<group>.filters.change(condition) — член группы там, где filters, — меняет одно условие, не трогая остальные условия группы и их порядок: встаёт на место ПЕРВОГО условия по тому же свойству — диапазон это два условия по одному свойству, и вторая его граница остаётся, — сохраняя то, чего не указывает, из сравнения, отрицания и or этого условия, либо добавляется со сравнением свойства по умолчанию, если такого не было. Так {property: 'price', value: 300} превращает price > 100 ИЛИ … в price > 300 ИЛИ …. Условие без value — поля нет, оно null, '' или значение, которое тип читает как NULL: false свойства BOOLEAN (у TBOOLEAN false — значение), — снимает это первое условие, и только его. Так же задаёт значение change фильтра по свойству и F3 в гриде над условиями, которые рисует представление (Подмена только фильтров), кроме пустой ячейки, чей F3 задаёт условие сравнения с NULL: раз FILTER — пользовательский фильтр, заданный заранее, его значение и условие, добавленное вручную, задаются по одному правилу.

Его стоит предпочитать замене всего списка. Вид, который пересобирает список, сливает его с условиями, которые видел его обработчик, поэтому обработчик со списком более раннего рендера молча теряет фильтр, выставленный позже.

export function ItemBoard(props) {
const items = props.data.i;
const search = items.filters.find(f => f.property === 'name');
return (
<div>
<input value={search ? search.value : ''} placeholder="имя содержит"
onChange={ev => props.controller.i.filters.change({ property: 'name', compare: '_', value: ev.target.value })}/>
<ul>{items.list.map(row => <li key={row.key}>{row.name.value}</li>)}</ul>
</div>
);
}

Пустой ввод снимает условие по name — первое, которое показывает ввод, — сохраняя остальные условия: его value — '', то есть значения нет.

filters следует за списком группы — каким его отправляет клиент и каким его сообщает сервер, например список, заданный действием FILTER, — и пересобирается только тогда: массив сохраняет идентичность между рендерами, в которых его условия никто не задавал заново, — изменение значения свойства, строк или текущего объекта оставляет его прежним, — поэтому представление может сравнивать его между рендерами и мемоизировать по нему.

Изменение через контроллер отправляется сразу, и filters показывает его тут же, ещё до того, как придут отфильтрованные строки; пока на него нет ответа, список, который сервер сообщает для группы в ответе на более ранний запрос, его не заменяет. Настройка, при которой штатная панель фильтров применяет изменения вручную (userFiltersManualApplyMode), его не придерживает: кнопка применения, которой она ждёт, — кнопка панели. Представление, которому нужна такая кнопка — или которое отправляет поиск только после паузы в наборе, — держит набираемое в собственном состоянии и вызывает change, когда пользователь применяет.

controller.<group>.filters.change(conditions) — то самое, что проекция зовёт data.<group>.filters, меняемое там же, где читается, — заменяет весь список сразу: массив задаёт все условия, одиночное условие — одно. Для кнопки «сбросить всё» (controller.i.filters.change([])) или когда вид действительно владеет всем набором условий. Массив — это список условий, как его читает FILTER в .lsf: элемент без value — условие сравнения с NULL («пусто», с negation — «не пусто»), поэтому условие, которое filters перечисляет со значением null, — например, заданное F3 на пустой ячейке, — принимается обратно как есть, и условие свойства BOOLEAN тоже: оно перечисляется как false, а false читается обратно как NULL. Свойство, по которому больше не фильтруют, массив просто не включает.

Обе формы вызова change сообщают об ошибке автора, а не отбрасывают условие: неизвестное свойство, неизвестная операция сравнения или IN ARRAY (так условие не сравнивает), неизвестное поле условия, negation или or не булевого типа, условие на действии или на свойстве, сгруппированном по колонкам.

Условие, заданное иначе, не перечисляется, когда оно на свойстве без интеграционного SID (NOEXTID), которому у проекции нет имени, на действии, по которому форму не фильтруют, или на свойстве, сгруппированном по колонкам, ведь условие здесь называет свойство, а не одну из его колонок. Что и почему, говорит консоль.

Поскольку форма-массив заявляет список целиком, вернуть в неё data.<group>.filters — значит снять эти условия; изменение ОДНОГО условия не трогает условий других свойств.

У дерева один бокс фильтров на все его группы, FILTERS(TREE <tree>), и его пользовательские фильтры лежат в узле дерева. filters там перечисляет условия всех его групп, сначала условия верхних групп, и называет каждое свойство именем его группы и его собственным, c.name, как <Platform> называет свойство группы. controller.<tree>.filters.change принимает условие, названное так же: имя читается до первой точки — имени группы, в котором точки нет, — а EXTID с точкой, который может быть у свойства, рисуемого платформой, принимается обратно таким, каким его даёт filters. Список, который он задаёт, — список дерева: controller.t.filters.change([]) одним запросом очищает условия всех его групп, как это делает собственная панель платформы, а одно условие вливается в условия их всех.

Рядом с filters пользовательские фильтры говорят, на чём может быть условие, — filterProperties, данные дизайна, есть с самого начала: свойства группы, которые может называть условие, где бы они ни были нарисованы, в порядке формы, — в узле дерева свойства всех его групп, сначала свойства верхних групп. Каждое — объект:

ПолеЗначение
propertyСвойство, названное так, как его называет условие
captionЕго подпись в дизайне, null, если дизайн её не задаёт
typeТип значения условия: string, number, date, boolean или json — тип ФИЛЬТРА, по которому читается и пишется value
comparesСравнения, которые для него предлагает штатный редактор фильтров, в том же написании, что и в filters
defaultCompareТо, с которого этот редактор начинает: названное дизайном, и оно не обязательно входит в compares

Действия, свойства в колонках и свойства без интеграционного SID не перечисляются: условия на них быть не может. Не перечисляется и файловое свойство: условие на нём всё так же перечисляется в filters, и его можно задать только без значения, как сказано выше. Поэтому представление, которое рисует только FILTERS(<group>), строит по нему редактор условия, хотя ни одного свойства группы в нём нет:

function FilterPanel({data, controller}) {
const typed = data.i.filterProperties.filter(p => p.type === 'string' || p.type === 'number');
const [choice, setChoice] = React.useState(typed[0]);
const [text, setText] = React.useState('');
const value = choice.type === 'number' ? (text === '' ? null : Number(text)) : text;
return <div>
<select onChange={e => setChoice(typed[e.target.selectedIndex])}>
{typed.map(p => <option key={p.property}>{p.caption || p.property}</option>)}
</select>
<input type={choice.type === 'number' ? 'number' : 'text'} onChange={e => setText(e.target.value)}/>
<button onClick={() => controller.i.filters.change({property: choice.property, compare: choice.defaultCompare, value})}>
Добавить
</button>
</div>;
}

value — того типа, который называет type, и преобразуется как любое значение свойства: этот редактор читает строку или число, поэтому предлагает только свойства string и number.

Управляющие элементы фильтров​

Управляющие элементы фильтров, которые рисует представление, одинаковы, кто бы ни рисовал условия, — само представление, получающее filters, или панель платформы, FILTERS(o), размещённый обратно с custom = NULL, — и каждый делает то же, что там делает кнопка платформы:

в узлечленнад условиями представлениянад панелью платформы
addFilter()сразу ставит то же условие, что и F3новое условие на той же ячейке с открытым редактором, как «добавить» панели
filtersPending, filtersManualapplyFilters()отказывает: ждать применения нечему, каждое изменение уходит сразуприменяет введённые условия — только пока filtersPending
resetFilters()очищает списоксбрасывает условия панели

filtersManual — показывает ли платформа кнопку применения: включена настройка, при которой её панель применяет изменения вручную (userFiltersManualApplyMode), и управляющие элементы показаны — их показывает и прячет кнопка фильтров тулбара. filtersPending — доступна ли эта кнопка: введённые условия ждут её. По умолчанию настройка выключена: изменение применяется сразу, оба остаются false, и представление может вовсе не рисовать FILTERCONTROLS(o) — группа фильтруется так же. Где условия рисует и само представление, resetFilters() очищает их так же, как filters.change([]): члены двух компонентов могут делать одно и то же — часть каждого компонента повторяет свой компонент.

function ItemFilterButtons({data, controller}) {
const items = data.i, buttons = controller.i;
return <div>
<button onClick={() => buttons.addFilter()}>+</button>
{items.filtersManual &&
<button disabled={!items.filtersPending} onClick={() => buttons.applyFilters()}>Применить</button>}
<button onClick={() => buttons.resetFilters()}>Сбросить</button>
</div>;
}

Ячейка, на которой добавляется условие, — текущая ячейка штатного грида, где строки рисует платформа, и первая колонка, которую несёт представление и показывает форма и на которой может быть условие, — текущей колонки у него нет — в его текущей строке, где строки рисует оно. У дерева это текущая ячейка штатного дерева или первая колонка группы текущего узла, которую показывает форма и на которой может быть условие, в текущей строке этой группы. Без FILTERS(o) в дизайне addFilter отклоняется — у платформы нет такой кнопки, — а F3 ничего не делает.

Внутри пакета команды входят в него, как filters.change.

Группы фильтров​

Группа фильтров, которую рисует представление, лежит в узле своей группы под своим именем, в форме записи свойства:

FORM items
OBJECTS i = Item
FILTERGROUP stock
FILTER 'В наличии' balance(i) > 0 'F10' DEFAULT
FILTER 'Нет в наличии' balance(i) <= 0
;
// data.i.stock
{value: 0, filters: ['В наличии', 'Нет в наличии'], canClear: true}

controller.i.stock.change(1); // выбрать «Нет в наличии»
controller.i.stock.change(null); // ни одного — как «(Все)» платформы
ПолеЗначение
valueИндекс выбранного фильтра в filters, null — ни одного
filtersПодписи фильтров в порядке дизайна
canClearМожно ли не выбрать ни одного — как это предлагает виджет платформы: группа из одного фильтра — её флажок, он снимается всегда; группа из нескольких — её список, с «(Все)», если нет NONULL. Где false, change(null) отклоняется

Выбранный фильтр применяет и сообщает сервер, поэтому value — то, что держит сервер: фильтр по умолчанию при открытии формы, выбор действия FILTERGROUP, — а фильтр, выбранный представлением, показывается сразу. change принимает индекс одного из filters или null, а остальное отклоняется. Он входит в пакет, как и filters.change, и обращается к серверу даже за показанным фильтром — после упавшего пакета тот задаётся заново. Клавиша фильтра продолжает работать, пока форма показывает группу фильтров — hidden её дескриптора — и показано представление, которое её рисует, вместе с хостом, в который это представление размещает контейнер, так, как её понимает виджет платформы: выбирает фильтр, а в группе из одного фильтра — флажке платформы — нажатая снова снимает его.

Группа фильтров принадлежит своей группе — последней для объектов, которые принимают её фильтры, той, к которой применяется фильтр формы, — в том числе группе дерева: она лежит в узле этой группы, data.<group>.<filterGroup>, а не в узле дерева, где лежит то, что у платформы одно на всё дерево, — его пользовательские фильтры, управляющие элементы фильтров, тулбар и сортировки, — хотя дизайн платформы ставит группы фильтров дерева в его FILTERGROUPS(TREE <tree>).

Фильтры по свойствам (FILTER)​

У свойства, объявленного пользовательским фильтром, — PROPERTIES(i) name FILTER или USER в блоке фильтров, — есть свой фильтр среди пользовательских фильтров его группы: компонент, который дизайн называет FILTER(name(i)), по имени свойства на форме, а панель фильтров платформы показывает строкой условия, которая есть с самого открытия формы. Фильтр по свойству, который рисует представление, — часть группы своего свойства там, как группа фильтров: в узле группы, в filter, по интеграционному имени свойства — один объект на фильтры, которые рисует представление, без прототипа, как byKey:

FORM items
OBJECTS i = Item
PROPERTIES(i) name FILTER, price
;

DESIGN items {
NEW search FIRST {
custom = 'ItemSearch';
MOVE FILTER(name(i));
}
}
// data.i.filter.name
{caption: 'Наименование', type: 'string', active: false, compare: '_', negation: false, or: false, value: null}

controller.i.filter.name.change('ab'); // отфильтровано по нему
controller.i.filter.name.change('ab', {negation: true}); // ... по его отрицанию
controller.i.filter.name.change(null); // ... и больше нет
ПолеЗначение
captionЗаголовок свойства в дизайне, null, если дизайн его не задаёт
typeТип значения условия: string, number, date, boolean или json — тип FILTER, по которому читается и пишется value
activeЕсть ли у фильтра условие — в том числе условие сравнения с NULL, у которого value, как и без условия, — null (у свойства BOOLEAN — false)
compare, negation, orСравнение условия фильтра, записанное так же, как в filters, отрицание условия и соединено ли оно со следующим по ИЛИ, как это говорит filters; пока условия нет — сравнение свойства по умолчанию, false и false
valueЗначение условия фильтра, null, пока условия нет

Условие фильтра — первое условие по его свойству среди пользовательских фильтров группы, как строка фильтра в панели платформы берёт первое условие по своей колонке, кто бы его ни задал: фильтр, filters.change, панель фильтров платформы, действие FILTER. change(value, options) задаёт его значение так же, как filters.change задаёт одно условие: на месте этого условия или, если условия нет, в новом. Параметры {compare, negation, or}, любые из них, задают сравнение условия — записанное так же, как в filters, — его отрицание и связку, как строка платформы при показанных управляющих элементах фильтров. Неуказанные параметры сохраняют значения текущего условия, а у нового условия это сравнение свойства по умолчанию, отсутствие отрицания и И. Параметр, которого change не знает, сравнение, которым условие сравнивать не может, — неизвестное или IN ARRAY, — и отрицание или or, отличные от true и false, отклоняются, и без значения тоже — тогда ничего не снимается. Без значения — отсутствующего, null, '' или такого, которое тип читает как NULL: false свойства BOOLEAN, — снимает это условие, что бы ни говорили параметры, и только его: вторая граница диапазона остаётся, как при очистке строки платформы. В пакете идёт с пакетом, как filters.change. Условие фильтра по файловому свойству можно только снять: его value — ссылка на файл, а не файл, с которым сравнивает условие.

export function ItemSearch({data, controller}) {
if (data['FILTER(name(i))'].hidden) return null;
const filter = data.i.filter.name;
return <label>{filter.caption}
<input value={filter.value ?? ''} onChange={e => controller.i.filter.name.change(e.target.value)}/>
</label>;
}

Фильтр по свойству не зависит от того, кто рисует условия группы: это представление, которое тогда получает и filters, или другое представление — тогда изменение уходит сразу, — или панель фильтров платформы. Над панелью платформы фильтр — её же строка, которую рисует представление: своей строки для него панель не показывает и задаёт его условие так же, как условия своих строк, — сразу или, в её ручном режиме применения при показанных управляющих элементах фильтров, когда пользователь применит условия; фильтр показывает изменение и до этого. Это строка панели, как любая другая: F3 на колонке, первая строка которой — она, кладёт в неё значение ячейки, сохраняя её сравнение, отрицание и связку, и применяет его так же — строку рисует React, поэтому редактор не открывается. Фильтр по свойству группы дерева лежит в узле своей группы, как группа фильтров группы дерева. Его запись, data['FILTER(name(i))'], говорит, показывает ли форма фильтр, — его SHOWIF или контейнера над ним.

Фильтр по свойству с custom = NULL — платформы: строка её панели фильтров, как её показывает обычная форма, которую представление размещает через <Platform name="FILTER(name(i))"/>, как любой дочерний компонент, который рисует платформа (размещение). Фильтр с собственным компонентом React — FILTER(name(i)) { custom = 'NameSearch'; } — рисует свою часть в собственном представлении (Собственное представление компонента).

Некоторые сочетания отклоняются при построении формы:

  • фильтр, который рисует представление, у группы в виде PANEL, у которой нет пользовательских фильтров;
  • фильтр, который рисует представление, по свойству, которое его условие не может назвать или дать ему значение: без интеграционного имени (NOEXTID), сгруппированному в колонки, действию;
  • фильтр, который должна рисовать платформа, там, где она не строит панель фильтров: с custom = NULL в представлении, когда условия группы рисует представление или тулбар выключает фильтры (showFilters = FALSE), и стоящий вне React, когда условия рисует представление.

Подмена только фильтров​

Генерируемые боксы группы — FILTERS(o), FILTERBOX(o), FILTERGROUPS(o), TOOLBARBOX(o), TOOLBARLEFT(o), TOOLBARRIGHT(o), TOOLBAR(o), POPUP(o), PANEL(o) — обычные контейнеры, поэтому каждый из них принимает custom. Компонент на одном из них подменяет только эту часть: строки продолжает рисовать штатный грид, а остальное в боксе остаётся платформе.

DESIGN items {
FILTERS(i) { custom = 'FilterPanel'; }
}

Такой компонент строк группы не рисует — их по-прежнему рисует платформа, — но пользовательские фильтры — его часть: props.data.<g> — узел группы в нём, с filters, с тем, на чём может быть условие (filterProperties), и без всего, что относится к строкам.

Своих условий платформа для такой группы не рисует, но её команды над ними остаются, и каждая меняет список группы — тот, который компонент видит в filters:

  • кнопка фильтров в тулбаре показывает и прячет управляющие элементы, которые платформа рисует в FILTERCONTROLS(o). Их сброс очищает список, а добавление делает то же, что F3. Если бокс компонента включает и FILTERCONTROLS(o), их рисует компонент: это его управляющие элементы фильтров, чьи resetFilters() и addFilter() делают то же самое, а кнопки фильтров в тулбаре нет;
  • F3 в гриде отдаёт значение его текущей ячейки свойству ячейки так же, как значение отдаётся фильтру по свойству: на место первого условия по свойству, сохраняя его сравнение, отрицание и or, а если такого нет — в конец, со сравнением свойства по умолчанию. Оно применяется сразу, как F3 и Enter на панели платформы: здесь нет редактора, чтобы ввести значение, его правит компонент. Alt+F3 оставляет только новое условие. Условие не начинается ни там, где текущей строки нет, — в пустом гриде, в сводной таблице, на карте, в календаре или пользовательском представлении, у которых нет ячейки, — ни на ячейке, свойства которой нет в filterProperties — кроме ячейки файлового свойства: F3 задаёт условие и на ней, а представление задаёт такое только без значения, — или которая показывает не то значение, по которому её свойство фильтруется;
  • Shift+F3 и Escape в гриде очищают список;
  • набор в колонке условия не начинает (грид с быстрым поиском ищет, как и раньше), и кнопки применения нет: изменение отправляется сразу, и настройка ручного применения ничего не придерживает.

Фильтры по свойствам, которые дизайн объявляет в боксе, — этого компонента: он рисует их как фильтры по свойствам.

Эти команды есть везде, где представление рисует бокс FILTERS группы в виде LIST, что бы ни говорил её TOOLBARSYSTEM(o): showFilters, как и остальные флаги show… тулбара, — только платформенный и выключает только то, что платформа рисует из фильтров: её панель, её управляющие элементы в FILTERCONTROLS и кнопку фильтров тулбара. При showFilters = FALSE и условиях, которые рисует представление, управляющие элементы фильтров представления и клавиши грида работают как обычно; при условиях платформы команд нет, и члены управляющих элементов, которые рисует представление, отказывают. Команды дерева те же — над условиями всех его групп, filters его узла, — и так же действует showFilters его тулбара.

props.controller.<g>.filters их меняет, поэтому панель и показывает текущие условия, и выставляет их: поле поиска из ItemBoard, без списка, и есть такая панель.

Условия, которые выставляет компонент, — это условия самой группы: действие FILTER, детализация сводной таблицы и этот компонент меняют один список и разойтись не могут.

TOOLBARSYSTEM(o), FILTERCONTROLS(o), FILTERGROUP(f) и фильтр по свойству FILTER(name(o)) не контейнеры, но каждый принимает собственный custom, и тогда на его месте рисует React-компонент (Собственное представление компонента).

То, что компонент получает состояние группы, не делает его единственным, кто это состояние рисует. Что рисуется, решают дизайн и размещение через <Platform>, а не содержимое data: состояние принадлежит группе, кто бы его ни рисовал. Отсюда два рецепта, и различаются они в дизайне, а не в проекции:

  • заменить фильтры — custom на FILTERS(o): компонент и есть область фильтров;
  • заменить всю группу — custom на BOX(o): больше не рисуется ничего, и фильтры принадлежат компоненту вместе со строками.

Компонент на соседнем с FILTERS(o) боксе — например, TOOLBARLEFT(o) — части фильтров не получает: в узле группы там, если он есть, filters нет.

Тулбар​

Где тулбар группы рисует представление, то, что показывают его кнопки, лежит в узле группы, props.data.<g>, а сами кнопки — члены группы, в props.controller.<g>. Своего тулбара для группы платформа тогда не рисует, и над своим гридом тоже. Шапка представления над штатным гридом:

DESIGN items {
NEW header FIRST {
custom = 'ItemHeader';
MOVE TOOLBARSYSTEM(i); // грид остаётся платформенным, тулбар — у представления
}
}
function ItemHeader({data, controller}) {
const items = data.i, buttons = controller.i;
const [count, setCount] = React.useState(null);
return <div>
<button onClick={async () => setCount(await buttons.getCount())}>Сколько</button>
{count != null && <span>Записей: {count}</span>}
<button onClick={() => buttons.export()}>В Excel</button>
<label>
<input type="checkbox" checked={items.manual}
onChange={e => buttons.manual.change(e.target.checked)}/> ручное обновление
</label>
{items.manual && <button disabled={!items.updatePending} onClick={() => buttons.update()}>Обновить</button>}
<select value={items.listView} onChange={e => buttons.listView.change(e.target.value)}>
{items.listViews.map(view => <option key={view}>{view}</option>)}
</select>
</div>;
}

Каждый член и то, что показывает его кнопка, есть, что бы ни говорили флаги дизайна, — showFilters, showViews, showCalculateSum, … только платформенные, по ним рисуется её собственный тулбар; представление рисует то, что хочет, — как и имя, которое каждый занимает в узле: там, где тулбар рисует представление, ни одно свойство группы не займёт getSum, что бы ни говорил showCalculateSum. visible — не из этих флагов: тулбар, который дизайн прячет (visible = FALSE), не часть ни одного представления — в узле от него ничего нет, членов нет, имён он не занимает.

в узлечленчто делаеткнопка платформы
manualmanual.change(on)включает или выключает ручное обновлениеручное обновление
updatePendingupdate()подтягивает изменения, которые сервер придерживает при ручном обновленииобновить
filterControlsVisiblefilterControlsVisible.change(shown)показаны ли управляющие элементы фильтров платформы — кнопки её FILTERCONTROLS и редакторы условий её панели FILTERS; на панели без условий — с условием для заполнениякнопка фильтров
listView, listViewslistView.change(name)показывает строки таблицы платформы в другом виде отображения — одном из listViews: grid, pivot, а также custom, map или calendar, где они у группы естьпереключатель вида отображения
getCount()число строк группы — только отмеченных, если пользователь их отметил, как считает платформа, — Promise ответа сервера, запрос, как getValuesколичество
getSum(property)сумма колонки — только по отмеченным строкам, если пользователь их отметил, как суммирует платформа, — Promise: числовое свойство группы, названное так, как его называет сортировка, не разложенное по колонкамсумма — текущей колонки, которой у представления нет, поэтому её называет вызов
export()выгрузка строк группы в Excel — серверный отчёт по нимэкспорт — на сводной таблице выгружающий её саму

То, на что действуют кнопки, — платформенное: filterControlsVisible показывает и прячет управляющие элементы фильтров — кнопки FILTERCONTROLS и редакторы ПЛАТФОРМЫ, и где у платформы их для группы нет — условия и FILTERCONTROLS(o) рисует представление или тулбар выключает фильтры платформы (showFilters = FALSE), — он остаётся false, а его изменение отклоняется с объяснением причины. Свои управляющие элементы представление показывает само — это его управляющие элементы фильтров. Переключатель вида отображения переключает таблицу платформы: где строки рисует представление, listViews пуст, listView равен null, а изменение отклоняется. У группы, строки которой рисует представление, тип вида списка — GRID по умолчанию: группа, которой форма задаёт другой (OBJECTS o = Order PIVOT, а также MAP, CALENDAR, CUSTOM), отклоняется при сборке формы, поскольку сервер открывает такую группу так, как это нужно представлению платформы этого вида, — задайте custom = NULL тому, что рисует строки, гриду группы или её дереву, чтобы их там рисовала платформа.

Внутри пакета члены, которые обращаются к серверу, отклоняются. Настроек таблицы платформы здесь нет — это её диалог над таблицей, которую она рисует. Тулбар, размещённый обратно с custom = NULL, их сохраняет.

Тулбар и управляющие элементы фильтров дерева​

У дерева один тулбар и один набор управляющих элементов фильтров на все его группы — TOOLBARSYSTEM(TREE t) и FILTERCONTROLS(TREE t). Где их рисует представление, они лежат в узле дерева data.t, кто бы ни рисовал строки дерева:

DESIGN catalogue {
NEW header FIRST {
custom = 'CatalogueHeader';
MOVE TOOLBARSYSTEM(TREE t); // дерево остаётся платформенным; его тулбар — представления
}
}
function CatalogueHeader({data, controller}) {
const buttons = controller.t; // t — имя дерева
return <div>
<button onClick={() => buttons.expandCurrent()}>Раскрыть текущий узел</button>
<button onClick={() => buttons.expandTree()}>Раскрыть дерево</button>
<button onClick={() => buttons.export()}>Excel</button>
<label>
<input type="checkbox" checked={data.t.filterControlsVisible}
onChange={e => buttons.filterControlsVisible.change(e.target.checked)}/> фильтры
</label>
</div>;
}

Кнопки тулбара штатного дерева становятся членами тулбара, что бы ни говорили его флаги show… — они только платформенные, как у тулбара группы, — а две, раскрывающие дерево, — только там, где строки дерева рисует платформа:

в узлечленчто делаеткнопка платформы
filterControlsVisiblefilterControlsVisible.change(shown)показаны ли управляющие элементы фильтров дерева платформы, как в тулбаре группыкнопка фильтров
export()выгружает дерево в Excel — отчёт сервера по немуэкспорт
expandCurrent()над штатным деревом: раскрывает его выделенный узел и все узлы под ним — только узел с детьми«Развернуть текущий объект»
expandTree()над штатным деревом: раскрывает все узлы группы выделенного узла под раскрытыми узлами выше и групп под ней — всё дерево, когда выделен узел верхней группы«Развернуть всё дерево»

Где строки дерева рисует представление, этих двух в тулбаре нет: дерево раскрывается членами, которые у него уже есть, — текущий узел собственным expandCurrent() дерева, в том же узле, где представление рисует и то и другое, дерево от группы вниз — expandAll() этой группы (всё дерево — верхней), — по одному члену на каждое действие. Поэтому то, какие члены раскрывают дерево, зависит от того, кто рисует его строки, и у представления, которое рисует тулбар дерева в другом контейнере, чем строки дерева, их там нет: они есть у представления, которое рисует строки. Они только раскрывают — штатное дерево из тулбара ничего не сворачивает. Внутри пакета expandCurrent() и expandTree() входят в него, как expandAll(), а export() отклоняется.

Управляющие элементы фильтров дерева — так же часть дерева, с данными и членами управляющих элементов фильтров группы, над условиями всех его групп.

Тулбар и фильтры платформы над строками представления​

Там, где строки группы рисует представление, платформа всё равно рисует то, что дизайн оставляет ей из тулбара и фильтров группы, — TOOLBARSYSTEM(o) и то, что содержат FILTERS(o) и FILTERCONTROLS(o), — если они вне контейнеров представления или размещены обратно с custom = NULL:

DESIGN items {
NEW cards {
custom = 'ItemCards';
MOVE GRID(i); // строки рисует представление; тулбар и фильтры остаются в BOX(i)
}
}

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

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

Дерево, которое рисует представление (MOVE GRID(TREE t)), получает то же самое — одно на все его группы, как у штатного дерева, — TOOLBARSYSTEM(TREE t) и то, что содержат FILTERS(TREE t) и FILTERCONTROLS(TREE t). В тулбаре — кнопки дерева: над строками представления кнопка, раскрывающая текущий объект, раскрывает то же, что expandCurrent() дерева, кнопка, раскрывающая дерево, — как над штатным деревом, все узлы группы текущего узла под раскрытыми узлами выше и узлы групп ниже, а пока текущего узла нет — ничего; всё дерево — это expandAll() верхней группы, — а экспорт выгружает дерево. Условия панели могут быть на колонках всех групп дерева, каждое — своей группы, а новое начинается на ячейке, на которой добавляется условие.

Того, что представление рисует само, платформа не строит: custom на BOX(o) рисует всю группу.

Собственное представление компонента​

Таблица, тулбар, управляющие элементы фильтров, группа фильтров и фильтр по свойству — GRID(o), GRID(TREE t) дерева, TOOLBARSYSTEM, FILTERCONTROLS, FILTERGROUP(fg) и FILTER(<property>) — принимают в custom компонент React так же, как контейнер, и компонент тогда рисует на их месте то, что они показывают из своей группы объектов или дерева: строки, кнопки тулбара, управляющие элементы фильтров, фильтр, выбранный в группе фильтров, условие фильтра по свойству. Остальная форма остаётся как есть:

DESIGN items {
GRID(i) { custom = 'ItemCards'; } // тулбар и фильтры над строками остаются платформенными
TOOLBARSYSTEM(j) { custom = 'OrderToolbar'; } // над платформенным гридом j
}

Компонент получает то же, что получил бы контейнер, в котором стоит только этот компонент, — NEW cards { custom = 'ItemCards'; MOVE GRID(i); }: часть группы или дерева на её узле, под теми же именами (таблица в начале) — строки и сортировки на data.i с controller.i.change(row), состояние и члены тулбара на data.j и controller.j, строки дерева на узлах его групп, а его собственное — на data.<tree>, группа фильтров — на data.<group>.<filterGroup>, фильтр по свойству — на data.<group>.filter.<property>. Больше на верхнем уровне ничего нет:

export function ItemCards({data, controller}) {
const items = data.i;
return <div className="cards">
{items.list.map(row =>
<div key={row.key} className={row.isCurrent ? 'current' : ''} onClick={() => controller.i.change(row)}>
{row.name.value}
</div>)}
</div>;
}

Собственные атрибуты дизайна компонента относятся к его представлению, как и к его представлению платформы: размер и место в контейнере, класс и SHOWIF, который прячет представление, — поэтому в его данных нет записи самого компонента, которая была бы у контейнера вокруг него. Внутри того, что рисует React-контейнер, у компонента собственное представление, и контейнер размещает его через <Platform name="GRID(i)"/>: запись компонента — в данных контейнера, а его части — в данных его собственного представления. Свойство, которое платформа рисует в строках, которые оно рисует, размещается построчно через <Platform name row/>; ничего другого оно не размещает.

Некоторые сочетания отклоняются при построении формы:

  • компонент, который там ничего не показывает, — таблица, тулбар или управляющие элементы фильтров группы в виде PANEL, у которой нет строк, и фильтр по свойству такой группы, поскольку пользовательских фильтров у неё нет; тулбар, скрытый visible = FALSE; группа фильтров без фильтров или без объекта, — не дал бы своему компоненту ничего для отрисовки;
  • таблица группы с видом списка, отличным от GRID, — PIVOT, MAP, CALENDAR, CUSTOM, — как и любое представление её строк (Тулбар);
  • таблица, в которую дизайн переносит компоненты, — MOVE PROPERTY(note(i)) внутри GRID(i) { ... }, запись, которую вид CUSTOM, MAP или CALENDAR показывает для строки во всплывающем окне: её представление не размещает представлений платформы, поэтому то, что содержит запись, рисует или размещает контейнер вокруг таблицы, NEW cards { custom = 'ItemCards'; MOVE GRID(i); };
  • значение, отличное от имени компонента: '', HTML-шаблон и свойство раскладывают потомков контейнера.

Атрибуты платформенной таблицы — valueClass таблицы, заданный свойством, hierarchicalCaption дерева — для её собственного представления ничего не значат, как и для таблицы, которую рисует React-контейнер: проекция не несёт ни того, ни другого.