Blog: Posts from January, 2022

Labels
AJAX(112) Apple(1) Application Builder(242) Application Factory(207) ASP.NET(95) ASP.NET 3.5(45) ASP.NET Code Generator(72) ASP.NET Membership(28) Azure(18) Barcodes(3) BLOB(18) Business Rules(1) Business Rules/Logic(140) BYOD(13) Caching(2) Calendar(5) Charts(29) Cloud(14) Cloud On Time(2) Cloud On Time for Windows 7(2) Code Generator(54) Collaboration(11) command line(1) Conflict Detection(1) Content Management System(11) COT Tools for Excel(26) CRUD(1) Custom Actions(1) Data Aquarium Framework(122) Data Sheet(9) Data Sources(22) Database Lookups(50) Deployment(22) Designer(177) DotNetNuke(12) EASE(20) Email(6) Features(99) Firebird(1) Form Builder(14) Globalization and Localization(6) Hypermedia(2) Installation(4) JavaScript(20) Kiosk(1) Low Code(3) Mac(1) Many-To-Many(4) Maps(6) Master/Detail(36) Microservices(4) Mobile(63) Mode Builder(3) Model Builder(3) MySQL(10) Native Apps(5) News(15) OAuth(5) OAuth Scopes(1) OAuth2(6) Offline(14) Oracle(10) PKCE(1) PostgreSQL(2) QR codes(2) Rapid Application Development(5) Reading Pane(2) Release Notes(163) Reports(48) REST(26) RESTful(21) RESTful Workshop(13) RFID tags(1) SaaS(7) Security(75) SharePoint(12) SPA(5) SQL Anywhere(3) SQL Server(26) Stored Procedure(4) Teamwork(15) Tips and Tricks(81) Tools for Excel(2) Touch UI(93) Transactions(5) Tutorials(183) Universal Windows Platform(3) User Interface(331) Video Tutorial(37) Web 2.0(100) Web App Generator(101) Web Application Generator(607) Web Form Builder(39) Web.Config(9) Workflow(28)
Archive
Blog
Posts from January, 2022
Tuesday, January 25, 2022PrintSubscribe
Embedded SPA3 (Custom UI) with RESTful 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 backend 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.

Thursday, January 20, 2022PrintSubscribe
Embedded SPA2 with REST Level 2

This tutorial explains how to build an embedded Single Page Application that makes the direct use of the REST resources of the host application. The new app will look just like its cousin, the Embedded SPA1 with RESTful Hypermedia. It blends in the host user interface, but does not make use of the hypermedia when working with the data.

Embedded SPA2 with REST Level 2
Embedded SPA2 with REST Level 2

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

The HTML markup of the page ~/app/pages/spa2.html will be exactly the same, so you can borrow it from SPA1. The script in the body of the page is different. Here it is.

HTML
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102<script>
    (function () {
        var products,
            pageIndex = 0,
            pageSize = 10;

        $(document)
            .one('pagereadycomplete.app', e => {
                start();
            }).on('vclick', '[data-action]', handleActions);


        function start() {
            // show the first page of products by default
            refreshProductList(0);
        }

        /* product list rendering and paging */

        function refreshProductList() {
            $app.restful({
                "method": 'GET',       // 1) HTTP method of the request. GET is optional.
                "url": '/v2/products', // 2) example: /v2/products?limit=10&page=0
                "query": {             // 3) Properties of the object are added to the URL
                    'limit': pageSize,
                    'page': pageIndex
                },
                headers: null,       // 4) Object represents the custom headers
                body: null,          // 5) Object represents the payload of the request
            })
                .then(result => {
                    products = result;
                    renderProductData();
                })
                .catch(ex => {
                    document.querySelector('#product-list').textContent = 'Unauthorized to see the products';
                    restfulException(ex);
                });
        }

        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 pageCount = Math.ceil(products.count / pageSize);
            sb.push('<button data-action="self">Refresh</button>');
            sb.push('<button data-action="first"', pageIndex > 0 ? '' : ' disabled="disabled"', '>First</button>');
            sb.push('<button data-action="prev"', pageIndex >= 1 ? '' : ' disabled="disabled"', '>Prev</button>');
            sb.push('<button data-action="next"', pageIndex <= pageCount - 2 ? '' : ' disabled="disabled"', '>Next</button>');
            sb.push('<button data-action="last"', pageIndex < pageCount - 1 ? '' : ' disabled="disabled"', '>Last</button>');
            sb.push('Page: ', pageIndex + 1, ' of ', pageCount,
                ' (', + 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 handleActions(e) {
            var btn = e.target.closest('[data-action]')
            if (btn && products) {
                var action = btn.getAttribute('data-action');
                switch (action) {
                    case 'self':
                        // the page index is not changed
                        break;
                    case 'first':
                        pageIndex = 0;
                        break;
                    case 'prev':
                        pageIndex--;
                        break;
                    case 'next':
                        pageIndex++;
                        break;
                    case 'last':
                        pageIndex = Math.ceil(products.count / pageSize) - 1;
                        break;
                }
                refreshProductList();
            }
        }

        /* miscellaneous utilities */

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

Code Analysis and Comparison

The app does not require the hypermedia and therefore the start of the app commences immediately in the pagereadycomplete.app event handler triggered by Touch UI framework.

JavaScript
1234$(document)
    .one('pagereadycomplete.app', e => {
        start();
    }).on('vclick', '[data-action]', handleActions);

This is the code from the SPA1 tutorial. The “hypermedia” app retrieves the API definition and calls the start function.

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

Function start has no means to ascertain if the user has access to the REST resources of the products data controller. It simply proceeds to call the refreshProductList function.

JavaScript
1234function start() {
    // show the first page of products by default
    refreshProductList();
}

The hypermedia-aware client SPA1 can alter its UI to match the API hypermedia controls infused in the API definition by the RESTful API Engine.

JavaScript
1234567function 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';
}

Function refreshProductList uses the $app.restful method to fetch the page of the product list identified by the global variable pageIndex. The url of the request is explicitly defined along with the HTTP method. The query parameters limit and page are also specified. The global variable products stores the product data fetched from the backend.

JavaScript
1234567891011121314151617181920function refreshProductList() {
    $app.restful({
        "method": 'GET',       // 1) HTTP method of the request. GET is optional.
        "url": '/v2/products', // 2) example: /v2/products?limit=10&page=0
        "query": {             // 3) Properties of the object are added to the URL
            'limit': pageSize,
            'page': pageIndex
        },
        headers: null,       // 4) Object represents the custom headers
        body: null,          // 5) Object represents the payload of the request
    })
        .then(result => {
            products = result;
            renderProductData();
        })
        .catch(ex => {
            document.querySelector('#product-list').textContent = 'Unauthorized to see the products';
            restfulException(ex);
        });
}

The “hypermedia” version of the refreshProductList method is simpler in SPA1. It passes the hypermedia control as the url parameter.

JavaScript
12345678function refreshProductList(hypermedia) {
    $app.restful({ "url": hypermedia })
        .then(result => {
            products = result;
            renderProductData();
        })
        .catch(restfulException);
}
Method $app.restful provides a thin wrapper on top of the Fetch API. It handles both hypermedia and explicit REST resource URIs. It uses the access token of the host application and seamlessly refreshes the token when needed.

The event handler handleActions must explicitly assign the pageIndex variables and perform calculations when the buttons in the pager toolbar are clicked.

JavaScript
123456789101112131415161718192021222324function handleActions(e) {
    var btn = e.target.closest('[data-action]')
    if (btn && products) {
        var action = btn.getAttribute('data-action');
        switch (action) {
            case 'self':
                // the page index is not changed
                break;
            case 'first':
                pageIndex = 0;
                break;
            case 'prev':
                pageIndex--;
                break;
            case 'next':
                pageIndex++;
                break;
            case 'last':
                pageIndex = Math.ceil(products.count / pageSize) - 1;
                break;
        }
        refreshProductList();
    }
}

The “hypermedia” version of this method is called handleHypermedia in SPA1. It appears to be simpler and lacks any calculations.

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

The physical rendering of the product list in renderProductData function is similar to its counterpart in SPA1. The paging toolbar construction takes more work. The code performs a few arithmetic and logical operations on the pageIndex and pageCount to determine if a particular button in the pager is enabled.

JavaScript
12345678910111213141516171819202122232425262728function 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 pageCount = Math.ceil(products.count / pageSize);
    sb.push('<button data-action="self">Refresh</button>');
    sb.push('<button data-action="first"', pageIndex > 0 ? '' : ' disabled="disabled"', '>First</button>');
    sb.push('<button data-action="prev"', pageIndex >= 1 ? '' : ' disabled="disabled"', '>Prev</button>');
    sb.push('<button data-action="next"', pageIndex <= pageCount - 2 ? '' : ' disabled="disabled"', '>Next</button>');
    sb.push('<button data-action="last"', pageIndex < pageCount - 1 ? '' : ' disabled="disabled"', '>Last</button>');
    sb.push('Page: ', pageIndex + 1, ' of ', pageCount,
        ' (', + 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
}

The configuration of the buttons in the pager toolbar is more straightforward in SPA1. A button is disabled if there is no corresponding hypermedia control in the products data as shown in the next fragment.

JavaScript
1234567891011sb.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>');

This is the sample data that will be typically stored in the products global variable. The hypermedia _links are ignored by REST-only apps and actively used by the RESTful client code.

JSON
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113{
    "_links": {
        "self": {
            "href": "/v2/products?page=0&limit=3"
        },
        "next": {
            "href": "/v2/products?page=1&limit=3"
        },
        "last": {
            "href": "/v2/products?page=25&limit=3"
        },
        "lookup-supplierId": {
            "href": "/v2/suppliers?count=true&fields=supplierId,companyName",
            "embeddable": true
        },
        "lookup-categoryId": {
            "href": "/v2/categories?count=true&fields=categoryId,categoryName",
            "embeddable": true
        },
        "create": {
            "href": "/v2/products",
            "method": "POST"
        },
        "schema": {
            "href": "/v2/products?_schema=true&page=0&limit=3"
        }
    },
    "count": 77,
    "page": 0,
    "collection": [
        {
            "_links": {
                "self": {
                    "href": "/v2/products/17"
                },
                "supplierId": {
                    "href": "/v2/products/17/supplier-id",
                    "embeddable": true
                },
                "categoryId": {
                    "href": "/v2/products/17/category-id",
                    "embeddable": true
                }
            },
            "productId": 17,
            "productName": "Alice Mutton",
            "supplierId": 7,
            "supplierCompanyName": "Pavlova, Ltd.",
            "categoryId": 6,
            "categoryName": "Meat/Poultry",
            "quantityPerUnit": "20 - 1 kg tins",
            "unitPrice": 13.3000,
            "unitsInStock": 0,
            "unitsOnOrder": 0,
            "reorderLevel": 0,
            "discontinued": false
        },
        {
            "_links": {
                "self": {
                    "href": "/v2/products/3"
                },
                "supplierId": {
                    "href": "/v2/products/3/supplier-id",
                    "embeddable": true
                },
                "categoryId": {
                    "href": "/v2/products/3/category-id",
                    "embeddable": true
                }
            },
            "productId": 3,
            "productName": "Aniseed Syrup",
            "supplierId": 1,
            "supplierCompanyName": "Exotic Liquids",
            "categoryId": 2,
            "categoryName": "Condiments",
            "quantityPerUnit": "12 - 550 ml bottles",
            "unitPrice": 10.0000,
            "unitsInStock": 13,
            "unitsOnOrder": 70,
            "reorderLevel": 25,
            "discontinued": false
        },
        {
            "_links": {
                "self": {
                    "href": "/v2/products/40"
                },
                "supplierId": {
                    "href": "/v2/products/40/supplier-id",
                    "embeddable": true
                },
                "categoryId": {
                    "href": "/v2/products/40/category-id",
                    "embeddable": true
                }
            },
            "productId": 40,
            "productName": "Boston Crab Meat",
            "supplierId": 19,
            "supplierCompanyName": "New England Seafood Cannery",
            "categoryId": 8,
            "categoryName": "Seafood",
            "quantityPerUnit": "24 - 4 oz tins",
            "unitPrice": 18.4000,
            "unitsInStock": 123,
            "unitsOnOrder": 0,
            "reorderLevel": 30,
            "discontinued": false
        }
    ]
}

The hypermedia can be disabled if the server.rest.hypermedia.enabled option is set to false in ~/app/touch-settings.json configuration file. Then the products data will look like this:

JSON
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748{
    "count": 77,
    "page": 0,
    "collection": [
        {
            "productId": 17,
            "productName": "Alice Mutton",
            "supplierId": 7,
            "supplierCompanyName": "Pavlova, Ltd.",
            "categoryId": 6,
            "categoryName": "Meat/Poultry",
            "quantityPerUnit": "20 - 1 kg tins",
            "unitPrice": 13.3000,
            "unitsInStock": 0,
            "unitsOnOrder": 0,
            "reorderLevel": 0,
            "discontinued": false
        },
        {
            "productId": 3,
            "productName": "Aniseed Syrup",
            "supplierId": 1,
            "supplierCompanyName": "Exotic Liquids",
            "categoryId": 2,
            "categoryName": "Condiments",
            "quantityPerUnit": "12 - 550 ml bottles",
            "unitPrice": 10.0000,
            "unitsInStock": 13,
            "unitsOnOrder": 70,
            "reorderLevel": 25,
            "discontinued": false
        },
        {
            "productId": 40,
            "productName": "Boston Crab Meat",
            "supplierId": 19,
            "supplierCompanyName": "New England Seafood Cannery",
            "categoryId": 8,
            "categoryName": "Seafood",
            "quantityPerUnit": "24 - 4 oz tins",
            "unitPrice": 18.4000,
            "unitsInStock": 123,
            "unitsOnOrder": 0,
            "reorderLevel": 30,
            "discontinued": false
        }
    ]
}

We recommend keeping the hypermedia in the “enabled” state even if you are not planning to build the hypermedia-aware apps. The hypermedia links will make it easier to discover the API resources, simplify debugging, and allow taking advantage of the _embed parameter to fetch complex data structures. Add the X-Restful-Hypermedia header with the false value when making HTTP requests or specify the hypermedia parameter with the false value when calling the $app.restful method to exclude the links from the final output.

RESTful vs REST

RESTful Hypermedia reduces the complexity of the client apps and allows evolving the backend application without impact on the existing clients. Hypermedia significantly simplifies the configuration of the user interface options available to the end user. Developers can enable and disabled the UI features by inspecting the hypermedia controls infused in the API definition and data.

REST-based applications include the explicit resource URIs and parameters, which may lead to the tight coupling between the client and the backend application. Client app developers will need to re-create some portion of the server-side business logic when building the resource URIs and configuring the user interface. Precise configuration of the UI features is complicated and requires obtaining the user roles, profile, or scopes.

Final Source Code

This single page app is also composed of a single physical file. The complete source of the spa2.html file is shown next. It includes the HTML and embedded script block with the variables and functions discussed in the tutorial.

HTML
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137<!DOCTYPE html>
<html lang="en">
<head>
    <title>Embedded SPA2 with REST (Level 2)</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-action] {
            margin-right: 1em !important;
        }
    </style>
</head>
<body>
    <div data-app-role="page">
        <h1 style="text-align:center">Embedded SPA2 with REST (Level 2)</h1>
        <div id="authenticated">
            <p>This is the list of products:</p>
            <div id="product-list"></div>
        </div>
    </div>
    <script>
        (function () {
            var products,
                pageIndex = 0,
                pageSize = 10;

            $(document)
                .one('pagereadycomplete.app', e => {
                    start();
                }).on('vclick', '[data-action]', handleActions);


            function start() {
                // show the first page of products by default
                refreshProductList();
            }

            /* product list rendering and paging */

            function refreshProductList() {
                $app.restful({
                    "method": 'GET',       // 1) HTTP method of the request. GET is optional.
                    "url": '/v2/products', // 2) example: /v2/products?limit=10&page=0
                    "query": {             // 3) Properties of the object are added to the URL
                        'limit': pageSize,
                        'page': pageIndex
                    },
                    headers: null,       // 4) Object represents the custom headers
                    body: null,          // 5) Object represents the payload of the request
                })
                    .then(result => {
                        products = result;
                        renderProductData();
                    })
                    .catch(ex => {
                        document.querySelector('#product-list').textContent = 'Unauthorized to see the products';
                        restfulException(ex);
                    });
            }

            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 pageCount = Math.ceil(products.count / pageSize);
                sb.push('<button data-action="self">Refresh</button>');
                sb.push('<button data-action="first"', pageIndex > 0 ? '' : ' disabled="disabled"', '>First</button>');
                sb.push('<button data-action="prev"', pageIndex >= 1 ? '' : ' disabled="disabled"', '>Prev</button>');
                sb.push('<button data-action="next"', pageIndex <= pageCount - 2 ? '' : ' disabled="disabled"', '>Next</button>');
                sb.push('<button data-action="last"', pageIndex < pageCount - 1 ? '' : ' disabled="disabled"', '>Last</button>');
                sb.push('Page: ', pageIndex + 1, ' of ', pageCount,
                    ' (', + 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 handleActions(e) {
                var btn = e.target.closest('[data-action]')
                if (btn && products) {
                    var action = btn.getAttribute('data-action');
                    switch (action) {
                        case 'self':
                            // the page index is not changed
                            break;
                        case 'first':
                            pageIndex = 0;
                            break;
                        case 'prev':
                            pageIndex--;
                            break;
                        case 'next':
                            pageIndex++;
                            break;
                        case 'last':
                            pageIndex = Math.ceil(products.count / pageSize) - 1;
                            break;
                    }
                    refreshProductList();
                }
            }

            /* 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 Embedded SPA3 (Custom UI) with RESTful Hypermedia segment to learn how to build a single page app embedded into the host with a completely custom UI. You will create a custom page without Touch UI that still takes advantage of the host security features.

Saturday, January 15, 2022PrintSubscribe
Embedded SPA1 with RESTful Hypermedia

Let’s build the first Single Page Application (SPA) in the RESTful Workshop series. The app will display a list of products when loaded in a browser.

Embedded SPA1 with RESTful Hypermedia.
Embedded SPA1 with RESTful Hypermedia.

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

Create the empty spa1.html file in the ~/app/pages folder of the backend application. You will gradually add the content to the file as you progress through the tutorial.

If you want the app page to have a dedicated option in the navigation menu of the host, then start Code On Time, select your project, choose Design, click on the New Page button on the Project Explorer toolbar, enter spa1 in the page name, set Template to (blank), set Generate to First Time Only, press OK button. Drag the new page node to the desired parent if needed. Generate the application to create the placeholder file.

Single Page App Layout

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

HTML
1234567891011121314151617181920212223242526272829303132333435<!DOCTYPE html>
<html lang="en">
<head>
    <title>Embedded SPA1 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>
    <div data-app-role="page">
        <h1 style="text-align:center">Embedded SPA1 with RESTful Hypermedia</h1>
        <div id="authenticated">
            <p>This is the list of products:</p>
            <div id="product-list"></div>
        </div>
    </div>
</body>
</html>

This SPA will blend in the user interface of the host. Touch UI will recognize the div[data-app-role=”page”] element as the virtual “start” page. The framework will also create the standard UI features such as navigation menu and sidebar. Element #product-list will serve as a container for the list of products.

The markup of SPA1 blends into Touch UI.
The markup of SPA1 blends into Touch UI.

Our single page app is physically embedded in the host and can take advantage of many of its features. Users will need to sign in to access the page.

Embedded Single Page App Lifecycle

Touch UI will route the visual presentation to the first virtual page when the physical HTML page ~/pages/spa1.html is loaded in a browser. The event pagereadycomplete.app is triggered on the document by the framework whenever a virtual page becomes ready for interaction. The embedded SPA will retrieve and display the first ten rows of the products when it happens.

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

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

        $(document)
            .one('pagereadycomplete.app', e => {
                $app.restful()
                    .then(result => {
                        apiHypermedia = result;
                        start();
                    })
                    .catch(restfulException);
            }).on('vclick', '[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>

Let’s discuss the individual components of this script.

The handler of the pagereadycomplete.app event will trigger one time only when the physical page is loaded and the user interface is fully constructed. The handler will invoke the $app.restful method to get the API definition available to the current user identity. The definition will be stored in the global apiHypermedia variable.

JavaScript
123456789$(document)
    .one('pagereadycomplete.app', e => {
        $app.restful()
            .then(result => {
                apiHypermedia = result;
                start();
            })
            .catch(restfulException);
    }).on('vclick', '[data-hypermedia]', handleHypermedia);
Method $app.restful provides a thin wrapper on top of the Fetch API. It fetches the /v2 endpoint resources of the backend application with the user access token passed in the Authorization header.

Next the pagereadycomplete.app handler will invoke the start function. If the fetched hypermedia contains the products key, then the function will refresh the product list with the first page of data. Otherwise the error message will let the user know that they are not authorized to access products.

JavaScript
1234567function 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';
}

Function refreshProductList invokes the $app.restful() method with the url set to the hypermedia argument. The result is cached in the global variable products. This is followed by the renderProductData call that will be discussed later.

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

The event handler handleHypermedia is triggered when the HTML elements with the data-hypermedia attribute are clicked. It calls the refreshProductList function with the corresponding hypermedia control specified in the argument. Please note that the event handler is processing the synthetic vclick event triggered by Touch UI in response to the mouse, pointer, or touch interactions.

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

The global variable apiHypermedia contains the payload of the /v2 endpoint returned when the $app.restful method is invoked without arguments in the start function. The top-level keys represent the data controllers that are available to the current user. The embedded hypermedia links “explain” how to access the controller resources.

JSON
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849{
    "_links": {
        "oauth2": {
            "href": "/oauth2/v2"
        },
        "restful.js": {
            "href": "/v2/js/restful-2.0.1.js"
        }
    },
    "categories": {
        "_links": {
            "self": {
                "href": "/v2/categories?count=true"
            },
            "first": {
                "href": "/v2/categories?page=0&limit=10"
            },
            "schema": {
                "href": "/v2/categories?count=true&_schema=only"
            }
        }
    },
    "products": {
        "_links": {
            "self": {
                "href": "/v2/products?count=true"
            },
            "first": {
                "href": "/v2/products?page=0&limit=5"
            },
            "schema": {
                "href": "/v2/products?count=true&_schema=only"
            }
        }
    },
    "suppliers": {
        "_links": {
            "self": {
                "href": "/v2/suppliers?count=true"
            },
            "first": {
                "href": "/v2/suppliers?page=0&limit=10"
            },
            "schema": {
                "href": "/v2/suppliers?count=true&_schema=only"
            }
        }
    }
}

The global variable products stores the product data with the hypermedia links. It is refreshed by the refreshProductList function after each successful call to the $app.restful method with the hypermedia control specified in the url argument.

JSON
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113{
    "_links": {
        "self": {
            "href": "/v2/products?page=0&limit=3"
        },
        "next": {
            "href": "/v2/products?page=1&limit=3"
        },
        "last": {
            "href": "/v2/products?page=25&limit=3"
        },
        "lookup-supplierId": {
            "href": "/v2/suppliers?count=true&fields=supplierId,companyName",
            "embeddable": true
        },
        "lookup-categoryId": {
            "href": "/v2/categories?count=true&fields=categoryId,categoryName",
            "embeddable": true
        },
        "create": {
            "href": "/v2/products",
            "method": "POST"
        },
        "schema": {
            "href": "/v2/products?_schema=true&page=0&limit=3"
        }
    },
    "count": 77,
    "page": 0,
    "collection": [
        {
            "_links": {
                "self": {
                    "href": "/v2/products/17"
                },
                "supplierId": {
                    "href": "/v2/products/17/supplier-id",
                    "embeddable": true
                },
                "categoryId": {
                    "href": "/v2/products/17/category-id",
                    "embeddable": true
                }
            },
            "productId": 17,
            "productName": "Alice Mutton",
            "supplierId": 7,
            "supplierCompanyName": "Pavlova, Ltd.",
            "categoryId": 6,
            "categoryName": "Meat/Poultry",
            "quantityPerUnit": "20 - 1 kg tins",
            "unitPrice": 13.3000,
            "unitsInStock": 0,
            "unitsOnOrder": 0,
            "reorderLevel": 0,
            "discontinued": false
        },
        {
            "_links": {
                "self": {
                    "href": "/v2/products/3"
                },
                "supplierId": {
                    "href": "/v2/products/3/supplier-id",
                    "embeddable": true
                },
                "categoryId": {
                    "href": "/v2/products/3/category-id",
                    "embeddable": true
                }
            },
            "productId": 3,
            "productName": "Aniseed Syrup",
            "supplierId": 1,
            "supplierCompanyName": "Exotic Liquids",
            "categoryId": 2,
            "categoryName": "Condiments",
            "quantityPerUnit": "12 - 550 ml bottles",
            "unitPrice": 10.0000,
            "unitsInStock": 13,
            "unitsOnOrder": 70,
            "reorderLevel": 25,
            "discontinued": false
        },
        {
            "_links": {
                "self": {
                    "href": "/v2/products/40"
                },
                "supplierId": {
                    "href": "/v2/products/40/supplier-id",
                    "embeddable": true
                },
                "categoryId": {
                    "href": "/v2/products/40/category-id",
                    "embeddable": true
                }
            },
            "productId": 40,
            "productName": "Boston Crab Meat",
            "supplierId": 19,
            "supplierCompanyName": "New England Seafood Cannery",
            "categoryId": 8,
            "categoryName": "Seafood",
            "quantityPerUnit": "24 - 4 oz tins",
            "unitPrice": 18.4000,
            "unitsInStock": 123,
            "unitsOnOrder": 0,
            "reorderLevel": 30,
            "discontinued": false
        }
    ]
}

The inclusion of the hypermedia controls (links) makes it very easy to determine the availability of a particular API resource (first, next, prev, last, self, create, etc.) and to pass its hypermedia as the url argument to the $spp.restful method. Each hypermedia control defines the mandatory href property and optional method property. The latter specifies the HTTP method that must be used to access the resource specified in href. If the method property is not specified, then GET is assumed.

If a particular hypermedia control is not available, then the state of data does not allow it. Client app developers only need to know the names of the hypermedia controls to present the corresponding user interface elements and to invoke the resources in response to user actions.

Product List Rendering

The page defines the #authenticated container with the embedded #product-list element.

Function renderProductData will produce the markup of a table element representing the rows of data stored in the global products variable.

JavaScript
1234567891011121314151617181920212223242526272829function 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
}

The table element is followed with the markup of the div.toolbar containing five buttons: Refresh, First, Prev, Next, and Last. The buttons are marked with the data-hypermedia attributes with the values that match the names of the hypermedia controls embedded in the products data.

The combined HTML is assigned to the #product-list element. Here is how the first page of data may look when the SPA is loaded in a browser.

The first page of data rendered by the hypermedia-enabled client app.
The first page of data rendered by the hypermedia-enabled client app.

The buttons are marked as disabled if their hypermedia control is not available. For example, the screenshot above shows the First button in the disabled state since the first page of data does not include the first hypermedia link.

The programming of five buttons in the toolbar takes exactly five lines of code. Developers are not required to calculate the range of rows that will need to be requested from the backend or to figure if a particular paging operation is possible. There are no physical resource URLs embedded in the client app code.

RESTful Hypermedia reduces the complexity of the client apps and allows evolving the backend application without impact on the existing clients.

Note that the renderProductList function is using $app.htmlEncode and $app.urlArgs methods to produce a “safe” HTML from data and to parse the URL parameters of the hypermedia href property to display the range of visible rows in the pager toolbar. Method $app.touch.scrollable notifies the host framework that the scrollable portion of the virtual page has changed.

Final Source Code

This single page app is composed of a single physical file. The complete source of the spa1.html file is shown next. It includes the HTML and embedded script block with the variables and functions discussed in the tutorial.

HTML
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115<!DOCTYPE html>
<html lang="en">
<head>
    <title>Embedded SPA1 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>
    <div data-app-role="page">
        <h1 style="text-align:center">Embedded SPA1 with RESTful Hypermedia</h1>
        <div id="authenticated">
            <p>This is the list of products:</p>
            <div id="product-list"></div>
        </div>
    </div>
    <script>
        (function () {
            var apiHypermedia,
                products;

            $(document)
                .one('pagereadycomplete.app', e => {
                    $app.restful()
                        .then(result => {
                            apiHypermedia = result;
                            start();
                        })
                        .catch(restfulException);
                }).on('vclick', '[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 Embedded SPA2 with REST Level 2 segment to learn how to work with the RESTful resources without hypermedia. You will modify the app created in this segment to use the direct resource URls with the explicitly specified HTTP methods.