2
votes

Question: What is the pattern for a login page for a standalone app in OpenUI5 including showing placeholder page content whilst UI5 bootstrap and component.js execute ?

I am trying to provide a pleasing UX for the login process for my standalone UI5 app. The sequence of events I intend would be:

  1. User navigates to the UI5 URL
  2. Pleasing background / placeholder is displayed while UI5 starts up
  3. Login dialogue is displayed, user enters details clicks login button etc...

My current issues:

  1. I put some text in the body of index.html and it appears immediately the user navigates to the page (good), but the text remains at the top of the page after the UI5 app starts up (bad).
  2. Leaving the index.html body blank, there is an 'empty page' lag whilst UI5 starts up (bad ux)....
  3. ...and I have a first UI5 view which acts as a placeholder and an associated login fragment but the fragment flashes up momentarily before being hidden by the content of the view (bad ux).

Overall I think there is probably a best-practice pattern for login based around understanding the sequence of lifecycle events at startup but I have so far failed to find a succinct explanation in UI5 docs.

Can anyone point me in a useful direction or provide advice please?

Edit: After more searching I found this post and linked JSBin which are helpful to a point. In essence the technique uses plain html and script to delay the load of the app until the UI5 scripts are loaded, showing a pleasing progress spinner whilst waiting. When the 'deviceready' event occurs the script starts the app load and removes the html elements of the progress indicator. The result is a flicker-free startup for UI5 with pleasing UX and no artifact HTML. This technique could probably be extended by placing the 'hide progress elements html' function in the to afterModelLoaded() process of your model load etc.

I also restrctured the events so that the open of the login dialogue is triggered in the onAfterRendering() function of the first page - I had previously has this in an onDisplay() funciton that was wired to fire when the route was matched. This was based on other experience where I wanted to kick off a further data fetch when opening a page but I think in that case the page was already rendered so the dialogue-flicker did not occur or happened off-screen. Triggering the dialogue in onAfterRendering() has stopped the dialogue flicker issue in this case.

2

2 Answers

4
votes

Regarding your current issues:

  1. Your whole UI5 app exists inside that index.html file. Whenever you use a UI5 application, you are in fact staying on that one and only HTML page the whole time. Therefore, whatever you put in the HTML file will appear everywhere inside the application. So don't put anything in it. Just use it to bootstrap the UI5 library.

  2. That 'empty page' lag can't be avoided (update: yes it can - see below). After the resources are downloaded from the server, UI5 apps take an extra few seconds to load. There is no way around this; UI5 is a huge library, and it will take the browser this extra time to get it ready. Because the library itself is getting prepared during that time, you cannot set a sort of splash screen (think this.getView().setBusy() for example). However, because essentially your whole website will exist in the browser after this delay, your site's navigation will be faster than navigation within apps written in most other frameworks, where the following page needs to be downloaded from a remove server every time you navigate away from a page.

  3. I don't know what you mean by a view which acts as a placeholder. I would probably remove it. Also, I wouldn't recommend having a fragment to handle login. Rather, create a dedicated view for it. This is the standard way of doing it.

To recap:

  1. User hits the URL -
  2. Library loads
  3. First view is the login view. User submits form; login view talks to server (and while it's doing this you can set the view to busy for a pleasant UX).
  4. If the user is authenticated, then navigate to your other views.

I hope this makes things clearer. Let me know if you have any questions.

Update: good find!! I hadn't thought of that. Yes, you can of course have your HTML page do something while it's waiting for the scripts to be downloaded, then when the ondeviceready method is called, hand everything over to the UI5 app.

2
votes

Recently, after digging in UI5 manuals, I've refactored the legacy codebase with the login fragment functionality using OpenUI5 1.86+ and the modern, strict JS (async/await, arrow functions, object shorthand, asynchronous UI5 loading, etc.) where it is possible in accordance with the UI5 code reuse best practices. Finally, I can share my completely tested, working, and ready-to-use turn-key outcomes.

index.html

A bootstrapping using the Content-Security-Policy (CSP):

<!DOCTYPE html>
<html lang = "en">
<head>
    <meta content = "default-src https: 'self' https://*.hana.ondemand.com 'unsafe-eval' 'unsafe-inline'; child-src 'none'; object-src 'none';"
          http-equiv = "Content-Security-Policy" />
    <meta charset = "utf-8" />
    <title>Certfys TMS</title>
    <!--suppress JSUnresolvedLibraryURL -->
    <script data-sap-ui-appCachebuster="./"
            data-sap-ui-async = "true"
            data-sap-ui-compatVersion = "edge"
            data-sap-ui-excludejquerycompat = "true"
            data-sap-ui-onInit = "module:sap/ui/core/ComponentSupport"
            data-sap-ui-resourceroots = '{"webapp": "./"}'
            data-sap-ui-theme = "sap_fiori_3"
            data-sap-ui-xx-componentpreload = "off"
            id = "sap-ui-bootstrap"
            src = "https://openui5nightly.hana.ondemand.com/resources/sap-ui-core.js">
    </script>
</head>
<body class = "sapUiBody"
      id = "content">
<div data-id = "rootComponentContainer"
     data-name = "webapp"
     data-sap-ui-component
     data-settings = '{"id" : "webapp"}'></div>
</body>
</html>

Component.js

// eslint-disable-next-line strict
"use strict";

// eslint-disable-next-line no-undef
sap.ui.define([
    "sap/ui/core/UIComponent",
    "sap/ui/Device",
    "./controller/AuthDialog"
// eslint-disable-next-line max-params
], (UIComponent, Device, AuthDialog) => UIComponent.extend("webapp.Component", {

    exit() {

        this._authDialog.destroy();

        delete this._authDialog;

    },

    getContentDensityClass() {

        if (!this._sContentDensityClass) {

            if (Device.support.touch) {

                this._sContentDensityClass = "sapUiSizeCozy";

            } else {

                this._sContentDensityClass = "sapUiSizeCompact";

            }

        }

        return this._sContentDensityClass;

    },

    init(...args) {

        UIComponent.prototype.init.apply(this, args);

        this.getRouter().initialize();

        this._authDialog = new AuthDialog(this.getRootControl(0));

    },

    metadata: {
        manifest: "json"
    },

    openAuthDialog() {

        this._authDialog.open();

    }

}));

App.controller.js

// eslint-disable-next-line strict
"use strict";

// eslint-disable-next-line no-undef
sap.ui.define([
    "sap/ui/core/mvc/Controller"
// eslint-disable-next-line max-params
], (Controller) => Controller.extend("webapp.controller.App", {

    onInit() {

        this.getView().addStyleClass(this.getOwnerComponent().getContentDensityClass());

    }

}));

Login.view.xml

<mvc:View
    xmlns:mvc = "sap.ui.core.mvc"
    controllerName = "webapp.controller.Login">
</mvc:View>

Login.controller.js

// eslint-disable-next-line strict
"use strict";

// eslint-disable-next-line no-undef
sap.ui.define([
    "sap/ui/core/mvc/Controller",
    "sap/ui/core/UIComponent",
    "webapp/controller/BaseController"
// eslint-disable-next-line max-params
], (Controller, UIComponent, BaseController) => Controller.extend("webapp.controller.login", {

    async onInit() {

        const session = {
            sessionID: sessionStorage.getItem("SessionId"),
            userID: sessionStorage.getItem("UserId")
        };

        const isAuthorized = await BaseController.isAuthorized(session);

        if (isAuthorized) {

            const oRouter = UIComponent.getRouterFor(this);
            oRouter.navTo("overview");

        } else {

            this.getOwnerComponent().openAuthDialog();

        }

    }

}));

AuthDialog.fragment.xml

<core:FragmentDefinition
    xmlns:core = "sap.ui.core"
    xmlns = "sap.m">
    <Dialog
        id = "authDialog"
        title = "{i18n>AUTH_DIALOG_DIALOG_TITLE}"
        type = "Message"
        escapeHandler = ".escapeHandler">
        <Label
            labelFor = "username"
            text = "{i18n>AUTH_DIALOG_LAB_USERNAME}" />
        <Input
            id = "username"
            liveChange = ".onLiveChange"
            placeholder = "{i18n>AUTH_DIALOG_PH_USERNAME}"
            type = "Text" />
        <Label
            labelFor = "password"
            text = "{i18n>AUTH_DIALOG_LAB_PASSWORD}" />
        <Input
            id = "password"
            liveChange = ".onLiveChange"
            placeholder = "{i18n>AUTH_DIALOG_PH_PASSWORD}"
            type = "Password" />
        <beginButton>
            <Button
                enabled = "false"
                id = "btnLogin"
                press = ".onPressLogin"
                text = "{i18n>AUTH_DIALOG_BTN_SUBMIT}"
                type = "Emphasized" />
        </beginButton>
    </Dialog>
</core:FragmentDefinition>

AuthDialog.js

// eslint-disable-next-line strict
"use strict";

const _resetForm = function _resetForm(oView) {

    oView.byId("username").setValue("");

    oView.byId("password").setValue("");

    oView.byId("btnLogin").setEnabled(false);

};

const _loginUser = async function _loginUser(oView, httpProtocol) {

    const userCredentials = {
        password: oView.byId("password").getValue(),
        username: oView.byId("username").getValue()
    };

    let authData;

    try {

        const authResponse = await fetch(`${httpProtocol}://${location.host}/login`, {
            body: JSON.stringify(userCredentials),
            cache: "no-cache",
            credentials: "same-origin",
            headers: {
                "Content-Type": "application/json"
            },
            method: "POST",
            mode: "same-origin",
            redirect: "follow",
            referrerPolicy: "no-referrer"
        });

        authData = await authResponse.json();

    } catch (err) {

        authData = {
            message: err.message,
            result: false
        };

    }

    return authData;

};

const _authUser = async function _authUser(UIComponent, MessageToast, oView, httpProtocol) {

    const authData = await _loginUser(oView, httpProtocol);

    sessionStorage.setItem("SessionId", authData.sessionID);
    sessionStorage.setItem("UserId", authData.userID);

    if (authData.result) {

        const oRouter = UIComponent.getRouterFor(oView);
        oRouter.navTo("overview");

    } else {

        const oBundle = oView.getModel("i18n").getResourceBundle();

        const sMsg = oBundle.getText(authData.message);

        MessageToast.show(sMsg);

        _resetForm(oView);

    }

};

// eslint-disable-next-line no-undef
sap.ui.define([
    "sap/ui/base/ManagedObject",
    "sap/ui/core/Fragment",
    "sap/ui/core/UIComponent",
    "sap/m/MessageToast",
    "webapp/controller/BaseController"
// eslint-disable-next-line max-params
], (ManagedObject, Fragment, UIComponent, MessageToast, BaseController) => ManagedObject.extend("webapp.controller.AuthDialog", {

    constructor: function constructor(oView) {

        this._oView = oView;

    },

    exit() {

        delete this._oView;

    },

    async open() {

        const oView = this._oView;

        if (!this._oDialog) {

            // noinspection JSUnusedGlobalSymbols
            const fragmentController = {

                escapeHandler(pEscapeHandler) {

                    pEscapeHandler.reject();

                },

                onLiveChange() {

                    const minRequiredLen = 3;

                    const username = oView.byId("username").getValue();

                    const password = oView.byId("password").getValue();

                    oView.byId("btnLogin").setEnabled((minRequiredLen <= username.length) && (minRequiredLen <= password.length));

                },

                async onPressLogin() {

                    const httpProtocol = BaseController.getHTTPProtocol();

                    await _authUser(UIComponent, MessageToast, oView, httpProtocol);

                }

            };

            this._oDialog = await Fragment.load({
                controller: fragmentController,
                id: oView.getId(),
                name: "webapp.view.AuthDialog"
            });

            this._oDialog.onsapenter = (async () => {

                if (oView.byId("btnLogin").getEnabled()) {

                    const httpProtocol = BaseController.getHTTPProtocol();

                    await _authUser(UIComponent, MessageToast, oView, httpProtocol);

                }

            });

            oView.addDependent(this._oDialog);

        }

        _resetForm(oView);

        this._oDialog.open();

    }

}));

BaseController.js

// eslint-disable-next-line strict
"use strict";

// eslint-disable-next-line no-undef
sap.ui.define([], () => ({

    getHTTPProtocol() {

        return ("localhost" === location.hostname)
            ? "http"
            : "https";

    },

    async isAuthorized(session) {

        const httpProtocol = this.getHTTPProtocol();

        let authData;

        try {

            const authResponse = await fetch(`${httpProtocol}://${location.host}/checkSession`, {
                body: JSON.stringify(session),
                cache: "no-cache",
                credentials: "same-origin",
                headers: {
                    "Content-Type": "application/json"
                },
                method: "POST",
                mode: "same-origin",
                redirect: "follow",
                referrerPolicy: "no-referrer"
            });

            authData = await authResponse.json();

        } catch (err) {

            authData = {
                message: err.message,
                result: false
            };

        }

        return authData.result;

    }

}));

manifest.json

The relevant manifest fragment:

"routes": [
    {
        "name": "login",
        "pattern": "",
        "target": "login"
    }
],
"targets": {
    "login": {
        "viewId": "login",
        "viewName": "Login"
    }
}

The final result:

UI5 login form using reusable Dialog fragment

Hope, it will help someone to grasp the way the modern UI5 is working with the fragments.