How-to: Пользовательские компоненты (свойства)
Для каждого типа свойства по умолчанию используется свой предопределенный визуальный компонент для отображения и редактирования данных. Однако, существует возможность переопределять компоненты на свои собственные, создаваемые при помощи JavaScript. Эта функциональность поддерживается только в веб-клиенте.
Рассмотрим задачу по созданию чата для общения между пользователями с целью демонстрации этой возможности.
Доменная логика
Для начала создадим доменную логику, в которой определена сущность Сообщение. Каждое сообщение содержит плоский текст, а также информацию об авторе и времени отправки.
CLASS Message 'Message';
dateTime 'Time' = DATA DATETIME (Message);
text 'Text' = DATA TEXT (Message);
author = DATA CustomUser (Message);
nameAuthor 'Author' (Message m) = name(author(m));
own (Message m) = author(m) = currentUser();
replyTo = DATA Message (Message);
nameAuthorReplyTo (Message m) = nameAuthor(replyTo(m));
textReplyTo (Message m) = text(replyTo(m));
Отображение списка сообщений
Список сообщений в чате на форме будем отображать компонентом, написанным на JavaScript. Каждое сообщение показывает сразу несколько значений — автора, время, текст, цитируемое сообщение, — а компонент свойства получает только значение своего свойства. Поэтому список сообщений отображается пользовательским компонентом группы объектов: все нужные свойства добавляются на форму как обычно, и компонент получает их значения по именам свойств на форме. Компонент свойства понадобится ниже — для поля ввода нового сообщения.
Создадим форму чата. При помощи ключевого слова CUSTOM указывается, что список сообщений должен отображаться при помощи функции chatMessages, которая будет написана на JavaScript:
FORM chat 'Chat'
OBJECTS msg = Message CUSTOM 'chatMessages' LAST
PROPERTIES(msg) READONLY nameAuthor, dateTime, text, own, nameAuthorReplyTo, textReplyTo
;
Далее настраиваем дизайн формы, помещая список сообщений в новый контейнер с идентификатором chat, а также удаляем ненужные компоненты, созданные автоматически:
DESIGN chat {
OBJECTS {
NEW chat {
fill = 1;
MOVE GRID(msg);
REMOVE BOX(msg);
}
}
REMOVE TOOLBARBOX;
}
Добавляем форму в навигатор:
NAVIGATOR {
NEW chat;
}
Далее создадим при помощи JavaScript и CSS компонент, который будет отображать сообщения в браузере.
Компонент создадим в файле chat.js, который расположим в папке resources/web. Это путь без сборки — обычный файл .js, без JSX и упаковки. Где размещается пользовательский JS и о варианте со сборкой см. How-to: Пользовательские клиентские JS-модули. controller, который получают эти классические компоненты, описан в How-to: API контроллера пользовательского представления.
Внутри файла chat.js создадим функцию chatMessages. Она будет возвращать объект, состоящий из двух функций: render и update.
Функция render принимает на вход элемент, внутри которого должны создаваться новые элементы, необходимые для отображения данных, а также контроллер. В ней создается и запоминается контейнер, в котором будут отображаться сообщения:
render: function (element, controller) {
let messages = document.createElement("div");
messages.classList.add("chat-messages");
element.messages = messages;
element.appendChild(messages);
}
Для обновления отображаемых значений платформа будет каждый раз вызывать функцию update, в которую будет передан тот же element, что и в функции render, контроллер, а также список сообщений list. Каждый элемент списка содержит значения свойств, добавленных на форму, в полях с именами этих свойств: nameAuthor, dateTime, text и так далее. Функция удаляет ранее созданные элементы и создает для каждого сообщения из списка свою структуру элементов:
update: function (element, controller, list) {
while (element.messages.lastElementChild) {
element.messages.removeChild(element.messages.lastElementChild);
}
for (let item of list) {
let message = document.createElement("div");
message.classList.add("chat-message");
if (item.own)
message.classList.add("chat-message-own");
if (controller.isCurrent(item))
message.classList.add("chat-message-current");
let header = document.createElement("div");
header.classList.add("chat-header");
let author = document.createElement("div");
author.classList.add("chat-author");
author.innerText = item.nameAuthor || '';
header.appendChild(author);
let replyAction = document.createElement("a");
replyAction.classList.add("chat-reply-action");
replyAction.appendChild(document.createTextNode("Reply"));
header.appendChild(replyAction);
message.appendChild(header);
let replyContent = document.createElement("div");
replyContent.classList.add("chat-reply-content");
let replyAuthor = document.createElement("div");
replyAuthor.classList.add("chat-reply-author");
replyAuthor.innerText = item.nameAuthorReplyTo || '';
replyContent.appendChild(replyAuthor);
let replyText = document.createElement("div");
replyText.classList.add("chat-reply-text");
replyText.innerText = item.textReplyTo || '';
replyContent.appendChild(replyText);
message.appendChild(replyContent);
let text = document.createElement("div");
text.classList.add("chat-text");
text.innerText = item.text || '';
message.appendChild(text);
let time = document.createElement("div");
time.classList.add("chat-time");
time.innerText = item.dateTime ? item.dateTime.toLocaleString() : '';
message.appendChild(time);
element.messages.appendChild(message);
}
let current = element.messages.querySelector(".chat-message-current");
if (current)
current.scrollIntoView({ block: "nearest" });
}
Значения свойств приходят преобразованными в значения JS: текстовые — строками, own — логическим значением, dateTime — объектом Date, поэтому время форматируется средствами браузера.
Текущее сообщение группы определяется методом isCurrent контроллера и выделяется классом chat-message-current. После обновления оно прокручивается в видимую область.
В результате для каждого сообщения будет создана следующая структура элементов:
<div class="chat-message chat-message-own">
<div class="chat-header">
<div class="chat-author">John Doe</div>
<a class="chat-reply-action">Reply</a>
</div>
<div class="chat-reply-content">
<div class="chat-reply-author"></div>
<div class="chat-reply-text"></div>
</div>
<div class="chat-text">Hello world !</div>
<div class="chat-time">05.10.2021, 15:28:05</div>
</div>
Для каждого элемента задается свой класс, который используется для дизайна при помощи CSS :
.chat-messages {
display: flex;
flex-direction: column;
}
.chat-message {
margin: 6px;
border: 1px solid;
border-radius: 10px;
padding: 6px;
display: flex;
flex-direction: column;
}
.chat-message-current {
border-color: blue;
}
.chat-header {
display: flex;
align-content: stretch;
justify-content: space-around;
}
.chat-author {
font-weight: bold
}
.chat-reply-action {
cursor: pointer;
margin-left: 4px;
}
.chat-reply-content {
border-left: 2px solid;
padding-left: 4px;
margin: 4px;
border-color: blue;
cursor: pointer;
flex: 1;
}
.chat-reply-author {
color: grey
}
.chat-reply-text {
white-space: pre-wrap;
max-height: 100px;
overflow: clip;
}
.chat-text {
white-space: pre-wrap;
}
.chat-message-own {
background-color: lightblue;
margin-left: 100px;
}
.chat-time {
color: grey
}
Чтобы объединить эти две функции в одну, создается новая функция chatMessages, которая возвращает их внутри одного объекта:
function chatMessages() {
return {
render: function (element, controller) {
...
},
update: function (element, controller, list) {
...
}
}
}
Для того, чтобы при открытии страницы в браузере, загрузились созданные js и css файлы, нужно добавить их инициализацию в действии onWebClientInit путем добавления имени файла в свойство onWebClientInit(STRING). Числовое значение необходимо для задания порядка загрузки:
onWebClientInit() + {
onWebClientInit('chat.js') <- 1;
onWebClientInit('chat.css') <- 2;
}
Сообщение, отображаемое при помощи созданного компонента, будет выглядеть следующим образом:
Обработка действий пользователя
В этом примере будем обрабатывать два действия пользователей для любого из сообщений: нажатие на цитируемое сообщение и нажатие на кнопку Reply. В первом случае будет осуществлен переход к исходному сообщению, а во втором - запоминание этого сообщения в локальное свойство и установка фокуса в поле ввода нового сообщения.
Объявим для них действия и добавим их на форму:
replyTo = DATA LOCAL Message ();
goToReply (Message m) { seek(replyTo(m)); } // переходим к цитируемому сообщению
reply (Message m) { replyTo() <- m; } // запоминаем текущее сообщение в локальное свойство
EXTEND FORM chat
PROPERTIES(msg) goToReply, reply
;
Для выполнения этих действий используется параметр controller, передаваемый в функцию update: его метод changeProperty выполняет действие, добавленное на форму, для переданного сообщения. Обработчики добавляются в функции update при создании элементов сообщения:
replyAction.onclick = function(event) {
controller.changeProperty('reply', item);
$(this).closest("div[lsfusion-container='chat']").find(".chat-message-input-area").focus();
}
replyContent.onmousedown = function(event) {
controller.changeProperty('goToReply', item);
}
По нажатию на кнопку Reply также происходит поиск поля для ввода сообщения при помощи jQuery и установка в него текущего фокуса. Элемент DOM с классом chat-message-input-area будет создан позднее.
Отправка нового сообщения
Осталось добавить на форму возможность пользователю создавать новые сообщения.
Для начала создадим действие send[], которое будет создавать новое сообщение в отдельной сессии
на основе локального свойства message[] и определенного ранее свойства replyTo[], а затем очищать их:
message = DATA LOCAL TEXT ();
send 'Send' () {
NEWSESSION NESTED LOCAL {
NEW m = Message {
dateTime(m) <- currentDateTime();
author(m) <- currentUser();
replyTo(m) <- replyTo();
text(m) <- message();
seek(m);
APPLY;
}
}
message() <- NULL;
replyTo() <- NULL;
}
Цитируемое сообщение покажем над полем ввода обычными свойствами формы, а для отмены цитирования объявим действие:
replyAuthor 'Reply to' () = nameAuthor(replyTo());
replyText '' () = STRING(text(replyTo()));
removeReply 'Cancel reply' () { replyTo() <- NULL; }
Поле ввода нового сообщения — это компонент свойства message[]: платформа передает в него текущее значение свойства, а введенный текст компонент возвращает через контроллер.
Создадим функцию chatMessageInput, которая будет генерировать этот компонент. Для ввода будем использовать элемент div с атрибутом contentEditable:
function chatMessageInput() {
return {
render: function (element, controller) {
let text = document.createElement("div");
text.classList.add("chat-message-input-area");
text.contentEditable = "true";
element.text = text;
element.appendChild(text);
},
update: function (element, controller, value) {
element.text.innerText = value || '';
}
}
}
В функцию update параметром value передается значение свойства message[] — строка либо null, если свойство пусто.
CSS для создаваемого элемента будет выглядеть следующим образом:
.chat-message-input-area {
flex: 1;
align-self: stretch;
max-height: 300px;
min-height: 90px;
padding: 4px;
overflow: auto;
}
В результате компонент будет выглядеть следующим образом:
Далее добавляем обработчики событий, которые будут отсылать сообщение по нажатию CTRL+ENTER,
а также записывать введенное сообщение в свойство message[] при потере компонентом фокуса.
Введенный текст передается методом change контроллера: он попадает в обработку изменения свойства message[] так же, как значение, введенное штатным редактором, — для первичного свойства это запись значения в него.
Действие send[] выполняется методом changeProperty контроллера формы, доступного как controller.form. Запросы выполняются на сервере в порядке вызова, поэтому к моменту выполнения send[] введенный текст уже записан в message[].
Обработчики добавляются в функции update:
element.text.onkeydown = function(event) {
if (event.keyCode == 10 || event.keyCode == 13)
if (event.ctrlKey) {
controller.change(element.text.innerText);
controller.form.changeProperty('send');
} else
event.stopPropagation(); // останавливаем дальнейшую обработку нажатия клавиши ENTER
}
element.text.onblur = function (event) {
controller.change(element.text.innerText);
}
Добавляем поле для ввода и цитируемое сообщение на форму, а также кнопку Send.
При помощи ключевого слова CUSTOM указывается, что значение свойства message[] должно отображаться при помощи созданной ранее функции chatMessageInput.
Если после свойства указано действие с ключевым словом ON CHANGE, вместо стандартной обработки изменения выполняется оно, а значение, переданное методом change, подставляется вместо ввода пользователя в его запрос значения:
EXTEND FORM chat
PROPERTIES replyAuthor() READONLY SHOWIF replyTo(), replyText() READONLY SHOWIF replyTo(), removeReply() SHOWIF replyTo(),
message() CUSTOM 'chatMessageInput',
send()
;
Изменяем дизайн формы, чтобы цитируемое сообщение, поле для ввода сообщения и кнопка Send располагались под списком сообщений:
DESIGN chat {
chat {
NEW reply {
horizontal = TRUE;
MOVE PROPERTY(replyAuthor());
MOVE PROPERTY(replyText());
MOVE PROPERTY(removeReply());
}
NEW chatMessage {
horizontal = TRUE;
alignment = STRETCH;
MOVE PROPERTY(message()) {
fill = 1;
autoSize = TRUE;
width = 0;
caption = '';
}
MOVE PROPERTY(send()) { fontSize = 32; alignment = STRETCH; }
}
}
}
Благодаря установке атрибутов autoSize и width компонент ввода будет растягиваться по мере увеличения размера сообщения.
Итоговая форма будет выглядеть следующим образом:

Методы контроллера
Методы контроллера, передаваемого в функцию update, не считая служебных (необязательные аргументы — в скобках):
| метод | что делает |
|---|---|
change([value]) | передает изменение значения (см. выше); без аргумента действие изменения вызывается без значения |
getValues(value, ok[, fail][, count]) | список подсказок с сервера для вводимого значения value — тех же, что при штатном редактировании свойства |
isReadOnly() | доступность свойства для правки: null — редактируемое, false — только чтение, true — отключено |
diff(list, fnc[, noDiffObjects][, removeFirst]) | вычисляет изменения массива значений — как метод diff в Пользовательские компоненты (объекты); элементы сопоставляются по полю objects |
getColorThemeName() | имя текущей цветовой темы: 'LIGHT' или 'DARK' |
form | контроллер формы |
Обработчик ok метода getValues получает результат в том же формате, что и getPropertyValues контроллера формы. Функция render и необязательная функция clear, вызываемая при очистке ячейки, получают вторым аргументом сокращенный контроллер — из его методов полезен clearDiff(), сбрасывающий запомненный методом diff список.
Пользовательский редактор
По умолчанию значение редактируется штатным механизмом свойства — например, текстовым полем ввода. Ключевое слово CHANGE представления значения CUSTOM позволяет заменить или дополнить этот механизм собственным редактором на JavaScript:
PROPERTIES(o) address CUSTOM CHANGE 'googleAutocomplete'
Функция редактора, как и функция отображения, регистрируется оберткой и возвращает объект из нескольких функций. Вид редактора определяется тем, какая функция отрисовки есть в этом объекте:
| функция | редактор |
|---|---|
renderInput(element, controller, value) | дополняет штатное текстовое поле ввода; element — само это поле |
renderDialog(element, controller, value) | редактор-окно |
render(element, controller, value) | редактор, замещающий содержимое ячейки |
value — текущее значение свойства, преобразованное в значение JS. Кроме функции отрисовки, объект может содержать:
getValue(element)— возвращает значение для фиксации. Строка'canceled'отменяет правку. Для редактора поля ввода при отсутствии этой функции берется текст самого поля.clear(element, cancel)— очистка при завершении правки.cancelистинен при отмене.onBrowserEvent(event, element)— обработка событий браузера во время правки.
Контроллер редактора предоставляет:
| метод | что делает |
|---|---|
commit([value]) | фиксирует правку: переданное значение либо, без аргумента, текущее (из getValue или поля ввода) |
cancel() | отменяет правку |
setDeferredCommitOnBlur(true) | откладывает фиксацию при потере фокуса — для выбора из списка подсказок, отрисованного вне element |
getColorThemeName() | имя текущей цветовой темы |
form | контроллер формы |
Зафиксированное значение проходит обычный канал изменения свойства — так же, как значение, введенное штатным редактором.
Например, встроенный редактор googleAutocomplete дополняет поле ввода адреса подсказками Google Maps:
function googleAutocomplete() {
return {
renderInput: (element, controller) => {
// список подсказок отрисовывается вне element, и выбор из него завершается потерей фокуса,
// поэтому фиксация по потере фокуса откладывается до установки значения
controller.setDeferredCommitOnBlur(true);
new google.maps.places.Autocomplete(element, { types: ['address'] });
},
clear: (element, cancel) => {
// удаляем элементы списка подсказок, добавленные в <body>
$(".pac-container").remove();
}
};
}