Embedded SPA3 (Custom UI) With RESTful Hypermedia

An embedded single-page application may have a completely custom UI while still taking advantage of the host security and hypermedia.

This is the third embedded Single Page Application in the RESTful Workshop series. The code is virtually identical to the first app that was created in the Embedded SPA1 with RESTful Hypermedia tutorial. This app has a custom user interface but remains embedded in the host RESTful application.

Single Page Application (SPA) with custom user interface embedded in the application with the RESTful API Engine created with Code On Time.
Single Page Application (SPA) with custom user interface embedded in the application with the RESTful API Engine created with Code On Time.

Developers may exercise full control over the layout and implementation of specific pages in applications created with Code On Time.

See the live page at https://demo.codeontime.com/pages/spa3.

Bring Your Own UI

Enter the following as the content of the ~/app/pages/spa3.html page. Use your favorite text editor to make changes.

HTML
123456789101112131415161718192021222324252627282930313233<!DOCTYPE html>
<html lang="en">
<head>
    <title>Embedded SPA3 (Custom UI) with RESTful Hypermedia</title>
    <style>
        #authenticated {
            max-width: 640px;
            min-width: 480px;
            margin: 0 auto 0 auto;
            line-height: 34px;
        }

        .toolbar {
            height: auto;
            display: flex;
            border: solid 1px #ddd;
            padding: .25em;
            margin: .5em 0;
        }

        button[data-hypermedia] {
            margin-right: 1em !important;
        }
    </style>
</head>
<body data-ui-framework="none">
    <h1 style="text-align:center">Embedded SPA3 (Custom UI) with RESTful Hypermedia</h1>
    <div id="authenticated">
        <p>This is the list of products:</p>
        <div id="product-list"></div>
    </div>
</body>
</html>

The attribute data-ui-framework=”none” applied to the body element instructs the host application to strip the presentation framework scripts from the page markup served to browsers. The core API scripts are still injected into the page, which makes it possible to take advantage of the security system of the host application. The code in the page can also take advantage of the $app.restful method and use other framework utilities.

Enter the following script block directly before the closing body tag in the page markup.

HTML
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980<script>
    (function () {
        var apiHypermedia,
            products;

        $(document)
            .ready(e => {
                $app.restful()
                    .then(result => {
                        apiHypermedia = result;
                        start();
                    })
                    .catch(restfulException);
            }).on('click', '[data-hypermedia]', handleHypermedia);

        function start() {
            // show the data if the API allows it
            if (apiHypermedia && apiHypermedia.products)
                refreshProductList(apiHypermedia.products._links.first);
            else
                document.querySelector('#product-list').textContent = 'Unauthorized to see the products';
        }

        /* product list rendering and paging */

        function refreshProductList(hypermedia) {
            $app.restful({ "url": hypermedia })
                .then(result => {
                    products = result;
                    renderProductData();
                })
                .catch(restfulException);
        }

        function renderProductData() {
            var sb = [
                '<table border="1" cellpadding="5" cellspacing="0">',
                '<tr><th>Product</th><th>Category</th><th>Supplier</th><th>Unit Price</th><th>Units In Stock</th></tr>'];
            for (var i = 0; i < products.collection.length; i++) {
                var p = products.collection[i];
                sb.push('<tr>');
                sb.push('<td>', $app.htmlEncode(p.productName), '</td>');
                sb.push('<td>', $app.htmlEncode(p.categoryName), '</td>');
                sb.push('<td>', $app.htmlEncode(p.supplierCompanyName), '</td>');
                sb.push('<td>', $app.htmlEncode(p.unitPrice), '</td>');
                sb.push('<td>', $app.htmlEncode(p.unitsInStock), '</td>');
                sb.push('</tr>')
            }
            sb.push('</table>');
            sb.push('<div class="toolbar">')
            var hypermedia = products._links;
            sb.push('<button data-hypermedia="self"', hypermedia.self ? '' : ' disabled="disabled"', '>Refresh</button>');
            sb.push('<button data-hypermedia="first"', hypermedia.first ? '' : ' disabled="disabled"', '>First</button>');
            sb.push('<button data-hypermedia="prev"', hypermedia.prev ? '' : ' disabled="disabled"', '>Prev</button>');
            sb.push('<button data-hypermedia="next"', hypermedia.next ? '' : ' disabled="disabled"', '>Next</button>');
            sb.push('<button data-hypermedia="last"', hypermedia.last ? '' : ' disabled="disabled"', '>Last</button>');
            var hypermediaArgs = $app.urlArgs(products._links.self);
            sb.push('Page: ', parseInt(hypermediaArgs.page) + 1, ' of ', Math.ceil(products.count / hypermediaArgs.limit),
                ' (', + products.count + ' items)');
            sb.push('</div>');
            document.querySelector('#product-list').innerHTML = sb.join('');
            $app.touch.scrollable('refresh'); // let the Touch UI know that the contents of the page have changed
        }

        function handleHypermedia(e) {
            var btn = e.target.closest('[data-hypermedia]')
            if (btn && products) {
                var hypermedia = btn.getAttribute('data-hypermedia');
                refreshProductList(products._links[hypermedia]);
            }
        }

        /* miscellaneous utilities */

        function restfulException(ex) {
            if (ex && ex.errors)
                $app.alert(ex.errors[0].reason + ': ' + ex.errors[0].message)
        }
    })();
</script>

The difference between this implementation and SPA1 is in the event handling only. Touch UI is not available - there are no pagereadycomplete.app or vclick events. SPA3 begins execution in the ready handler triggered on the document and responds to the click event triggered on the buttons in the pager toolbar.

JavaScript
123456789$(document)
    .ready(e => {
        $app.restful()
            .then(result => {
                apiHypermedia = result;
                start();
            })
            .catch(restfulException);
    }).on('click', '[data-hypermedia]', handleHypermedia);

If Touch UI is enabled, then the SPA must wait for the pagereadycomplete.app event to begin execution. This will ensure that the app is gracefully blending in. Here is how the app starts in the SPA1 tutorial.

JavaScript
123456789$(document)
    .one('pagereadycomplete.app', e => {
        $app.restful()
            .then(result => {
                apiHypermedia = result;
                start();
            })
            .catch(restfulException);
    }).on('vclick', '[data-hypermedia]', handleHypermedia);

Everything else is exactly the same.

Final Source Code

This single page app is composed of a single physical file. The complete source of the spa3.html file is shown next.

HTML
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113<!DOCTYPE html>
<html lang="en">
<head>
    <title>Embedded SPA3 (Custom UI) with RESTful Hypermedia</title>
    <style>
        #authenticated {
            max-width: 640px;
            min-width: 480px;
            margin: 0 auto 0 auto;
            line-height: 34px;
        }

        .toolbar {
            height: auto;
            display: flex;
            border: solid 1px #ddd;
            padding: .25em;
            margin: .5em 0;
        }

        button[data-hypermedia] {
            margin-right: 1em !important;
        }
    </style>
</head>
<body data-ui-framework="none">
    <h1 style="text-align:center">Embedded SPA3 (Custom UI) with RESTful Hypermedia</h1>
    <div id="authenticated">
        <p>This is the list of products:</p>
        <div id="product-list"></div>
    </div>
    <script>
        (function () {
            var apiHypermedia,
                products;

            $(document)
                .ready(e => {
                    $app.restful()
                        .then(result => {
                            apiHypermedia = result;
                            start();
                        })
                        .catch(restfulException);
                }).on('click', '[data-hypermedia]', handleHypermedia);

            function start() {
                // show the data if the API allows it
                if (apiHypermedia && apiHypermedia.products)
                    refreshProductList(apiHypermedia.products._links.first);
                else
                    document.querySelector('#product-list').textContent = 'Unauthorized to see the products';
            }

            /* product list rendering and paging */

            function refreshProductList(hypermedia) {
                $app.restful({ "url": hypermedia })
                    .then(result => {
                        products = result;
                        renderProductData();
                    })
                    .catch(restfulException);
            }

            function renderProductData() {
                var sb = [
                    '<table border="1" cellpadding="5" cellspacing="0">',
                    '<tr><th>Product</th><th>Category</th><th>Supplier</th><th>Unit Price</th><th>Units In Stock</th></tr>'];
                for (var i = 0; i < products.collection.length; i++) {
                    var p = products.collection[i];
                    sb.push('<tr>');
                    sb.push('<td>', $app.htmlEncode(p.productName), '</td>');
                    sb.push('<td>', $app.htmlEncode(p.categoryName), '</td>');
                    sb.push('<td>', $app.htmlEncode(p.supplierCompanyName), '</td>');
                    sb.push('<td>', $app.htmlEncode(p.unitPrice), '</td>');
                    sb.push('<td>', $app.htmlEncode(p.unitsInStock), '</td>');
                    sb.push('</tr>')
                }
                sb.push('</table>');
                sb.push('<div class="toolbar">')
                var hypermedia = products._links;
                sb.push('<button data-hypermedia="self"', hypermedia.self ? '' : ' disabled="disabled"', '>Refresh</button>');
                sb.push('<button data-hypermedia="first"', hypermedia.first ? '' : ' disabled="disabled"', '>First</button>');
                sb.push('<button data-hypermedia="prev"', hypermedia.prev ? '' : ' disabled="disabled"', '>Prev</button>');
                sb.push('<button data-hypermedia="next"', hypermedia.next ? '' : ' disabled="disabled"', '>Next</button>');
                sb.push('<button data-hypermedia="last"', hypermedia.last ? '' : ' disabled="disabled"', '>Last</button>');
                var hypermediaArgs = $app.urlArgs(products._links.self);
                sb.push('Page: ', parseInt(hypermediaArgs.page) + 1, ' of ', Math.ceil(products.count / hypermediaArgs.limit),
                    ' (', + products.count + ' items)');
                sb.push('</div>');
                document.querySelector('#product-list').innerHTML = sb.join('');
                $app.touch.scrollable('refresh'); // let the Touch UI know that the contents of the page have changed
            }

            function handleHypermedia(e) {
                var btn = e.target.closest('[data-hypermedia]')
                if (btn && products) {
                    var hypermedia = btn.getAttribute('data-hypermedia');
                    refreshProductList(products._links[hypermedia]);
                }
            }

            /* miscellaneous utilities */

            function restfulException(ex) {
                if (ex && ex.errors)
                    $app.alert(ex.errors[0].reason + ': ' + ex.errors[0].message)
            }
        })();
    </script>
</body>
</html>

Next

Continue to the Standalone SPA4 with RESTful Hypermedia and OAuth 2.0 segment. You will learn how to create a single page app with a custom user interface that can be hosted on any domain. The app will use OAuth 2.0 Authorization Code Flow with PKCE to authenticate users.


This tutorial is part of the RESTful Workshop designed to empower individual developers and teams to create amazing enterprise-quality backends for mobile and web applications. Zero experience and no code is required to provide your data with the REST API conforming to the Level 3 of Richardson Maturity Model with the built-in OAuth 2.0 and Open ID Connect.