Now that we got our base infrastructure in place, as described in part 1 of this series, it is time to move forward and cover more complex - and probably more interesting - client-facing components. See how our membership (login) and content management (markdown and role editors) components are implemented and how they work together with Baasic backend services to provide smooth experience for your end users.

Login

Basic authentication functionality is provided via standard login component.

Polymer login element Polymer login element in action

When a site user enters his username and password, our login component sends an Ajax request to Baasic membership service to authenticate him. If everything goes well, Baasic returns a token that is used with all subsequent requests; it is stored in the local cache, and an additional GET request is issued to retrieve additional info, including user roles. Login component will fire a user-login signal when user’s login status is changed, so other components are notified when this happens and can redraw themselves accordingly.

Front-end is rather straightforward - just a couple of templates that change visibility based on the user’s status, a progress indicator, and supporting components (globals, Ajax, signals, toast):

<div id="container">
    <template bind if="{{!isLoggedIn}}">
        <div id="loginbox" vertical layout>
            <div><paper-input id="txtUsername" label="Username" value="{{username}}" floatinglabel></paper-input></div>
            <div><paper-input id="txtPassword" label="Password" type="password" value="{{password}}" floatinglabel></paper-input></div>
            <div>
                <paper-button on-click="{{logIn}}" raised>Log in</paper-button> 
                <template bind if="{{loginInProgress}}">
                    <div class="spinner">
                      <div class="rect1"></div>
                      <div class="rect2"></div>
                      <div class="rect3"></div>
                      <div class="rect4"></div>
                      <div class="rect5"></div>
                    </div>
                </template>
            </div>


        </div>
    </template>
    <template bind if="{{isLoggedIn}}">
        <div id="loggedin" vertical layout>
            <div>You are logged in as {{baasicUser.userName}}</div>
            <div>
                <paper-button on-click="{{logOut}}" raised>Log out</paper-button>
            </div>
        </div>
    </template>
</div>
<baasic-globals id="globals"></baasic-globals>
<baasic-ajax url="{{baseUrl}}/{{version}}/{{application}}/login"
             id="ajax"
             method="POST"
             params='{"type":"Forms"}'
             handleas="json"
             on-core-response="{{ajaxPostResponse}}"
             on-core-error="{{ajaxPostError}}"></baasic-ajax>
<baasic-ajax url="{{baseUrl}}/{{version}}/{{application}}/login"
             id="ajaxGet"
             method="GET"
             params='{"type":"Forms"}'
             contenttype="application/json"
             handleas="json"
             on-core-response="{{ajaxGetResponse}}"
             on-core-error="{{ajaxGetError}}"></baasic-ajax>

<core-signals on-core-signal-user-login="{{loginChanged}}"></core-signals>
<paper-toast text="An error has occured while trying to log you in. Please check your data and try again." id="toastError" onclick="discardToast(el)"></paper-toast>

There is also nothing too complicated in the script itself: just a couple of Baasic method calls and their handlers that store user’s info in the cache, along with the logic for redrawing the component and firing signals to other components. Login component itself sits on top of some of the components we have built in the part 1, so the amount of housekeeping code is significantly reduced.

ready: function () {
    var globals = this.$.globals;
    if (!this.baseUrl) this.baseUrl = globals.config.baseUrl;
    if (!this.version) this.version = globals.config.version;
    if (!this.application) this.application = globals.config.application;
    this.loadUser();
},
loadUser: function () {
    this.baasicUser = Baasic.Cache.getItem('baasicUser');
    if (!this.baasicUser)
        this.baasicUser = {};
    this.toggleView();
},
logIn: function () {
    this.loginInProgress = true;
    var ajax = this.$.ajax;
    ajax.body = 'grant_type=password&username=' + this.username + '&password=' + this.password;
    ajax.go();
},
loginChanged: function (data) {
    if (data.detail && data.detail.baasicUser && !data.detail.baasicUser.accessToken) {
        this.resetUser();
    }
},
resetUser: function () {
    this.baasicUser = {};
    Baasic.Cache.removeItem('baasicUser');
    this.toggleView();
},
logOut: function () {
    //signal that user has logged out, so other modules could switch views acordingly
    this.fire('core-signal', { name: 'user-login', data: { baasicUser: {} } });
},
ajaxPostResponse: function (e) {
    this.baasicUser.accessToken = e.detail.response.access_token;
    this.baasicUser.tokenType = e.detail.response.token_type;
    this.baasicUser.userName = this.username;
    this.baasicUser.expiresIn = e.detail.response.expires_in;
    //before storing the user to the cache, perform an additional GET call on the same login API endpoint to retrieve additional user data - groups, id, etc.
    var ajaxGet = this.$.ajaxGet;
    var headers = ajaxGet.headers || {};
    headers['Authorization'] = this.baasicUser.tokenType + ' ' + this.baasicUser.accessToken;
    ajaxGet.headers = headers;
    this.$.ajaxGet.go();
},
ajaxPostError: function (e) {
    this.loginInProgress = false;
    this.$.toastError.show();
},
ajaxGetResponse: function (e) {
    this.baasicUser.roles = e.detail.response.roles;
    this.baasicUser.id = e.detail.response.id;
    this.baasicUser.expiresAbsolute = new Date(new Date().getTime() + (this.baasicUser.expiresIn * 1000));
    this.baasicUser.isAdmin = Baasic.Utils.isArrayMatch(this.$.globals.config.adminRoles, e.detail.response.roles);
    //now that we have everything, save the user data to the local storage
    Baasic.Cache.setItem('baasicUser', this.baasicUser, {
        expirationAbsolute: this.baasicUser.expiresAbsolute,
        expirationSliding: null,
        priority: Cache.Priority.HIGH,
        callback: null
    });

    //signal that user has logged in, so other modules could switch views acordingly
    this.fire('core-signal', { name: 'user-login', data: { baasicUser: this.baasicUser } });
    this.loginInProgress = false;
    //redirect to another URL if needed
    if (this.redirectUrl)
        window.location.href = this.redirectUrl;
    else
        this.toggleView();

},
ajaxGetError: function (e) {
    this.loginInProgress = false;
    this.$.toastError.show();
},
toggleView: function () {
    if (this.baasicUser && this.baasicUser.accessToken)
        this.isLoggedIn = true;
    else
        this.isLoggedIn = false;
},
discardToast: function (el) {
    el.dismiss();
}

Article preview

This is probably the simplest UI component. It just grabs the appropriate article entity from the Baasic backend based on its slug parameter, shows edit button if user is in the administrative role, and takes care of editor’s display state and events.

<template bind if="{{isAdmin}}">
    <paper-button on-click="{{toggle}}">Edit article</paper-button>
    <core-collapse id="collapse">
        <div class="[ article editor ]">
            <baasic-articleeditor id="articleEditor" application="webcomponents" article="{{article}}" slug="{{slug}}" hideSaveButton="true" on-article-save="{{reload}}"></baasic-articleeditor>
        </div>
        <div class="[ article ] [ footer ]">
            <paper-button on-click="{{saveArticle}} {{toggle}}" affirmative>Save</paper-button>
        </div>
    </core-collapse>
</template>
<baasic-globals id="globals"></baasic-globals>

<baasic-ajax id="ajax" 
    url="{{baseUrl}}/{{version}}/{{application}}/article/{{slug}}/"
    params='{"embed":"Authors,Tags"}'
    handleas="json"
    contenttype="application/json"
    response="{{response}}"
    on-core-error="{{onGetError}}"></baasic-ajax>
<core-signals on-core-signal-user-login="{{loginChanged}}"></core-signals>

Script code is equally simple:

ready: function () {
    this.globals = this.$.globals;
    if (!this.baseUrl) this.baseUrl = this.globals.config.baseUrl;
    if (!this.version) this.version = this.globals.config.version;
    if (!this.application) this.application = this.globals.config.application;
    this.fetchData();
},
responseChanged: function (oldValue, newValue) {
    if (newValue != null && oldValue != newValue) {
        this.article = newValue;
        this.$.content.innerHTML = marked(this.article.content);
    }
},
toggle: function() {
    this.shadowRoot.getElementById('collapse').toggle();
    this.shadowRoot.getElementById('articleEditor').refresh();
},
loginChanged: function (data) {
    this.fetchData();
},
fetchData: function () {
    this.isAdmin = this.globals.baasicUser.isAdmin;
    if (!this.isAdmin && this.sync)
        this.$.ajax.xhrArgs = { sync: true };
    else
        this.$.ajax.xhrArgs = { sync: false };
    this.$.ajax.go();
},
onGetError: function (e) {
    this.shadowRoot.getElementById('content').innerHTML = '';
},
saveArticle: function (e) {
    this.shadowRoot.getElementById('articleEditor').saveChanges(e);
},
reload: function (e) {
    this.shadowRoot.getElementById('content').innerHTML = marked(e.detail.content);
}

Editors

Markdown and role editor component is a final piece of functionality we need to have for a minimal, yet fully functional content management system.

Polymer article editor Baasic markdown editor

Our editor is based on the markdown editor component created by Nicolas Chaulet. We have modified it quite a bit, and as full source is available in our repository, I will not be showing it here.

Its front end again consists of just a couple of templates for displaying the appropriate content based on the user’s role:

<template>
    <style>
        :host {
            display: block;
            background: #fff;
        }
        paper-checkbox {
            padding: 20px 20px 20px 0;
        }
        paper-checkbox + label {
            padding: 16px 0 0 0;
        }
        h1, h2, h3 {
            font-family: inherit;
            font-weight: normal;
        }
    </style>
    <template bind if="{{!isAdmin}}">
        You are not allowed to access this section. Please check your login information or contact the administrator to obtain the right credentials.
    </template>

    <template bind if="{{isAdmin}}">
        <div id="editorBox">
            <paper-input id="txtTitle" label="Article title" floatingLabel></paper-input>
            <baasic-markdowneditor id="editor"></baasic-markdowneditor>
            <div id="rolesList" horizontal layout>
                <div flex one>
                    <h3>Who can view this content?</h3>
                    <template repeat="{{ role in roles }}">
                        <div horizontal layout>
                            <paper-checkbox name="cbRead" id="{{role.id}}"></paper-checkbox>
                            <label>{{ role.name }}</label>
                        </div>
                    </template>
                </div>
                <div flex one>
                    <h3>Who can edit this content?</h3>
                    <template id="tplRoles" repeat="{{ role in roles }}">
                        <div horizontal layout>
                            <paper-checkbox name="cbEdit" id="{{role.id}}"></paper-checkbox>
                            <label>{{ role.name }}</label>
                        </div>
                    </template>
                </div>
            </div>
            <template bind if="{{!hideSaveButton}}">
                <paper-button on-click="{{saveChanges}}">Save</paper-button>
            </template>
        </div>
    </template>


    <baasic-globals id="globals"></baasic-globals>

    <core-signals on-core-signal-user-login="{{loginChanged}}"></core-signals>

    <baasic-crud id="crudElement"
                 geturl="{{baseUrl}}/{{version}}/{{application}}/article/{{slug}}/"
                 posturl="{{baseUrl}}/{{version}}/{{application}}/article/"
                 puturl="{{baseUrl}}/{{version}}/{{application}}/article/{{slug}}/"
                 deleteurl="{{baseUrl}}/{{version}}/{{application}}/article/{{slug}}/"
                 on-get-completed="{{handleCrudCompleted}}"
                 on-get-error="{{handleCrudError}}"></baasic-crud>

</template>

Script code is a bit more complex, and shows a couple of neat techniques we have used. For example, we are loading lookups asynchronously via promises in the globals component and are using an associative array to simplify parsing and setting user roles. Code should be pretty self-explanatory, please post a comment if you need any additional guidance.

ready: function () {
    this.globals = this.$.globals;
    if (!this.baseUrl) this.baseUrl = this.globals.config.baseUrl;
    if (!this.version) this.version = this.globals.config.version;
    if (!this.application) this.application = this.globals.config.application;

    this.crud = this.$.crudElement;
    this.loadLookups();
    this.fetchData();
},
loadLookups: function() {
    this.globals.roles
        .then(function (d) {
            this.roles = d.data;
            this.setValues();
        }.bind(this));
    this.globals.actions
        .then(function (d) {
            this.setAccessActions(d.data);
            this.setValues();
        }.bind(this));
},
articleChanged: function (oldValue, newValue) {
    //if article does not exist, the element will issue a POST call to create it when Save button is clicked, otherwise a PUT call will be issued to update the existing article
    if (newValue)
        this.articleExists = true;
    else
        this.articleExists = false;
    //set article content and role checkboxes
    this.setValues();
},
loginChanged: function (data) {
    if (this.auto)
        this.fetchData();
},
fetchData: function() {
    this.isAdmin = this.globals.baasicUser.isAdmin;
    //get the article if it is not set via element article attribute
    if (this.auto) {
        this.crud.get({ embed: 'Authors,Tags' });
    }
},
handleCrudCompleted: function (e) {
    this.article = e.detail.response;
},
setValues: function () {
    if (this.article && this.roles && this.accessActions) {
        if (this.shadowRoot.getElementById('editor'))
            //need to set this via this.job, otherwise it tends to crash the browser and exhibit other strange behaviour
            this.job('setEditorValue', this.setEditorValue, 0);
        if (this.shadowRoot.getElementById('txtTitle'))
            this.shadowRoot.getElementById('txtTitle').value = this.article.title;
        this.displayRoles(this.article.permissions);
    }
},
setEditorValue: function () {
    this.shadowRoot.getElementById('editor').value = this.article.content;
},
handleCrudError: function (e) {
    this.articleExists = false;
},
setAccessActions: function (actions) {
    //form an associative array of system actions that can be accessed by action abbreviation and holds a full action lookup object inside. This is used to quickly retrieve the action object by its abbreviation.
    if (actions) {
        this.accessActions = {};
        for (var i = 0, len = actions.length; i < len; i++) {
            this.accessActions[actions[i].abrv] = actions[i];
        }
    }
},
displayRoles: function(permissions) {
    //set appropriate values of checkboxes based on the permissions from the article object
    this.setCheckboxes("[name^='cbRead']", 'Read', permissions);
    this.setCheckboxes("[name^='cbEdit']", 'Update', permissions);
},
setCheckboxes: function (selector, action, permissions) {
    //loops through all checkboxes and checks them if a role from the permission list matches the ID of a checkbox
    if (this.accessActions) {
        var cbs = this.shadowRoot.querySelectorAll(selector);
        var act = this.accessActions[action];
        for (i = 0; i < cbs.length; i++) {
            var exists = permissions.filter(function (p) {
                return p.actionId === act.id && p.roleId === cbs[i].id;
            });
            if (exists.length)
                cbs[i].checked = true;
        }
    }
},
parseRoles: function () {
    //retrieves the contents of the checkbox lists on save
    var permArr = [];
    this.setPermissions("[name^='cbRead']", 'Read', permArr);
    this.setPermissions("[name^='cbEdit']", 'Update', permArr);
    return permArr;
},
setPermissions: function (selector, action, permArr) {
    //adds checked roles to the permission array
    var act = this.accessActions[action];
    var cbs = this.shadowRoot.querySelectorAll(selector);
    for (i = 0; i < cbs.length; i++) {
        if (cbs[i].checked) {
            permArr.push({
                actionId: act.id,
                roleId: cbs[i].id
            });
        }
    }
},
saveChanges: function (e) {
    var content = this.shadowRoot.getElementById('editor').value;
    var title = this.shadowRoot.getElementById('txtTitle').value;
    var article = {
        title: title,
        content: content,
        allowComments: 'false',
        //status - published
        status: '2',
        publishDate: new Date(),
        //dynamic alternative would be slug: String.slugify(title), but let's stick to simpler scneario
        slug: this.slug,
        permissions: this.parseRoles()
    };
    var body = JSON.stringify(article);
    if (this.articleExists)
        this.crud.put(null, body);
    else {
        this.crud.post(null, body);
        this.articleExists = true;
    }
    this.fire('article-save', { title: title, content: content });
},
refresh: function () {
    //needed to show the contents of a markdown editor on initial load, without it it would be empty
    this.shadowRoot.getElementById('editor').refresh();
}

That’s all for this time - I hope that you will spend some time with our first component set and see how it works in your scenarios. Web components and Polymer really open up a world of possibilities, and are a right match for Bassic architecture and project goals, so we plan to use it more frequently in our custom projects. Let us know if there are other scenarios and topics that you would like us to cover in future posts.

Feel free to leave a comment

comments powered by Disqus