Responsive UI

This document explains the UI engine for responsive usage (applicable to version 4.0 and above).

Conception

The responsive UI is based on the disposition responsive with a simple startup and site parts configured as disposition resources:

The engine is a common MVC (Model-View-Contoller) interface:

Model

The main site is loaded once with its disposition resources, then the UI exchanges metadata and data with the back-end.

View

The view layer provides the rendering with several functions:

Extension: the engine needs a renderer tool, only one component have been yet released:

Styles: the engine loads also the user's theme:

Controller

The controller provides the business logic between the model and the view:

All the rendering will be made by the browser in one-page based on JQuery and Bootstrap v3. Most functions have been implemented on responsive UI, other functions are only wrapped in an iframe with the backend rendering (i.e. crosstab, script editor).

External libs

This engine integrates some popular plugins:

Global properties

Simplicite.UI.Globals contains all the default options that will be used by each object. Each UI object gets a copy in obj.locals.ui to override the default behaviors.

Options can be changed in :

Simplicite.UI.Globals

Name Description Default value
container Optional UI location null = 'body'
resources Optional list of resources to load null = [MAIN, HEADER, FOOTER, MENU, WORK]
context UI launcher: object, name, rowId null = current disposition
theme CSS theme null = 'default'
themes Themes to switch, null: no switch available ['default','light','dark']
font Specific Google font name null = none
defaultContentLoad Optional handler when a content is displayed
defaultContentUnload Optional handler when a content is replaced
onload Optional page loaded called before the ready callback
onbeforeunload Optional page before unload
onunload Optional page unload
useMainParts Must the engine load the default main page ? true
useSocial Is the social function active ? true
useCopyLink Allows to copy deeplink to objects true
useUndoRedo Allows undo/redo: true, false or "keys" for CTRL-Y/Z only true
shortcuts true/false or list of shortcuts {name, label, url, target, icon} true
slideNav Slide screen on push/pull navigation ? true
tinymceOptions tinymce default options
ajaxSetup Ajax global options see below
scope Multi-apps options see below
exports Export default options { CSV, XLS, PDF, ARC, XML, ZIP } see below
list Object list default options see below
form Object form default options see below
search Object search default options see below
summary Object summary default options see below

Simplicite.UI.Globals.ajaxSetup

Name Description Default value
crossDomain Use CORS HTTP access ? true
xhrFields Force some xhr values { withCredentials: true }

Simplicite.UI.Globals.scope

Name Description Default value
enabled true/false or array of authorized scopes {home, url, icon, label, help} true
backend allows to return to backend ? true

Simplicite.UI.Globals.exports

Name Description Default value
CVS CVS export options { enabled:true, sep:';' }
XLS Excel export options { enabled:true, formats: { poi: "Excel", html: "HTML" }}
PDF PDF export options { enabled:true }
ARC Archive export options { enabled:true }
XML Simplicite XML export options { enabled:true, inline:true, timestamp:false }
ZIP Simplicite ZIP export options { enabled:true }

Simplicite.UI.Globals.list

Name Description Default value
container target container for the list #work if undefined
beforeload(ctn,obj) optional trigger before loading the list
onload(ctn,obj) optional trigger when list is displayed
onunload(ctn,obj) optional trigger before unloading the list
display(ctn,obj,params,cbk) optional function to override default rendering
title list title object label
minified false: displays rows as table records or true: summaries false
minifiable allows to switch rendering rows to summaries true
layout minified obj-grid layout "article", "masonry", "inline" or "float" 'masonry'
areas array of field-areas to display on header row visible areas
columns array of fields/columns to display visible fields on list
rows array of rows to display [ items ] result of search service
sort allows the sort by columns true
search allows search by 'column', 'popup' or 'docked' dialog 'popup'
groupBy flag to group fields if specified false
filters optional filters - override object filters and updatable by user
fixedFilters optional fixed filters - override filters and not updatable by user
forceSearch show the search dialog when the list is opened
showFilters show each user filter on top of list true if PANEL_FILTER=yes
showSearchInlined show/hide the inlined search by columns false
showIndex show the fulltext search metadata.indexable
indexRequest current index search request
oncreate create handler, null or false: no creation bind create action if granted
actions arrays { list, listPlus, row, rowPlus } of actions {name, label, confirm, callback, showLabel, ...}
null:no action
metadata.actions
floating ensure some actions to stay visible during vertical scrolling true
onopen(ctn,obj,rowId) handler when record is opened/clicked engine.openObject
onhelp(ctn,obj) handler to display the long help engine.displayHelp
showAreaTitles show area titles true
renderTitle(obj,fld,label) optional title rendering
renderValue(obj,fld,val) optional cell rendering
rowActionsRight true to display actions on the right side false
followLinks allows references navigation true
rowOpenDocs add links to open documents and images true
isExtended show/hide extended fields on list, except creation false
msg array of messages to display on list
msgRow array of row messages {rowId:[msg]} to display per rowId
help contextual help
nav optional navigation 'new' or 'add'
showNav displays the navigation bar true
showTotals displays the total row if any true
context list context LIST, PANEL, UPDATE Simplicite.CONTEXT_LIST
inst optional instance name the_ajax_
parent optional parent object for panel
view optional view container { name, item, home }
edit edit list current mode = 'newline' or 'rows'
listEdit allows to edit cells ? from metadata
addEdit allows to add row on list ? from metadata
bulkDelete allows bulk deletion on list ? from metadata
bulkUpdate allows bulk update on list ? from metadata
selectRows can select rows for bulk actions ?
onSelectRow select row handler call service
template list template see below
<div class="objlist">
    <div class="panel panel-default panel-list">
        <div class="panel-heading">
            <div class="head">
                <div class="obj-title">
                    <img class="icon-title"/>
                    <h4 class="form-title"/>
                </div>
                <div class="list-filters"/>
                <div class="list-actionbar"/>
            </div>
        </div>
        <div class="panel-body">
            <form autocomplete="off" onsubmit="return false;"/>
        </div>
        <div class="panel-footer"/>
    </div>
</div>

nota

Simplicite.UI.Globals.form

Name Description Default value
container target container for the form '#work' if undefined
beforeload(ctn,obj) optional trigger before loading the form
onload(ctn,obj) optional trigger when form is displayed
onunload(ctn,obj) optional trigger before unloading the form
display(ctn,obj,params,cbk) optional function to override default rendering
title form title userkey label
values fields values result of get service
onsave(ctn,obj,cbk) handler to save the form engine.saveForm
onsaveclose(ctn,obj,cbk) handler to save and close the form engine.saveForm + closeForm
onsavenew(ctn,obj,cbk) handler to save and create engine.saveForm + create
onsavecopy(ctn,obj,cbk) handler to save and copy engine.saveForm + copy
onclose(ctn,obj) handler to close form engine.closeForm
activateSaveOnChange activate the save button only on change event ? false
actionAutoSave auto-save form on custom action true
onhelp(ctn,obj) handler to display the long help engine.displayHelp
onsocial(ctn,obj) handler to display the social posts engine.displaySocial
actions { form, formPlus } arrays of actions {name, label, confirm, callback...}
null:no action
metadata.actions
transitions arrays of transition actions {name, label, confirm, callback...}
null: no transition
floating ensure some actions to stay visible during vertical scrolling true
readonly read only applies on fields
use onsave/onclose/actions/transitions to disable buttons
isExtended show/Hide extended fields on form false
msg optional array of messages
help contextual help
nav optional navigation 'new' or 'add'
to navigate on list 'first', 'prev', 'next' or 'last'
showNav displays the navigation bar true
showOptions displays the options menu true
showAreas displays fields areas in 'tabs' or 'split' mode if no UI template 'tabs'
showViews displays views/links, true/false, 'tabs', 'vertical' or 'split' 'vertical'
followLinks allows references navigation true
createLinks allows references creation true
formTab selected tab index per tab area
viewTab selected view metadata.defaultView
inst optional instance name the_ajax_
copy gets the item for copy false
workflow displays the form within a workflow false
parent optional parent object { name, inst, field, rowId }
constraints applies object constraints true
areaColumn default columns per area without UI template 2
template form template see below
<div class="objform">
    <div class="panel panel-default">
        <div class="panel-heading">
            <div class="head">
                <div class="obj-title">
                    <img class="icon-title"/>
                    <h4 class="form-title"/>
                </div>
                <div class="form-actionbar"/>
            </div>
        </div>
        <div class="panel-body">
            <form autocomplete="off" class="panel-form" onsubmit="return false;">
                <div class="form-areas"/>
            </form>
            <div class="form-navbar"/>
        </div>
    </div>
</div>

nota

Type Element
AREA <div class="area" data-area="2,3,4,view,obj;ref:="/>
ACTION <div class="action" data-action="name"/>
FIELD <div class="field" data-field="fullinput"></div>
LABEL <div|span class="field" data-field="fullinput" data-display="label"/>
VALUE <div|span class="field" data-field="fullinput" data-display="value"/>
INPUT <div class="field" data-field="fullinput" data-display="input"/>
IMAGE <div class="field" data-field="fullinput" data-display="image"/>
DOC <div class="field" data-field="fullinput" data-display="preview"/>
TEXT <span|div|h1... class="text" data-text="code"/>
BUTTON <button|div class="btn" data-obj="" data-rowid=""/>

Simplicite.UI.Globals.search

Name Description Default value
beforeload(ctn,obj) optional trigger before loading the search
onload(ctn,obj) optional trigger when form is displayed
onunload(ctn,obj) optional trigger before unloading the form
display(ctn,obj,params,cbk) optional function to override default rendering
title form title object label
msg optional messages
help contextual help
fields searchable fields from metadata
fixedFilters optional fixed filters - override filters and not updatable by user
showSorting displays the column sorting true
showIndex displays the fulltext search metadata.indexable
isExtended show/hide fields on search form false
position current position of search: 'column', 'popup' or 'docked'
minified is parent list minified ? false
inst optional instance name

Simplicite.UI.Globals.summary

Name Description Default value
beforeload(ctn,obj) optional trigger before loading the summary
onload(ctn,obj) optional trigger when summary is displayed
actions { row, rowPlus } of actions {name, label, confirm, callback...}
null: no action
from metadata
icon show the object icon true
image show the object first image true
fields list of fields, null: no field visible on list and not empty
maxFields limit fields size, 0: no field 10
layout search index obj-grid layout 'article', 'masonry', 'inline' or 'float' 'masonry'
template summary template see below
<div class="obj-summary">
    <div class="obj-body">
        <div class="title">
            <img class="obj-icon"/>
            <div>
                <h4 class="obj-title"/>
                <h5 class="obj-label"/>
            </div>
        </div>
        <div class="image">
            <img class="obj-image"/>
        </div>
        <div class="obj-fields"/>
        <div class="obj-addons"/>
    </div>
    <div class="obj-actions"/>
</div>

nota

Type Element
ACTION <div class="action" data-action="name"/>
FIELD <span|div|img class="field" data-field="fullinput"/>
TEXT <span|div class="text" data-text="code"/>
BUTTON <button|div class="btn" data-obj="" data-rowid=""/>

Simplicite.UI.Globals.agenda

Name Description Default value
beforeload(ctn,obj,agd) optional trigger before loading the calendar
onload(ctn,obj,agd) optional trigger when form is displayed
onunload(ctn,obj,agd) optional trigger before unloading the form
minTime optional minimum time '00:00:00'
maxTime optional maximum time (exclusive) '24:00:00'
startTime optional work time start Agenda start or '09:00'
endTime optional work time end Agenda end or '18:00'
snap optional snap duration Agenda quantum or '00:05:00'
slot optional slot duration Agenda quantum or '00:30:00'
height optional height (in px, auto or function) 800
login optional user login filter
group optional group name filter
date optional date to display today
click(event) optional open event handler Open event form
select(start,end) optional range selection handler Open creation form
drop(event,delta,revert) optional drag&drop handler Update the event date in DB, revert if no allowed
resize(event,delta,revert) optional event resizing handler Update the event duration in DB, revert if not allowed
title(obj,item) optional title handler Label fields or the user-key label
editable(obj,item) optional handler to indicate if a specific event is editable true if object is updatable
color(obj,item) optional handler to color the event Generate a color per user/group
borderColor(obj,item) optional handler of event border color 'grey'
textColor(obj,item) optional handler of event text color 'black'
render(event,element) optional handler to render the event
column(moment) optional handler to display the date header

Devices

Desktop

Default behaviors on large desktop :

Tablet

Almost similar as desktop, except :

Mobile

Client side hooks

The UI engine is a singleton named $ui.

main page startup

Use system parameters :

or the dispositon SCRIPT to override properties or functions, for example:

(function(ui,$) {
    // Bind ui.ready to document = engine is loaded and UI is ready
    $(document).on("ui.ready", function() {
        // customize UI here    
        // UI options = Globals merged with disposition script thru $ui.ready
        var opt = ui.options;
        // Disable links on forms
        opt.form.followLinks = false;
        // Export to Excel with POI only
        opt.exports.XLS.formats = { poi: "Excel" };
        // Remove some Admin menu to GUEST users
        if (ui.grant.hasReponsibility("GUEST"))
            $(".main-menu [data-obj='myAdminObject']").remove();
        //...
    });

    // Bind ui.beforeunload to document = UI and Ajax are still available
    $(document).on("ui.beforeunload", function() {
        // window will be unloaded
        var app = ui.getAjax();
        ui.alert({
            title: app.T("INFO"),
            content: "bye " + ui.grant.getFullName()
        });     
    });

    // Bind ui.unload to document = last call
    $(document).on("ui.unload", function() {
        // window is unloaded
    });
})(window.$ui, jQuery);

Business object hooks

To add specific behaviors, the designer must add a JS resource named SCRIPT to register the object in the global Simplicite.UI.hooks. The hook will be called once by each object instance (the_ajax, panel_ajax...).

For example:

(function(ui) {
    // Is it the responsive UI ?
    if (!ui) return;
    // Ajax services with current grant, menu, texts...
    var app = ui.getAjax();
    // Register the hooks for myObject
    Simplicite.UI.hooks.myObject = function(o, cbk) {
        try {
            // In the example hooks will be available on main instance only
            if (o.isMainInstance()) {
                console.log("myObject hook loading...");
                // object UI parameters = clone of the globals properties
                var p = o.locals.ui;
                // When object form is loaded in the container ctn
                p.form.onload = function(ctn, obj) {
                    // Alert on big amount
                    var amount = ui.getUIField(ctn, obj, "myAmount"),
                        check = ui.getUIField(ctn, obj, "myCheck");
                    if (parseInt(amount.ui.val()) > 5000) {
                        // Simple alert
                        ui.alert(app.T("MESSAGE_TO_CHECK_THE_AMOUNT"));
                        // Uncheck on screen
                        check.ui.val(false);
                    }
                    // Bind change on a field to change one other field
                    var field = ui.getUIField(ctn, obj, "myField");
                    field.ui.on("change", function() {
                        var v = field.ui.val(),
                            f = ui.getUIField(ctn, obj, "myOtherField");
                        // Change properties
                        f.ui.visible(v ? Simplicite.VIS_HIDDEN: Simplicite.VIS_BOTH);
                        f.ui.updatable(f.required && v=="123");
                    });
                };
            }
        }
        catch(e) {
            // Thank you to isolate your scripts
            app.error("Error in Simplicite.UI.hooks.myObject: "+e.message);
        }
        finally {
            // Required callback when hooks are loaded
            cbk && cbk();
        }
    };
})(window.$ui);

Example: set image to field

/** 
 * Sample method to change the image
 * @param data Base64 encoded image "data:image/png;base64,..."
 * @function
 */
function setImage(data) {
    // Object instance displayed on screen
    var user = $ui.getAjax().getBusinessObject("User", "the_ajax_User");
    // Image field on UI
    var pict = $ui.getUIField($("#work"), user, "usr_image_id");
    // Change the image on screen if field exists
    pict && pict.ui.val({
        id: "0", // new image
        name: "picture.png",
        mime: "image/png",
        content: data.split(",")[1] // exclude "data:image/png;base64,"
    });
}

Styles

For example:

/* Change the border of a form group of field */
.objform.object-myObject .form-group[data-group='myField'] {
    border: solid 3px #FF6;
}
/* Hide the minifiable button on list */
.objlist.object-myObject .btn-minifiable {
    display: none;
}
/* Hide area 3 on mobile XS screen */
@media screen and (max-width: 767px) {
    .objform.object-myObject .area[data-area='3:='] {
        display: none!important;
    }
}

Stand-alone usage

The engine can be loaded in a stand-alone page:

Example:

<!DOCTYPE html>
<html>
<head>
    <title>Simplicite custom web front-end demo</title>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, minimum-scale=1.0, maximum-scale=1.0">
    <script type="text/javascript" src="http(s)://<base URL>/scripts/jquery/jquery.js"></script>    
    <script type="text/javascript" src="http(s)://<base URL>/scripts/ui/engine.js" charset="UTF-8"></script>
    <script type="text/javascript" src="scripts.js"></script>
</head>
<body>
<div class="container main" style="display:none">
    <header>
        <nav class="navbar navbar-default">
            <div class="container-fluid">
                <div class="navbar-header">
                    <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#header-collapse" aria-expanded="false">
                        <span class="sr-only">Toggle navigation</span>
                        <span class="icon-bar"></span>
                        <span class="icon-bar"></span>
                        <span class="icon-bar"></span>
                    </button>
                    <a class="navbar-brand" href="#">Simplicité</a>
                </div>
                <div class="collapse navbar-collapse" id="header-collapse">
                    <ul class="nav navbar-nav">
                        <li class="menu menu-catalog"><a href="#">Catalog</a></li>
                        <li class="menu menu-orders"><a href="#">My orders</a></li>
                    </ul>
                    <form class="navbar-form navbar-right">
                        <span class="logged-client"></span>
                        <div class="form-group">
                            <input type="text" class="form-control input-login" placeholder="enter your client ID">
                        </div>
                        <button type="button" class="btn btn-default btn-login">Login</button>
                    </form>
                </div>
            </div>
        </nav>
    </header>
    <div id="work">
        <div id="nav"></div>
        <div id="content"></div>
    </div>
</div>
</body>
</html>

Script:

(function($) {
    // Ajax connector
    var app = new Simplicite.Ajax("http(s)://<base URL>", "api", "website", "simplicite");
    var client;

    // Simplified client login
    function login() {
        client = null;
        var lc = $(".logged-client").empty();
        var code = $(".input-login").val();
        if (code) {
            // Search the client
            var cli = app.getBusinessObject("DemoClient");
            cli.search(function(list) {
                if (list && list.length) {
                    client =  list[0];
                    // Displays the name in header
                    lc.text(client.demoCliFirstname + " " + client.demoCliLastname);
                    // Load client orders
                    myOrders();
                }
                else $ui.alert("Invalid client code.")
            }, {
                demoCliCode: code
            });
        }
        else $ui.alert("Please enter your client code.")
    }

    // Order button callback
    function order(a, prd, id) {
        if (!client) {
            $ui.alert("Please enter your client code first.");
            return;
        }
        $("#content").objectForm("DemoOrder", app.DEFAULT_ROW_ID, {
            // populate the parent product
            parent: {
                name: prd.getName(),
                inst: prd.getInstance(),
                field: "demoOrdPrdId",
                rowId: prd.getRowId(),
                object: prd
            },
            // populate the client
            onload: function(ctn,ord) {
                $ui.populateReference(ctn, ord, "demoOrdCliId", client.row_id);
            }
        });
    }

    // Catalog of products
    function catalog() {
        tglMenu("catalog");
        $("#content").objectList("DemoProduct", {
            nav: "new",        // start a new navigation bar
            minified: true,    // show summaries by default
            minifiable: true,  // allow to toggle to grid/table
            selectRows: false, // no rows selection
            // only one action on item = order
            actions: {
                 list: null,
                 listPlus: null,
                 row: [{
                     name: "order",
                     label: "Order now !",
                     callback: order,
                     icon: "truck",
                     showLabel: true
                 }],
                 rowPlus: null
            }
        });
    }

    // List orders of identified client
    function myOrders() {
        tglMenu("orders");
        $("#content").objectList("DemoOrder", {
            nav: "new",
            fixedFilters: { // fixed filter = this filter is not updatable by user
                demoOrdCliId: client ? client.row_id: "is null"
            },
            // Simplified list
            oncreate: null,   // no creation
            actions: null,    // no generic actions (prefs, reload, export, ...)
            listEdit: false,  // no edit list
            addList: false,   // no create on list
            bulkDelete: false, // no bulk deletion
            bulkUpdate: false, // no bulk update
            selectRows: false, // no row selectors
            onload: function(ctn, obj) {
                // Limit searchable fields
                obj.locals.ui.search.fields = ["demoOrdNumber", "demoOrdDate", "demoPrdReference"];
            }
        });
    }

    function tglMenu(name) {
        $(".menu").removeClass("active");
        $(".menu.menu-"+name).addClass("active");
    }

    function init() {
        // Additive styles
        $('head').append('<link rel="stylesheet" href="css/styles.min.css" type="text/css"/>');

        // Login
        $(".btn-login").click(login);
        $(".input-login").keyup(function(e) {
            if (e.which==13) login();
        });
        // Menu
        $(".menu-orders > a").click(myOrders);
        $(".menu-catalog > a").click(catalog).click();

        // Show UI
        $(".main").fadeIn();
    }

    // Load UI engine with bootstrap rendering
    $ui.ready(app, "bootstrap", {
        useMainParts: false // don't load standard parts
    }, init);
})(jQuery);

Screenshots

Catalog

Demo catalog of products

List

Demo orders list

Form

Demo order form

JSdoc for responsive UI

Examples of third party client-side APIs integration

Google Maps autocomplete API

For a MyObject with a myAddressField text field and a myCoordinatesField geographical coordinates, it is possible to add the client autocomplete API feature on a main form by implementing it like this:

MyObject = (function(ui, $) {
    if (!ui) return; // Do nothing on legacy UI
    Simplicite.UI.hooks.MyObject = function(o, cbk) {
        try {
            o.locals.ui.form.onload = function(ctn, obj) {
                try {
                    ui.loadScript({
                        url: "https://maps.googleapis.com/maps/api/js?key=" + Simplicite.GOOGLE_API_KEY + "&libraries=places",
                        onload: function() {
                            var addr = ui.getUIField(ctn, obj, "myAddressField").ui.input[0];
                            var ac = new google.maps.places.Autocomplete(addr);
                            ac.addListener("place_changed", function() {
                                var l = ac.getPlace().geometry.location;
                                ui.getUIField(ctn, obj, "myCoordinatesField").ui.val(l.lat() + "," + l.lng());
                            });
                        }
                    });
                } catch (el) {
                    console.error(el.message);
                }
            };
        } catch (e) {
            console.error(e.message);
        } finally {
            cbk && cbk();
        }
    };
})(window.$ui, jQuery);

If you want this to work also on the list (edit list / create on list) you need to implement it like this:

MyObject = (function(ui) {
    if (!ui) return; // Do nothing on legacy UI
    var app = ui.getAjax();
    Simplicite.UI.hooks.MyObject = function(o, cbk) {
        try {
            function addAddressHook(ctn, obj, list) {
                function autocomplete(id) {
                    var addr = ui.getUIField(ctn, obj, "myAddressField", id).ui.input[0];
                    if (addr) {
                        var ac = new google.maps.places.Autocomplete(addr);
                        ac.addListener("place_changed", function() {
                            var l = ac.getPlace().geometry.location;
                            ui.getUIField(ctn, obj, "myCoordinatesField", id).ui.val(l.lat() + "," + l.lng());
                        });
                    }
                }
                try {
                    ui.loadScript({
                        url: "https://maps.googleapis.com/maps/api/js?key=" + Simplicite.GOOGLE_API_KEY + "&libraries=places",
                        onload: function() {
                            if (list) {
                                autocomplete(app.DEFAULT_ROW_ID); // Create on list
                                for (var i = 0; i < obj.list.length; i++)
                                    autocomplete(obj.list[i].data.row_id); // Edit list
                            } else
                                autocomplete(); // Create/update form
                        }
                    });
                } catch (el) {
                    console.error(el.message);
                }
            }
            o.locals.ui.list.onload = function(ctn, obj) { addAddressHook(ctn, obj, true); };
            o.locals.ui.form.onload = function(ctn, obj) { addAddressHook(ctn, obj); };
        } catch (e) {
            console.error(e.message);
        } finally {
            cbk && cbk();
        }
    };
})(window.$ui);

Example of responsive external object

Demo external object

External object configuration

Server side code

This code is executed on server-side to send the required resources to UI

package com.simplicite.extobjects.Application;

import java.util.*;
import com.simplicite.util.*;
import com.simplicite.util.tools.*;

/**
 * External object MyExternalPage
 */
public class MyExternalPage extends ExternalObject {
    private static final long serialVersionUID = 1L;

    /**
     * Display method
     * @param params Request parameters
     */
    @Override
    public Object display(Parameters params)
    {
        // No header or legacy stuff
        setDecoration(false);
        // Send page for responsive UI ?
        if (getGrant().isResponsive())
        {
            // Add the STYLES resource attached to MyExternalPage definition
            appendCSSInclude(HTMLTool.getResourceCSSURL(this, "STYLES"));
            // Add the SCRIPT resource attached to MyExternalPage definition
            appendJSInclude(HTMLTool.getResourceJSURL(this, "SCRIPT"));
            // Run the client-side JavaScript statement that will be run by the UI
            return sendJavaScript("MyExternalPage.render(ctn);");
        }
        else return "Unsupported in legacy version";
    }
}

Note: This code is written in Java but it can be easily transposed to Rhino script.

Resource SCRIPT

The external object's SCRIPT resource contains the client-side JavaScript (it is loaded by the UI before running the statement returned by the server-side display method above). It displays a title and the lists of users and groups in 2 bootstrap columns.

MyExternalPage = (function() {
    // render the gadget in container ctn
    function render(ctn) {
        var app = $ui.getAjax(), // Ajax services
            view = $ui.view,     // view controller
            tools = view.tools,  // bootstrap tools
            users = $('<div/>'), // container of users
            groups = $('<div/>'),// container of groups
            row = tools.row([
                tools.col("md-6", users),
                tools.col("md-6", groups)
            ]),
            title = $('<h1 class="myexternalpage"/>')
                .append(view.icon('far/user'))
                .append($('<span/>').text(app.T("MY_TITLE")));

        // Load the lists
        $ui.displayList(users, "User");
        $ui.displayList(groups, "Group");

        // Replace container content
        view.getContent(ctn)
            .html(title)
            .append(row);
    }

    return { render: render };
})();

Note: Make sure - as in the exemple above - to isolate your JavaScript code in a dedicated namespace (e.g. the same name as the external object name), as a matter of fact the responsive UI is one-page so any JavaScript name must be unique application-wide

Resource STYLES

Put your specific styles in the external object's STYLES resource, for example:

.myexternalpage {
    padding: 15px;
    background: #337ab7;
    color: #fff;
}
.myexternalpage span {
    margin-left: 10px;
}

Note: Make sure - as in the exemple above - to give a unique name to your CSS classes for instance with a dedicated name prefix (e.g. the lowercase version of the external object name), as a matter of fact the responsive UI is one-page so any CSS class name must be unique application-wide