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

В данном примере используется самая простая схема обновления, но при необходимости ее можно оптимизировать путем обновления DOM только для изменившихся значений. Для этой цели у controller есть метод getDiff, в который параметром нужно передать новый список объектов list. Этот метод в качестве результата вернет объект с массивами add, update, remove, в которых будут храниться соответственно добавленные, изменившиеся и удаленные объекты. Пример:

let diff = controller.getDiff(list);
for (let object of diff.add) { ... }
for (let object of diff.update) { ... }
for (let object of diff.remove) { ... }

После удаления старых элементов для каждого объекта из массива list создается свой div card, в который помещаются нужные элементы отображения каждого свойства. Названия полей объектов соответствуют названию свойств на форме. При помощи метода 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;
}

В результате получившаяся форма будет выглядеть следующим образом:

Вызов сервера

Помимо рассмотренных выше методов отрисовки, controller позволяет клиентскому JS обращаться к серверу и получать результат в виде Promise. Тот же объект передается первым аргументом в JavaScript-функцию, заданную в действии INTERNAL CLIENT.

  • exec(action, ...params) — выполняет именованное действие; результатом становится возвращаемое действием значение (RETURN), если оно есть.
  • eval(script, ...params) — выполняет lsf-скрипт, который сам определяет действие run, поэтому его параметрам можно задать явные типы.
  • evalAction(script, ...params) — выполняет тело действия, оборачивая его в действие run, к параметрам которого обращаются позиционно как $1, $2, ….
  • change(property, ...keyParams, value) — изменяет свойство; последний аргумент — значение, предыдущие — ключи.
controller.exec('recalc', orderId);
controller.eval('run(INTEGER a) { RETURN a * 2; }', 21).then(v => console.log(v)); // 42
controller.evalAction('RETURN 2 + 3;').then(v => console.log(v)); // 5
controller.change('note', orderId, 'checked');

Результат преобразуется в значение JS:

Результат на сервереЗначение в JS
скалярное число, строка, логическое значение или датачисло, строка, логическое значение или Date
JSONразобранный объект или массив
JSONTEXT, XMLисходная строка
файл — EXPORT, изображение или свойство файлового типастрока со ссылкой для скачивания
отсутствующий результат или NULLundefined

Параметры передаются как обычные значения JS (число, строка, логическое значение, Date или объект/массив для параметра типа JSON) и привязываются позиционно. Ошибка — отсутствующее действие или свойство, ошибка в скрипте или исключение во время выполнения — отклоняет Promise с её сообщением.

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

По умолчанию эти вызовы ограничены так же, как внешний HTTP-API: при enableAPI = 0 вызов разрешён, только если действие или свойство помечено @@api (что заодно открывает его по HTTP), либо у пользователя есть права администратора. Чтобы контроллер конкретной формы мог вызывать выбранные действия и свойства в обход этого ограничения, их перечисляют в блоке API формы — тогда для доступа достаточно того, что пользователь может открыть эту форму, и явного перечисления:

FORM order 'Order'
OBJECTS o = Order
PROPERTIES(o) number, note
API round, format = formatSum, taxRate
;

Теперь controller.exec("round", 3.14159), controller.exec("format", 1990, "USD") и controller.change("taxRate", 0.2) работают на этой форме без @@api и enableAPI. Каждую запись можно переименовать псевдонимом (format = formatSum), снабдить префиксом ACTION, чтобы выбрать действие, и указать полностью с сигнатурой (round[NUMERIC]) для выбора перегрузки; exec требует действие, change — свойство. Параметры передаются вызывающей стороной позиционно как обычные значения — в фазе 1 записи это в основном такие примитивные вызовы. Блок меняет то, какие вызовы разрешены, а не то, как связываются параметры, и не ограничивает значения аргументов, которые передаёт вызывающая сторона, поэтому перечислять стоит только записи, безопасные при любых аргументах (привязка параметра к собственному объекту формы — фаза 2). eval/evalAction выполняют произвольный скрипт и остаются под ограничением.