How-to: Пользовательские компоненты (объекты)
По умолчанию каждый объект на форме с видом представления GRID отображается на форме в виде плоской таблицы со столбцами. Однако, в платформе существует возможность создавать свои собственные компоненты для визуализации списка объектов.
В качестве наглядного примера рассмотрим задачу по отображению в виде "плитки" списка товаров с изображениями.
Доменная логика
Для начала создадим классы и свойства товаров, а также форму редактирования:
CLASS Item 'Item';
name 'Name' = DATA STRING (Item) NONULL;
price 'Price' = DATA NUMERIC[12,2] (Item) NONULL;
image '' = DATA IMAGEFILE (Item);
FORM item 'Item'
OBJECTS i = Item PANEL
PROPERTIES(i) name, price, image
EDIT Item OBJECT i
;
DESIGN item {
OBJECTS {
MOVE PROPERTY(image(i)) {
fill = 1;
}
}
}
Для каждого товара должны быть заданы наименование, цена и изображение.
Интерфейс
Создадим форму со списком товаров. Для этого добавим на форму объект Товар, его свойства, а также действия по добавлению, редактированию и удалению:
FORM items 'Items'
OBJECTS i = Item CUSTOM 'itemCards'
PROPERTIES(i) READONLY image, price, name
PROPERTIES(i) NEWSESSION new = NEW, edit = EDIT GRID, DELETE GRID
;
NAVIGATOR {
NEW items;
}
При помощи ключевого слова CUSTOM указывается, что для отрисовки списка товаров должен использоваться не стандартный табличный интерфейс,
а компоненты, создаваемые функцией itemCards. Эту функцию объявим в файле itemcards.js, который поместим в папку resources/web. Это путь без сборки — обычный файл .js, без JSX и упаковки; где размещается пользовательский JS и о варианте со сборкой см. How-to: Пользовательские клиентские JS-модули.
Она будет возвращать объект, состоящий из двух функций: render и update. Регистрируется именно функция: платформа сама вызывает itemCards() и берет render и update из результата, поэтому нельзя регистрировать сам объект с этими функциями без обертки-функции.
Функция render принимает на вход контроллер и элемент, внутри которого должны создаваться новые элементы, необходимые для отображения данных:
render: (element, controller) => {
let cards = document.createElement("div")
cards.classList.add("item-cards");
element.cards = cards;
element.appendChild(cards);
},
В данном примере мы создаем новый div cards, запоминаем его и добавляем внутрь element.
Для обновления отображаемых значений платформа будет каждый раз вызывать функцию update, в которую будет передан тот же element, что и в функции render, а также список значений list:
update: (element, controller, list) => {
while (element.cards.lastElementChild) {
element.cards.removeChild(element.cards.lastElementChild);
}
for (let item of list) {
let card = document.createElement("div")
card.classList.add("item-card");
if (controller.isCurrent(item))
card.classList.add("item-card-current");
let cardImage = document.createElement("img")
cardImage.classList.add("item-card-image");
cardImage.src = item.image;
card.appendChild(cardImage);
let cardPrice = document.createElement("div")
cardPrice.classList.add("item-card-price");
cardPrice.innerHTML = item.price;
card.appendChild(cardPrice);
let cardName = document.createElement("div")
cardName.classList.add("item-card-name");
cardName.innerHTML = item.name;
card.appendChild(cardName);
element.cards.appendChild(card);
card.onclick = function(event) {
if (!controller.isCurrent(item)) controller.changeObject(item);
}
card.ondblclick = function(event) {
controller.changeProperty('edit', item);
}
}
}
Так как функция update вызывается каждый раз, когда изменяются данные, то первым делом происходит удаление всех ранее созданных элементов (а именно карточек товаров).
В list передается не весь набор объектов, а только считанная страница: для группы объектов с видом представления CUSTOM ее размер по умолчанию — 1000 объектов. Чтобы представление получало все объекты группы, задайте в блоке OBJECTS опцию PAGESIZE 0 (читать все объекты).
В данном примере используется самая простая схема обновления, но при необходимости ее можно оптимизировать путем обновления DOM только для изменившихся значений.
Для этой цели у controller есть метод diff, в который передаются новый список объектов list и функция-обработчик. Метод сравнивает переданный список со списком из предыдущего вызова (при первом вызове — с пустым) и выдает изменения как последовательный сценарий преобразования старого списка в новый: обработчик вызывается для каждого изменения и получает его тип ('add', 'update' или 'remove'), позицию изменения в преобразуемом списке (для 'add' и 'update' она совпадает с позицией объекта в новом списке) и сам объект (для 'remove' — удаленный, для остальных типов — новый); после этого переданный список запоминается. Два необязательных флага уточняют сравнение: при noDiffObjects строка одного объекта не превращается в строку другого — вместо 'update' такая пара дает 'remove' и 'add', а при removeFirst обработчик получает сначала все удаления — с позициями в предыдущем списке — и только затем добавления и изменения.
Пример:
controller.diff(list, (type, index, object) => {
switch (type) {
case 'add': ...; break;
case 'update': ...; break;
case 'remove': ...; break;
}
}, true, true);
Метод clearDiff сбрасывает запомненный список — его вызывают в необязательной функции clear компонента, которая вызывается при очистке представления с теми же element и controller, чтобы следующая отрисовка началась с пустого состояния.
После удаления старых элементов для каждого объекта из массива list создается свой div card, в который помещаются нужные элементы отображения каждого свойства.
Названия полей объектов соответствуют названию свойств на форме. Значения свойств преобразуются в значения JS так же, как в строках React-представления: например, значения классов даты и времени передаются как Date, а JSON — как разобранный объект. При помощи метода isCurrent определяется, какой объект из списка является текущим.
В самом конце функции добавляются обработчики нажатия кнопки мыши на карточку товара.
По одиночному нажатию у контроллера вызывается метод changeObject, который изменяет текущий объект. Второй параметр (rendered) не указывается (то есть считается равным false), что означает, что сервер должен в итоге вызвать функцию update с новым списком объектов (возможно тем же). Так как значение метода isCurrent изменится, то повторное создание карточек товаров изменит текущий выделенный объект в интерфейсе.
По двойному нажатию вызывается метод changeProperty, который изменяет текущее значение свойства edit для объекта, переданного вторым параметром. Поскольку edit является действием, то третий параметр - значение, на которое необходимо изменить текущее значение свойства, не передается, и вместо изменения будет произведен вызов этого действия. В данном случае будет открыта форма редактирования товара.
Чтобы объединить функции render и update в одну, создается функция itemCards, которая возвращает их внутри одного объекта:
function itemCards() {
return {
render: function (element, controller) => {
...
},
update: function (element, controller, list) {
...
}
}
}
Для завершения настройки дизайна создадим файл itemcards.css, которую также поместим в папку resources/web:
.item-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
grid-auto-rows: 200px;
grid-gap: 10px;
}
.item-card {
cursor: pointer;
display: flex;
flex-direction: column;
overflow: hidden;
align-items: center;
padding: 8px;
}
.item-card-current {
background-color: lightblue;
}
.item-card-image {
flex: 1;
min-height: 100px;
}
.item-card-price {
font-weight: bold;
}
.item-card-name {
color: gray;
}
Для того, чтобы при открытии страницы в браузере, загрузились созданные js и css файлы, нужно добавить их инициализацию в действии onWebClientInit путем добавления имени файла в свойство onWebClientInit(STRING). Числовое значение необходимо для задания порядка загрузки:
onWebClientInit() + {
onWebClientInit('itemcards.js') <- 1;
onWebClientInit('itemcards.css') <- 2;
}
В результате получившаяся форма будет выглядеть следующим образом:

Методы контроллера
Методы локального контроллера, передаваемого в render и update, не считая служебных (необязательные аргументы — в скобках). Здесь property — интеграционное имя свойства, добавленного на форму в этой группе объектов, object — объект из списка list:
| метод | что делает |
|---|---|
isCurrent(object) | является ли объект текущим в группе |
changeObject(object[, rendered]) | задает текущий объект группы (см. выше) |
changeProperty(property[, object][, value]) | изменяет значение свойства или выполняет действие — на текущем объекте или на переданном (см. выше) |
changeProperties(properties, objects, values) | несколько вызовов changeProperty одним запросом из параллельных массивов |
getValue(property, object) | текущее значение свойства для объекта |
getCaption(property) | заголовок свойства |
isPropertyReadOnly(property, object) | доступность свойства для правки: null — редактируемое, false — только чтение |
getBackground(property, object) / getForeground(property, object) | цвет фона / текста ячейки |
getFont(property, object) | шрифт ячейки |
getPlaceholder(property, object), getPattern(property, object), getRegexp(property, object), getRegexpMessage(property, object), getTooltip(property, object), getValueTooltip(property, object) | значения одноименных атрибутов дизайна свойства |
getCaptionClass(property), getGridClass(property, object), getValueClass(property, object) | CSS-классы, заданные одноименными атрибутами дизайна |
getChangeKey(property, object) / getChangeMouse(property, object) | заданное на форме сочетание клавиш / событие мыши, изменяющее свойство |
getPropertyCustomOptions(property, object) | значение опции OPTIONS свойства (разобранный JSON) |
getPropertyValues(property, value[, mode], ok[, fail][, count]) | список подсказок значений свойства с сервера |
diff(list, fnc[, noDiffObjects][, removeFirst]) / clearDiff() | вычисляет изменения списка (см. выше) |
setBooleanViewFilter(property, pageSize) | фильтрует представление условием «значение свойства истинно» |
setDateIntervalViewFilter(startProperty, endProperty, pageSize, start, end) | фильтрует представление по интервалу дат |
getColorThemeName() | имя текущей цветовой темы: 'LIGHT' или 'DARK' |
form | контроллер формы |
Определение «значение или строка» в changeProperty, формат значений и правила уточнения имени — те же, что у одноименного метода контроллера формы; свойство, не являющееся колонкой этой группы, changeProperty передает контроллеру формы, который разрешает его в пределах всей формы. changeProperties применяет несколько изменений одним запросом — например, встроенное представление диаграммы Ганта при перетаскивании полосы задачи изменяет обе даты сразу:
controller.changeProperties(['start', 'end'], [task, task], [newStart, newEnd]);
getPropertyValues использует те же режимы mode и формат результата, что и одноименный метод контроллера формы, но свойство разрешается среди колонок этой группы, а запрос выполняется для ее текущего объекта:
controller.getPropertyValues('name', query, result => { ... });
setBooleanViewFilter и setDateIntervalViewFilter задают серверный фильтр представления и размер страницы pageSize: в следующий вызов update список приходит уже отфильтрованным. setBooleanViewFilter оставляет объекты, у которых значение свойства property истинно. setDateIntervalViewFilter оставляет объекты, у которых период от значения startProperty до значения endProperty пересекается с интервалом от start до end (значения — JS Date; если endProperty равен null, оба конца периода берутся из startProperty); так, встроенное представление календаря читает только события видимого диапазона дат.
Геттеры отображения позволяют компоненту использовать атрибуты дизайна, задаваемые на форме: например, встроенное представление диаграмм строит наборы данных, беря заголовок и цвета колонок из getCaption, getBackground и getForeground.
Вызов сервера
Пользовательское представление объекта обращается к серверу через контроллер формы: он доступен как controller.form из локального контроллера представления, как props.controller в React-представлении либо как контроллер, передаваемый в функцию INTERNAL CLIENT (последним аргументом, после преобразованных параметров вызова). Его методы exec / eval / evalAction / change выполняются на сервере и возвращают Promise; их сигнатуры — в разделе Вызов сервера. Далее в этом разделе — как ведут себя эти серверные вызовы: преобразование результата, сессии и авторизационная проверка.
Результат преобразуется в значение JS:
| Результат на сервере | Значение в JS |
|---|---|
| скалярное число, строка, логическое значение или дата | число, строка, логическое значение или Date |
JSON | разобранный объект или массив |
JSONTEXT, XML | исходная строка |
файл — EXPORT, изображение или свойство файлового типа | строка со ссылкой для скачивания |
отсутствующий результат или NULL | undefined |
Параметры передаются как обычные значения JS (число, строка, логическое значение, Date или объект/массив для параметра типа JSON) и привязываются позиционно. Объект lsFusion передать нельзя — передаётся его числовой id; если параметр типизирован классом, платформа сама находит объект этого класса по id. Дескриптор строки (row.objects) объектом не является: для типизированного классом параметра вызов завершается ошибкой. Ошибка — отсутствующее действие или свойство, ошибка в скрипте или исключение во время выполнения — отклоняет Promise с её сообщением.
В форме вызовы выполняются в сессии формы, поэтому изменение видно последующим вызовам и фиксируется при применении изменений формы. В навигаторе каждый вызов выполняется в своей сессии, поэтому изменение отбрасывается, если скрипт не зафиксирует его через APPLY, а чтение видит сохранённое в базе состояние.
По умолчанию эти вызовы ограничены так же, как внешний HTTP-API: при enableAPI = 0 вызов разрешён, только если действие или свойство помечено @@api (что заодно открывает его по HTTP), либо у пользователя есть права администратора. Чтобы контроллер конкретной формы мог вызывать выбранные действия и свойства в обход этого ограничения, их перечисляют в блоке CUSTOMS формы — тогда для доступа достаточно того, что пользователь может открыть эту форму, и явного перечисления:
FORM order 'Order'
OBJECTS o = Order
PROPERTIES(o) number, note
CUSTOMS round, format = formatSum, taxRate
;
Теперь controller.form.exec("round", 3.14159), controller.form.exec("format", 1990, "USD") и controller.form.change("taxRate", 0.2) работают на этой форме без @@api и enableAPI (React-представление вызывает то же на props.controller). Каждую запись можно переименовать псевдонимом (format = formatSum), снабдить префиксом ACTION, чтобы выбрать действие, и указать полностью с сигнатурой (round[NUMERIC]) для выбора перегрузки; exec требует действие, change — свойство. Параметры передаются вызывающей стороной позиционно как обычные значения — в фазе 1 записи это в основном такие примитивные вызовы. Блок меняет то, какие вызовы разрешены, а не то, как связываются параметры, и не ограничивает значения аргументов, которые передаёт вызывающая сторона, поэтому перечислять стоит только записи, безопасные при любых аргументах (привязка параметра к собственному объекту формы — фаза 2). eval/evalAction выполняют произвольный скрипт и остаются под ограничением. Сквозной работающий пример — один компонент CUSTOM, вызывающий действия с параметрами-объектами, примитивами и JSON, читающий результат RETURN и изменяющий свойства через CUSTOMS, — в статье How-to: Пользовательские компоненты (вызовы сервера).
Для вызова, нужного пользовательскому представлению, предпочитайте CUSTOMS, а не @@api: CUSTOMS ограничивает доступ этой формой, тогда как @@api ещё и открывает действие или свойство для внешнего HTTP-API. Помечайте @@api только то, что действительно входит в этот API.