How-to: Custom components (properties)
For each property type, by default, a predefined visual component is used to display and edit the data. However, it is possible to override components with your own, created with JavaScript. This functionality is only supported in the web client.
Consider the task of creating a chat room for communication between users to demonstrate this capability.
Domain logic
First, let's create a domain logic in which the Message entity is defined. Each message contains plain text, as well as information about the author and sending time.
CLASS Message 'Message';
dateTime 'Time' = DATA DATETIME (Message);
text 'Text' = DATA TEXT (Message);
author = DATA CustomUser (Message);
nameAuthor 'Author' (Message m) = name(author(m));
replyTo = DATA Message (Message);
nameAuthorReplyTo (Message m) = nameAuthor(replyTo(m));
textReplyTo (Message m) = text(replyTo(m));
Message list display
The list of chat messages on the form will be displayed as rows in a table with one column. For this column the value display component will be changed to one that will be written in JavaScript. The easiest way to represent the value is in the form of JSON format string, which will store all the parameters of the message. To build this string we will use operator JSON :
json (Message m) =
JSON FROM
author = nameAuthor(m),
time = dateTime(m),
text = text(m),
own = IF author(m) = currentUser() THEN 1 ELSE 0,
replyAuthor = nameAuthorReplyTo(m),
replyText = textReplyTo(m),
replyMessage = replyTo(m);
Example value:
{
"author":"John Doe",
"time":"2021-10-05T15:28:05",
"text":"Hello, Jack!",
"own":1,
"replyAuthor":"Jack Smith",
"replyText":"Hello, John",
"replyMessage":31302
}
Next, use JavaScript and CSS to create a component that will display messages in the browser.
The component will be created in the chat.js file, which will be located in the resources/web folder. This is the no-build path — a plain .js file, no JSX or bundling; see How-to: Custom client JS modules for where custom JS goes and for the with-build alternative. The controller these classic components receive is described in How-to: Custom view controller.
Inside the chat.js file, create chatMessageRender function. It will return an object consisting of two functions: render and update.
The render function takes as input an element within which the new elements necessary to display the data should be created:
render: function (element) {
let message = document.createElement("div")
message.classList.add("chat-message");
let header = document.createElement("div");
header.classList.add("chat-header");
let author = document.createElement("div");
author.classList.add("chat-author");
element.author = author;
header.appendChild(author);
let replyAction = document.createElement("a");
replyAction.classList.add("chat-reply-action");
let replyCaption = document.createTextNode("Reply");
replyAction.appendChild(replyCaption);
element.replyAction = replyAction;
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");
element.replyAuthor = replyAuthor;
replyContent.appendChild(replyAuthor);
let replyText = document.createElement("div");
replyText.classList.add("chat-reply-text");
element.replyText = replyText;
replyContent.appendChild(replyText);
element.replyContent = replyContent;
message.appendChild(replyContent);
let text = document.createElement("div");
text.classList.add("chat-text");
element.text = text;
message.appendChild(text);
let time = document.createElement("div");
time.classList.add("chat-time");
element.time = time;
message.appendChild(time);
element.message = message;
element.appendChild(message);
}
This code inside element creates and stores new div for each message, which will then be used to draw parts of the message. The result will be the following component structure:
<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">2021-10-05T15:28:05</div>
</div>
Each element has its own class, which is used to design with CSS:
.chat-message {
margin: 6px;
border: 1px solid;
border-radius: 10px;
padding: 6px;
display: flex;
flex-direction: column;
}
.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 {
display: flex;
}
.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
}
To update the displayed values, the platform will call the update function each time, in which the same element will be passed, as in the render function, as well as the value itself:
update: function (element, controller, value) {
element.author.innerHTML = value.author || '';
element.replyAuthor.innerHTML = value.replyAuthor || '';
element.replyText.innerHTML = value.replyText || '';
element.time.innerHTML = value.time;
element.text.innerHTML = value.text || '';
if (value.own)
element.message.classList.add('chat-message-own');
else
element.message.classList.remove('chat-message-own');
}
This function takes as the value parameter the JavaScript object that is calculated from the previously described json property. The values of all fields are written to the elements that were previously constructed in the render function.
To combine these two functions into one, a new function chatMessageRender is created, which returns them within the same object:
function chatMessageRender() {
return {
render: function (element) {
...
},
update: function (element, controller, value) {
...
}
}
}
In order to load created js and css files when the page opens in the browser, you must add their initialization to the action onWebClientInit by adding the file name to the onWebClientInit(STRING) property. A numeric value is needed to specify the order of loading:
onWebClientInit() + {
onWebClientInit('chat.js') <- 1;
onWebClientInit('chat.css') <- 2;
}
The message displayed by the created component will look like this:
Handling user actions
In this example, we will handle two user actions for any of the messages: clicking on the quoted message and clicking on the Reply button. In the first case, the transition to the original message will be done, and in the second case - storing the message in local property and setting the focus in the input field of the new message.
The controller parameter, passed to the update function, is used to notify the server of an event made by the user:
element.replyAction.onclick = function(event) {
controller.change({ action : 'reply' });
$(this).closest("div[lsfusion-container='chat']").find(".chat-textarea").focus();
}
element.replyContent.onmousedown = function(event) {
controller.change({ action : 'goToReply' });
}
Clicking on the quoted message also searches for the message field using jQuery and sets the current focus to it. The DOM element with the class chat-message-input-area will be created later.
Depending on the action made by the user, the change method is called on the controller, which sends information about the event as a JSON-object. The platform will automatically pass the value to the defined action changeMessage :
replyTo = DATA LOCAL Message ();
changeMessage (Message m) {
INPUT f = JSON DO
IMPORT JSON FROM f FIELDS() STRING action DO { // import the file as a json in local properties
IF action = 'goToReply' THEN
seek(replyTo(m)); // go to the quoted message
IF action = 'reply' THEN
replyTo() <- m; // store the current message in a local property
}
}
This action reads the object passed from JavaScript, parses JSON, and then performs the appropriate action.
Finally, create a chat form and add a table with a list of messages to it. The table will have exactly one column, the value of which will be JSON built earlier. The keyword CUSTOM specifies that the value is to be displayed using the chatMessageRender function created earlier. The action specified after the keyword ON CHANGE is called when the controller.change method is executed for the selected message.
FORM chat 'Chat'
OBJECTS msg = Message LAST
PROPERTIES(msg) json CUSTOM 'chatMessageRender' ON CHANGE changeMessage(msg)
;
Next, customize the form design by placing the table with the list of messages in a new container with the identifier chat, and remove unnecessary components created automatically:
DESIGN chat {
OBJECTS {
NEW chat {
fill = 1;
MOVE GRID(msg) {
captionHeight = 0;
PROPERTY(json(msg)) {
autoSize = TRUE;
}
}
REMOVE BOX(msg);
}
}
REMOVE TOOLBARBOX;
}
Add a form to the navigator:
NAVIGATOR {
NEW chat;
}
Send a new message
It remains to add to the form the ability for the user to create new messages.
First, let's create a send action that will create a new message in a separate session based on the local message property and the previously defined replyTo property:
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;
}
}
}
Similar to the json property described earlier, we create a new jsonInputMessage property, which will be used by the component to enter a new message:
jsonInputMessage () = JSON FROM
replyAuthor = nameAuthor(replyTo()),
replyText = text(replyTo()),
text = message();
Next, create a function that will generate a component to display and input a new message. For this we will use the div element with the contentEditable attribute:
function chatMessageInputRender() {
return {
render: function (element) {
let input = document.createElement("div");
input.classList.add("chat-message-input");
let reply = document.createElement("div");
reply.classList.add("chat-reply");
let replyContent = document.createElement("div");
replyContent.classList.add("chat-reply-content");
let replyAuthor = document.createElement("div");
replyAuthor.classList.add("chat-reply-author");
element.replyAuthor = replyAuthor;
replyContent.appendChild(replyAuthor);
let replyText = document.createElement("div");
replyText.classList.add("chat-reply-text");
element.replyText = replyText;
replyContent.appendChild(replyText);
element.replyContent = replyContent;
reply.appendChild(replyContent);
let replyRemove = document.createElement("div");
replyRemove.classList.add("chat-reply-remove");
element.replyRemove = replyRemove;
reply.appendChild(replyRemove);
input.appendChild(reply);
let text = document.createElement("div");
text.classList.add("chat-message-input-area");
text.contentEditable = "true";
element.text = text;
input.appendChild(text);
element.appendChild(input);
},
update: function (element, controller, value) {
if (value !== null) {
element.replyAuthor.innerHTML = value.replyAuthor || '';
element.replyText.innerHTML = value.replyText || '';
element.replyRemove.innerHTML = value.replyAuthor ? '❌' : '';
element.text.innerHTML = value.text || '';
}
}
}
The CSS for the created elements will look like this:
.chat-message-input {
display: flex;
flex-direction: column;
flex: 1;
align-self: stretch;
max-height: 300px;
min-height: 90px;
}
.chat-reply-remove {
justify-content: flex-end;
align-items: center;
display: flex;
cursor: pointer;
margin-right: 10px;
}
.chat-message-input-area {
padding: 4px;
}
As a result, the component will look like this:
Then we add event handlers that will delete the quoted message, send the message when CTRL+ENTER is pressed, and write the entered message to a local property when the component loses focus.
The browser side will have the following JavaScript code:
element.replyRemove.onclick = function(event) {
controller.change({ action : 'replyRemove' });
}
element.text.onkeydown = function(event) {
if (event.keyCode == 10 || event.keyCode == 13)
if (event.ctrlKey)
controller.change({ action : 'send', value : element.text.innerHTML })
else
event.stopPropagation(); // stop further processing after pressing ENTER
}
element.text.onblur = function (event) {
controller.change({ action : 'change', value : element.text.innerHTML });
}
The changeInputMessage action will receive these events on the server:
changeInputMessage () {
INPUT f = JSON DO
IMPORT JSON FROM f FIELDS() STRING action, TEXT value DO {
IF action = 'replyRemove' THEN
replyTo() <- NULL;
IF action = 'send' THEN {
message() <- value;
send();
}
IF action = 'change' THEN
message() <- value;
}
}
Add an input field to the form based on the previously declared functions and actions, as well as a Send button:
EXTEND FORM chat
PROPERTIES jsonInputMessage() CUSTOM 'chatMessageInputRender' ON CHANGE changeInputMessage(),
send()
;
Change the design of the form, so that the field for entering a message and the Send button were under the list of messages:
DESIGN chat {
chat {
NEW chatMessage {
horizontal = TRUE;
alignment = STRETCH;
MOVE PROPERTY(jsonInputMessage()) {
fill = 1;
autoSize = TRUE;
width = 0;
caption = '';
}
MOVE PROPERTY(send()) { fontSize = 32; alignment = STRETCH; }
}
}
}
By setting the autoSize and width attributes, the input component will stretch as the message size grows.
The final form will look like this:
Controller methods
The methods of the controller passed to the update function, internal helpers aside (optional arguments are bracketed):
| method | what it does |
|---|---|
change([value]) | pass a value change (see above); with no argument the change action is called without a value |
getValues(value, ok[, fail][, count]) | a server suggestion list for the typed value — the same suggestions as when the property is edited normally |
isReadOnly() | whether the property can be edited: null — editable, false — read-only, true — disabled |
diff(list, fnc[, noDiffObjects][, removeFirst]) | compute the changes of a value array — like the diff method in Custom components (objects); the items are matched by their objects field |
getColorThemeName() | the current color theme name: 'LIGHT' or 'DARK' |
form | the form controller |
The ok handler of getValues receives the result in the same format as the form controller's getPropertyValues. The render function and the optional clear function, invoked when the cell is cleared, receive a reduced controller as the second argument — of its methods, clearDiff() is the useful one, resetting the list remembered by diff.
Custom editor
By default the value is edited by the property's standard mechanism — a text input, for instance. The CHANGE keyword of the CUSTOM value view replaces or extends that mechanism with an editor of your own, written in JavaScript:
PROPERTIES(o) address CUSTOM CHANGE 'googleAutocomplete'
The editor function, like the display function, is registered as a wrapper and returns an object of several functions. The editor kind is determined by which rendering function that object contains:
| function | editor |
|---|---|
renderInput(element, controller, value) | extends the standard text input; element is that input itself |
renderDialog(element, controller, value) | a window editor |
render(element, controller, value) | an editor replacing the cell content |
value is the current property value converted to a JS value. Besides the rendering function, the object may contain:
getValue(element)— returns the value to commit; the string'canceled'cancels the edit. For an input editor, when this function is absent, the text of the input itself is taken.clear(element, cancel)— cleanup when the edit finishes;cancelis true on cancellation.onBrowserEvent(event, element)— handling browser events during the edit.
The editor controller provides:
| method | what it does |
|---|---|
commit([value]) | commit the edit: the passed value or, with no argument, the current one (from getValue or the input) |
cancel() | cancel the edit |
setDeferredCommitOnBlur(true) | defer the commit on focus loss — for picking from a suggestion list rendered outside element |
getColorThemeName() | the current color theme name |
form | the form controller |
The committed value goes through the property's ordinary change channel — the same way as a value entered by the standard editor.
For example, the built-in googleAutocomplete editor extends an address input with Google Maps suggestions:
function googleAutocomplete() {
return {
renderInput: (element, controller) => {
// the suggestion list is rendered outside element, and picking from it ends with a focus loss,
// so the commit on focus loss is deferred until the value is set
controller.setDeferredCommitOnBlur(true);
new google.maps.places.Autocomplete(element, { types: ['address'] });
},
clear: (element, cancel) => {
// remove the suggestion list elements added to <body>
$(".pac-container").remove();
}
};
}