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

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