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

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, изображение или свойство файлового типастрока со ссылкой для скачивания
отсутствующий результат или NULLundefined

Параметры передаются как обычные значения 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.