Setting up SUSI Desktop Locally for Development and Using Webview Tag and Adding Event Listeners

SUSI Desktop is a cross platform desktop application based on electron which presently uses chat.susi.ai as a submodule and allows the users to interact with susi right from their desktop.

Any electron app essentially comprises of the following components

    • Main Process (Managing windows and other interactions with the operating system)
    • Renderer Process (Manage the view inside the BrowserWindow)

Steps to setup development environment

      • Clone the repo locally.
$ git clone https://github.com/fossasia/susi_desktop.git
$ cd susi_desktop
      • Install the dependencies listed in package.json file.
$ npm install
      • Start the app using the start script.
$ npm start

Structure of the project

The project was restructured to ensure that the working environment of the Main and Renderer processes are separate which makes the codebase easier to read and debug, this is how the current project is structured.

The root directory of the project contains another directory ‘app’ which contains our electron application. Then we have a package.json which contains the information about the project and the modules required for building the project and then there are other github helper files.

Inside the app directory-

  • Main – Files for managing the main process of the app
  • Renderer – Files for managing the renderer process of the app
  • Resources – Icons for the app and the tray/media files
  • Webview Tag

    Display external web content in an isolated frame and process, this is used to load chat.susi.ai in a BrowserWindow as

    <webview src="https://chat.susi.ai/"></webview>
    

    Adding event listeners to the app

    Various electron APIs were used to give a native feel to the application.

  • Send focus to the window WebContents on focussing the app window.
  • win.on('focus', () => {
    	win.webContents.send('focus');
    });
    
  • Display the window only once the DOM has completely loaded.
  • const page = mainWindow.webContents;
    ...
    page.on('dom-ready', () => {
    	mainWindow.show();
    });
    
  • Display the window on ‘ready-to-show’ event
  • win.once('ready-to-show', () => {
    	win.show();
    });
    

    Resources

    1. A quick article to understand electron’s main and renderer process by Cameron Nokes at Medium link
    2. Official documentation about the webview tag at https://electron.atom.io/docs/api/webview-tag/
    3. Read more about electron processes at https://electronjs.org/docs/glossary#process
    4. SUSI Desktop repository at https://github.com/fossasia/susi_desktop.

    Store User’s Personal Information with SUSI

    In this blog, I discuss how SUSI.AI stores personal information of it’s users. This personal information is mostly about usernames/links to different websites like LinkedIn, GitHub, Facebook, Google/Gmail etc. To store such details, we have a dedicated API. Endpoint is :

    https://api.susi.ai/aaa/storePersonalInfo.json
    

    In this API/Servlet, storing the details and getting the details, both the aspects are covered. At the time of making the API call, user has an option either to ask the server for a list of available store names along with their values or request the server to store the value for a particular store name. If a store name already exists and a client makes a call with new/updated value, The servlet will update the value for that particular store name.

    The reason you are looking at minimal user role as USER is quite obvious, i.e. these details correspond to a particular user. Hence neither we want someone writing such information anonymously nor we want this information to be visible to anonymous user until allowed by the user.

    In the next steps, we start evaluating the API call made by the client. We look at the combination of the parameters present in the request. If the request is to fetch list of available stores, server first checks if Accounting object even has a JSONObject for “stores” or not. If not found, it sends an error message “No personal information is added yet.” and error code 420. Prior to all these steps, server first generates an accounting object for the user. If found, details are encoded as JSONObject’s parameter. Look at the code below to understand things fairly.

    Accounting accounting = DAO.getAccounting(authorization.getIdentity());
            if(post.get("fetchDetails", false)) {
                if(accounting.getJSON().has("stores")){
                    JSONObject jsonObject = accounting.getJSON().getJSONObject("stores");
                    json.put("stores", jsonObject);
                    json.put("accepted", true);
                    json.put("message", "details fetched successfully.");
                    return new ServiceResponse(json);
                } else {
                    throw new APIException(420, "No personal information is added yet.");
                }
            }
    

    If the request was not to fetch the list of available stores, It means client wants server to save a new field or update a previous value for that of a store name. A combination of If-else evaluates whether the call even contains required parameters.

    if (post.get(“storeName”, null) == null) {
    throw new APIException(422, “Bad store name encountered!”);
    }

    String storeName = post.get(“storeName”, null);
    if (post.get(“value”, null) == null) {
    throw new APIException(422, “Bad store name value encountered!”);
    }

    If request contains all the required data, then store name & value are extracted as key-value pair from the request.

    In the next steps, since the server is expected to store list of the store names for a particular user, First the identity is gathered from the already present authorization object in “serviceImpl” method. If the server finds a “null” identity, It throws an error with error code 400 and error message “Specified User Setting not found, ensure you are logged in”.

    Else, server first checks if a JSONObject with key “stores” exists or not. If not, It will create an object and will put the key value pair in the new JSONObject. Otherwise it would anyways do so.

    Since these details are for a particular account (i.e. for a particular user), these are placed in the Accounting.json file. For better knowledge, Look at the code snippet below.

    if (accounting.getJSON().has("stores")) {
                    accounting.getJSON().getJSONObject("stores").put(storeName, value);
                } else {
                    JSONObject jsonObject = new JSONObject(true);
                    jsonObject.put(storeName, value);
                    accounting.getJSON().put("stores", jsonObject);
                }
    
                json.put("accepted", true);
                json.put("message", "You successfully updated your account information!");
                return new ServiceResponse(json);
    

    Additional Resources :

    Enhancing SUSI Desktop to Display a Loading Animation and Auto-Hide Menu Bar by Default

    SUSI Desktop is a cross platform desktop application based on electron which presently uses chat.susi.ai as a submodule and allows the users to interact with susi right from their desktop. The benefits of using chat.susi.ai as a submodule is that it inherits all the features that the webapp offers and thus serves them in a nicely build native application.

    Display a loading animation during DOM load.

    Electron apps should give a native feel, rather than feeling like they are just rendering some DOM, it would be great if we display a loading animation while the web content is actually loading, as depicted in the gif below is how I implemented that.
    Electron provides a nice, easy to use API for handling BrowserWindow, WebContent events. I read through the official docs and came up with a simple solution for this, as depicted in the below snippet.

    onload = function () {
    	const webview = document.querySelector('webview');
    	const loading = document.querySelector('#loading');
    
    	function onStopLoad() {
    		loading.classList.add('hide');
    	}
    
    	function onStartLoad() {
    		loading.classList.remove('hide');
    	}
    
    	webview.addEventListener('did-stop-loading', onStopLoad);
    	webview.addEventListener('did-start-loading', onStartLoad);
    };
    

    Hiding menu bar as default

    Menu bars are useful, but are annoying since they take up space in main window, so I hid them by default and users can toggle their display on pressing the Alt key at any point of time, I used the autoHideMenuBar property of BrowserWindow class while creating an object to achieve this.

    const win = new BrowserWindow({
    	
    	show: false,
    	autoHideMenuBar: true
    });
    

    Resources

    1. More information about BrowserWindow class in the official documentation at electron.atom.io.
    2. Follow a quick tutorial to kickstart creating apps with electron at https://www.youtube.com/watch?v=jKzBJAowmGg.
    3. SUSI Desktop repository at https://github.com/fossasia/susi_desktop.

    Using Universal Image Loader to Display Image on Phimpme Android Application

    In Phimpme Android application we needed to load image on the sharing Activity fast so that there won’t be any delay that is visible by a user in the loading of any activity. We used Universal Image Loader to load the image on the sharing Activity to load Image faster.

    Getting Universal Image Loader

    To get Universal Image Loader in your application go to Gradle(app)-> and then add the following line of code inside dependencies:

    dependencies{
    
    compile 'com.nostra13.universalimageloader:universal-image-loader:1.9.4'
    
    }

    Initialising Universal Image Loader and Displaying Image

    To display image on using Universal Image Loader we need to convert the image into a URI from a file path:

    saveFilePath = getIntent().getStringExtra(EXTRA_OUTPUT);
    Uri uri = Uri.fromFile(new File(saveFilePath));

    How an image should be displayed

    We need to display the image in such a way that it covers the whole image view in the sharing Activity. The image should be zoomed out. The quality of the image should not be distorted or reduced. The image should look as it is. The image should be zoomable so that the user can pinch to zoom in and zoom out. For the image to adjust the whole Image View we set ImageScaleType.EXACTLY_STRETCHED. We will also set cacheInMemory to true and cacheOnDisc to true.  

    private void initView() {
       saveFilePath = getIntent().getStringExtra(EXTRA_OUTPUT);
       Uri uri = Uri.fromFile(new File(saveFilePath));
       ImageLoader imageLoader = ((MyApplication)getApplicationContext()).getImageLoader();
       DisplayImageOptions options = new DisplayImageOptions.Builder()
               .cacheOnDisc(true)
               .imageScaleType(ImageScaleType.EXACTLY_STRETCHED)
               .cacheInMemory(true)
               .bitmapConfig(Bitmap.Config.RGB_565)
               .build();
       imageLoader.displayImage(uri.toString(), shareImage, options);
    }

    Image Loader function in MyApplication class:

    private void initImageLoader() {
       File cacheDir = com.nostra13.universalimageloader.utils.StorageUtils.getCacheDirectory(this);
       int MAXMEMONRY = (int) (Runtime.getRuntime().maxMemory());
       // System.out.println("dsa-->"+MAXMEMONRY+"   "+(MAXMEMONRY/5));//.memoryCache(new
       // LruMemoryCache(50 * 1024 * 1024))
       DisplayImageOptions defaultOptions = new DisplayImageOptions.Builder()
               .cacheInMemory(true)
               .cacheOnDisk(true)
               .build();
    
       ImageLoaderConfiguration config = new ImageLoaderConfiguration.Builder(
               this).memoryCacheExtraOptions(480, 800).defaultDisplayImageOptions(defaultOptions)
               .diskCacheExtraOptions(480, 800, null).threadPoolSize(3)
               .threadPriority(Thread.NORM_PRIORITY - 2)
               .tasksProcessingOrder(QueueProcessingType.FIFO)
               .denyCacheImageMultipleSizesInMemory()
               .memoryCache(new LruMemoryCache(MAXMEMONRY / 5))
               .diskCache(new UnlimitedDiskCache(cacheDir))
               .diskCacheFileNameGenerator(new HashCodeFileNameGenerator()) // default
               .imageDownloader(new BaseImageDownloader(this)) // default
               .imageDecoder(new BaseImageDecoder(false)) // default
               .defaultDisplayImageOptions(DisplayImageOptions.createSimple()).build();
    
       this.imageLoader = ImageLoader.getInstance();
       imageLoader.init(config);
    }

    Image View in Sharing Activity XML file:

    In the Sharing Activity Xml resource, we need to specify the width of the image view and the height of the image view. In Phimpme Android application we are using ImageViewTouch so that we have features like touch to zoom in zoom out. The scale type of the imageView is centerCrop so that image which is loaded is zoomed out and focus is in the center of the image.  

    <org.fossasia.phimpme.editor.view.imagezoom.ImageViewTouch
       android:layout_width="match_parent"
       android:layout_height="wrap_content"
       android:id="@+id/share_image"
       android:layout_below="@+id/toolbar"
       android:layout_weight="10"
       android:layout_alignParentStart="true"
       android:scaleType="centerCrop"/>

    Conclusion

    To load image faster on any ImageView we should use Universal Image Loader. It helps load the activity faster and allows many features as discussed in the blog.

     

    Github

    Resources

    Auto Updating SUSI Android APK and App Preview on appetize.io

    This blog will cover the way in which the SUSI Android APK is build automatically after each commit and pushed to “apk” branch in the github repo. Other thing which will be covered is that how the app preview on appetize.io can be updated after each commit. This is basically for the testers who wish to test the SUSI Android App. There are four ways to test the SUSI Android App. One is to simply download the alpha version of the app from the Google PlayStore. Here is the link to the app. Join the alpha testing and report bugs on the github issue tracker of the repo. Other way is to build the app from Android Studio but you may need to set the complete project. If you are looking to contribute in the project, this is the advised way to test the app. The other two ways are explained below.

    Auto Building of APK and pushing to “apk” branch

    We have written a script which does following steps whenever a PR is merged:

    1. Checks if the commit is of a PR or a commit to repo
    2. If not of PR, configures a user whose github account will be used to push the APKs.
    3. Clones the repo, generates the debug and release APK.
    4. Deletes everything in the apk branch.
    5. Commits and Pushes new changes to apk branch.

    This script is written for people or testers who do not have android studio installed in their computer and want to test the app. So, they can directly download the apk from the apk branch and install it in their phone. The APK is always updated after each commit. So, whenever a tester downloads the APK from apk branch, he will always get the latest app.

    if [[ $CIRCLE_BRANCH != pull* ]]
    then
        git config --global user.name "USERNAME"
        git config --global user.email "EMAIL"
    
        git clone --quiet --branch=apk https://USERNAME:[email protected]/fossasia/susi_android apk > /dev/null
        ls
        cp -r ${HOME}/${CIRCLE_PROJECT_REPONAME}/app/build/outputs/apk/app-debug.apk apk/susi-debug.apk
        cp -r ${HOME}/${CIRCLE_PROJECT_REPONAME}/app/build/outputs/apk/app-release-unsigned.apk apk/susi-release.apk
        cd apk
    
        git checkout --orphan workaround
        git add -A
    
        git commit -am "[Circle CI] Update Susi Apk"
    
        git branch -D apk
        git branch -m apk
    
        git push origin apk --force --quiet > /dev/null
    fi
    

    Auto Updating of App Preview on appetize.io

    The APKs generated in the above step can now be used to set up the preview of the app on the appetize.io. Appetize.io is an online simulator to run mobile apps ( IOS and Android). Appetize.io provides a nice virtual mobile frame to run native apps with various options like screen size, mobile, OS version, etc. Appetize.io provides some API to update/publish the app. In SUSI, we once uploaded the app on appetize.io and now we are using the API provided by them to update the APK everytime a commit is pushed in the repository.

    API information (Derived from official docs of appetize.io):

    You may upload a new version of an existing app, or update app settings.

    Send an HTTP POST request to

    https://[email protected]/v1/apps/PUBLICKEY

    Replace APITOKEN with your API token and PUBLICKEY with the public key of the app you’re updating. Your API token must be permissioned to the same account as was used to upload the app. The POST body must be a JSON object. To delete a previously set field, use a value of null.

    Optional Fields

    1. url: (string) a publicly accessible link to your .zip, .tar.gz, or .apk file, used to upload a new version of your app.
    2. note: (string) a note for your own purposes, will appear on your management dashboard.

    For the url parameter, we have used https://github.com/fossasia/susi_android/raw/apk/susi-debug.apk and note can be anything. We have used Update SUSI Preview.

    curl https://[email protected]/v1/apps/mbpprq4xj92c119j7nxdhttjm0 -H 'Content-Type: application/json' -d '{"url":"https://github.com/fossasia/susi_android/raw/apk/susi-debug.apk", "note": "Update SUSI Preview"}'
    

    Summary

    This blog covered about how to implement an automatic structure to generate APKs for testing and using that APK to build a preview on websites like appetize.io and then using the APIs provided by them to update the APK after each PR merge in the repo. Check out the resources below to learn more about the topic. So, if you are thinking of contributing to SUSI Android App, this may help you a little in testing the app. But if not, then you can also use the similar technique for your android app as well and ease the life of testers.

    Resources

    1. Docs of appetize.io to learn more about the API https://appetize.io/docs
    2. Tutorial on using curl to make API requests https://curl.haxx.se/docs/httpscripting.html
    3. Tutorial on writing basic shell scripts https://ryanstutorials.net/bash-scripting-tutorial/

    Adding Tweet Streaming Feature in World Mood Tracker loklak App

    The World Mood Tracker was added to loklak apps with the feature to display aggregated data from the emotion classifier of loklak server. The next step in the app was adding the feature to display the stream of Tweets from a country as they are discovered by loklak. With the addition of stream servlet in loklak, it was possible to utilise it in this app.

    In this blog post, I will be discussing the steps taken while adding to introduce this feature in World Mood Tracker app.

    Props for WorldMap component

    The WorldMap component holds the view for the map displayed in the app. This is where API calls to classifier endpoint are made and results are displayed on the map. In order to display tweets on clicking a country, we need to define react props so that methods from higher level components can be called.

    In order to enable props, we need to change the constructor for the component –

    export default class WorldMap extends React.Component {
        constructor(props) {
            super(props);
            ...
        }
        ...
    }
    

    [SOURCE]

    We can now pass the method from parent component to enable streaming and other components can close the stream by using props in them –

    export default class WorldMoodTracker extends React.Component {
        ...
        showStream(countryName, countryCode) {
            /* Do something to enable streaming component */
            ...
        }
     
        render() {
            return (
                 ...
                    <WorldMap showStream={this.showStream}/>
                 ...
            )
        }
    }
    

    [SOURCE]

    Defining Actions on Clicking Country Map

    As mentioned in an earlier blog post, World Mood Tracker uses Datamaps to visualize data on a map. In order to trigger a piece of code on clicking a country, we can use the “done” method of the Datamaps instance. This is where we use the props passed earlier –

    done: function(datamap) {
        datamap.svg.selectAll('.datamaps-subunit').on('click', function (geography) {
            props.showStream(geography.properties.name, reverseCountryCode(geography.id));
        })
    }
    

    [SOURCE]

    The name and ID for the country will be used to display name and make API call to stream endpoint respectively.

    The StreamOverlay Component

    The StreamOverlay components hold all the utilities to display the stream of Tweets from loklak. This component is used from its parent components whose state holds info about displaying this component –

    export default class WorldMoodTracker extends React.Component {
        ...
        getStreamOverlay() {
            if (this.state.enabled) {
                return (<StreamOverlay
                    show={true} channel={this.state.channel}
                    country={this.state.country} onClose={this.onOverlayClose}/>);
            }
        }
    
        render() {
            return (
                ...
                    {this.getStreamOverlay()}
                ...
            )
        }
    }
    

    [SOURCE]

    The corresponding props passed are used to render the component and connect to the stream from loklak server.

    Creating Overlay Modal

    On clicking the map, an overlay is shown. To display this overlay, react-overlays is used. The Modal component offered by the packages provides a very simple interface to define the design and interface of the component, including style, onclose hook, etc.

    import {Modal} from 'react-overlays';
    
    <Modal aria-labelledby='modal-label'
        style={modalStyle}
        backdropStyle={backdropStyle}
        show={true}
        onHide={this.close}>
        <div style={dialogStyle()}>
            ...
        </div>
    </Modal>
    

    [SOURCE]

    It must be noted that modalStyle and backdropStyle are React style objects.

    Dialog Style

    The dialog style is defined to provide some space at the top, clicking where, the overlay is closed. To do this, vertical height units are used –

    const dialogStyle = function () {
        return {
            position: 'absolute',
            width: '100%',
            top: '5vh',
            height: '95vh',
            padding: 20
            ...
        };
    };
    

    [SOURCE]

    Connecting to loklak Tweet Stream

    loklak sends Server Sent Events to clients connected to it. To utilise this stream, we can use the natively supported EventSource object. Event stream is started with the render method of the StreamOverlay component –

    render () {
        this.startEventSource(this.props.channel);
        ...
    }
    

    [SOURCE]

    This channel is used to connect to twitter/country/<country-ID> channel on the stream and then this can be passed to EventStream constructor. On receiving a message, a list of Tweets is appended and later rendered in the view –

    startEventSource(country) {
        let channel = 'twitter%2Fcountry%2F' + country;
        if (this.eventSource) {
            return;
        }
        this.eventSource = new EventSource(host + '/api/stream.json?channel=' + channel);
        this.eventSource.onmessage = (event) => {
            let json = JSON.parse(event.data);
            this.state.tweets.push(json);
            if (this.state.tweets.length > 250) {
                this.state.tweets.shift();
            }
            this.setState(this.state);
        };
    }
    

    [SOURCE]

    The size of the list is restricted to 250 here, so when a newer Tweet comes in, the oldest one is chopped off. And thanks to fast DOM actions in React, the rendering doesn’t take much time.

    Rendering Tweets

    The Tweets are displayed as simple cards on which user can click to open it on Twitter in a new tab. It contains basic information about the Tweet – screen name and Tweet text. Images are not rendered as it would make no sense to load them when Tweets are coming at a high rate.

    function getTweetHtml(json) {
        return (
            <div style={{padding: '5px', borderRadius: '3px', border: '1px solid black', margin: '10px'}}>
                <a href={json.link} target="_blank">
                <div style={{marginBottom: '5px'}}>
                    <b>@{json['screen_name']}</b>
                </div>
                <div style={{overflowX: 'hidden'}}>{json['text']}</div>
                </a>
            </div>
        )
    }
    

    [SOURCE]

    They are rendered using a simple map in the render method of StreamOverlay component –

    <div className={styles.container} style={{'height': '100%', 'overflowY': 'auto',
        'overflowX': 'hidden', maxWidth: '100%'}}>
        {this.state.tweets.reverse().map(getTweetHtml)}
    </div>
    

    [SOURCE]

    Closing Overlay

    With the previous setup in place, we can now see Tweets from the loklak backend as they arrive. But the problem is that we will still be connected to the stream when we click-close the modal. Also, we would need to close the overlay from the parent component in order to stop rendering it.

    We can use the onclose method for the Modal here –

    close() {
        if (this.eventSource) {
            this.eventSource.close();
            this.eventSource = null;
        }
        this.props.onClose();
    }
    

    [SOURCE]

    Here, props.onClose() disables rendering of StreamOverlay in the parent component.

    Conclusion

    In this blog post, I explained how the flow of props are used in the World Mood Tracker app to turn on and off the streaming in the overlay defined using react-overlays. This feature shows a basic setup for using the newly introduced stream API in loklak.

    The motivation of such application was taken from emojitracker by mroth as mentioned in fossasia/labs.fossasia.org#136. The changes were proposed in fossasia/apps.loklak.org#315 by @singhpratyush (me).

    The app can be accessed live at https://singhpratyush.github.io/world-mood-tracker/index.html.

    Resources

    Using Protractor for UI Tests in Angular JS for Loklak Apps Site

    Loklak apps site’s home page and app details page have sections where data is dynamically loaded from external javascript and json files. Data is fetched from json files using angular js, processed and then rendered to the corresponding views by controllers. Any erroneous modification to the controller functions might cause discrepancies in the frontend. Since Loklak apps is a frontend project, any bug in the home page or details page will lead to poor UI/UX. How do we deal with this? One way is to write unit tests for the various controller functions and check their behaviours. Now how do we test the behaviours of the site. Most of the controller functions render something on the view. One thing we can do is simulate the various browser actions and test site against known, accepted behaviours with Protractor.

    What is Protractor

    Protractor is end to end test framework for Angular and AngularJS apps. It runs tests against our app running in browser as if a real user is interacting with our browser. It uses browser specific drivers to interact with our web application as any user would.

    Using Protractor to write tests for Loklak apps site

    First we need to install Protractor and its dependencies. Let us begin by creating an empty json file in the project directory using the following command.

    echo {} > package.json
    

    Next we will have to install Protractor.

    The above command installs protractor and webdriver-manager. After this we need to get the necessary binaries to set up our selenium server. This can be done using the following.

    ./node_modules/protractor/bin/webdriver-manager update
    ./node_modules/protractor/bin/webdriver-manager start
    

    Let us tidy up things a bit. We will include these commands in package.json file under scripts section so that we can shorten our commands.

    Given below is the current state of package.json

    {
        "scripts": {
            "start": "./node_modules/http-server/bin/http-server",
            "update-driver": "./node_modules/protractor/bin/webdriver-manager update",
            "start-driver": "./node_modules/protractor/bin/webdriver-manager start",
            "test": "./node_modules/protractor/bin/protractor conf.js"
        },
        "dependencies": {
            "http-server": "^0.10.0",
            "protractor": "^5.1.2"
        }
    }
    
    

    The package.json file currently holds our dependencies and scripts. It contains command for starting development server, updating webdriver and starting webdriver (mentioned just before this) and command to run test.

    Next we need to include a configuration file for protractor. The configuration file should contain the test framework to be used, the address at which selenium is running and path to specs file.

    // conf.js
    exports.config = {
        framework: "jasmine",
        seleniumAddress: "http://localhost:4444/wd/hub",
        specs: ["tests/home-spec.js"]
    };
    
    

    We have set the framework as jasmine and selenium address as http://localhost:4444/wd/hub. Next we need to define our actual file. But before writing tests we need to find out what are the things that we need to test. We will mostly be testing dynamic content loaded by Javascript files. Let us define a spec. A spec is a collection of tests. We will start by testing the category name. Initially when the page loads it should be equal to All apps. Next we test the top right hand side menu which is loaded by javascript using topmenu.json file.

    it("should have a category name", function() {
        expect(element(by.id("categoryName")).getText()).toEqual("All apps");
      });
    
      it("should have top menu", function() {
        let list = element.all(by.css(".topmenu li a"));
        expect(list.count()).toBe(5);
      });
    
    

    As mentioned earlier, we are using jasmine framework for writing our specs. In the above code snippet ‘it’ describes a particular test. It takes a test description and a callback function thereby providing a very efficient way to document our tests white write the test code itself. In the first test we use expect function to check whether the category name is equal to All apps or not. Here we select the div containing the category name by its id.

    Next we write a test for top menu. There should be five menu options in total for the top menu. We select all the list items that are supposed to contain the top menu items and check whether the number of such items are five or not using expect function. As it can be seen from the snippet, the process of selecting a node is almost similar to that of Jquery library.

    Next we test the left hand side category list. This list is loaded by AngularJS controller from apps,json file. We should make sure the list is loaded properly and all the options are present.

    it("should have a category list", function() {
        let categoryIds = ["All", "Scraper", "Search", "Visualizer", "LoklakLibraries", "InternetOfThings", "Misc"];
        let categoryNames = ["All", "Scraper", "Search", "Visualizer", "Loklak Libraries", "Internet Of Things", "Misc"];
    
        expect(element(by.css("#catTitle")).getText()).toBe("Categories");
    
        let categoryList = element.all(by.css(".category-main"));
        expect(categoryList.count()).toBe(7);
    
        categoryIds.forEach(function(id, index) {
          element(by.css("#" + id)).isPresent().then(function(present) {
            expect(present).toBe(true);
          });
    
          element(by.css("#" + id)).getText().then(function(text) {
            expect(text).toBe(categoryNames[index]);
          });
        });
      });
    
    

    At first we maintain two lists of category id and category names. We begin by confirming that Category title is equal to Categories. Next we get the list of categories and iterate over them, For each category we check whether the corresponding id is present in the DOM or not. After confirming this, we match the names of the categories with the expected names. Elements.all function allows us to get a list of selected nodes.

    Finally we check the click functionality of the left side menu. Expected behaviour is, on clicking a menu item, the category name should get replaced with the selected category name. For this we need to simulate the click event. Protractor allows us to do it very easily using click function.

    it("category list should respond to click", function() {
        let categoryIds = ["All", "Scraper", "Search", "Visualizer", "LoklakLibraries", "InternetOfThings", "Misc"];
        let categoryNames = ["All apps", "Scraper", "Search", "Visualizer", "Loklak Libraries", "Internet Of Things", "Misc"];
    
        categoryIds.forEach(function(id, index) {
          element(by.id(categoryIds[index])).click().then(function() {
            browser.getCurrentUrl().then(function(url) {
              expect(url).toBe("http://127.0.0.1:8080/#/" + categoryIds[index]);
            });
            element(by.id("categoryName")).getText().then(function(text) {
              expect(text).toBe(categoryNames[index]);
            });
          });
        });
      });
    
    
    
    

    Once again we maintain two lists, category id and category names. We obtain the present list of categories and iterate over them. For each category link we simulate a click event. For each click event we check two values. We check the new browser URL which should now contain the category id. Next we check the value of category name. It should be equal to the category selected.
    FInally after all the tests are over we get the final report on our terminal.
    In order to run the tests, use the following command.

    npm test
    

    This will start executing the tests.

    Important resources

    Keeping Order of tickets in Event Wizard in Sync with API on Open Event Frontend

    This blog article will illustrate how the various tickets are stored and displayed in order the event organiser decides  on  Open Event Frontend and also, how they are kept in sync with the backend.

    First we will take a look at how the user is able to control the order of the tickets using the ticket widget.

    {{#each tickets as |ticket index|}}
      {{widgets/forms/ticket-input ticket=ticket
      timezone=data.event.timezone
      canMoveUp=(not-eq index 0)
      canMoveDown=(not-eq ticket.position (dec
      data.event.tickets.length))
      moveTicketUp=(action 'moveTicket' ticket 'up')
      moveTicketDown=(action 'moveTicket' ticket 'down')
      removeTicket=(confirm 'Are you sure you  wish to delete this 
      ticket ?' (action 'removeTicket' ticket))}}
    {{/each}}
    

    The canMoveUp and canMoveDown are dynamic properties and are dependent upon the current positions of the tickets in the tickets array.  These properties define whether the up or down arraow or both should be visible alongside the ticket to trigger the moveTicket action.

    There is an attribute called position in the ticket model which is responsible for storing the position of the ticket on the backend. Hence it is necessary that the list of the ticket available should always be ordered by position. However, it should be kept in mind, that even if the position attribute of the tickers is changed, it will not actually change the indices of the ticket records in the array fetched from the API. And since we want the ticker order in sync with the backend, i.e. user shouldn’t have to refresh to see the changes in ticket order, we are going to return the tickets via a computed function which sorts them in the required order.

    tickets: computed('data.event.tickets.@each.isDeleted', 'data.event.tickets.@each.position', function() {
       return this.get('data.event.tickets').sortBy('position').filterBy('isDeleted', false);
     })
    

    The sortBy method ensures that the tickets are always ordered and this computed property thus watches the position of each of the tickets to look out for any changes. Now we can finally define the moveTicket action to enable modification of position for tickets.

    moveTicket(ticket, direction) {
         const index = ticket.get('position');
         const otherTicket = this.get('data.event.tickets').find(otherTicket => otherTicket.get('position') === (direction === 'up' ? (index - 1) : (index + 1)));
         otherTicket.set('position', index);
         ticket.set('position', direction === 'up' ? (index - 1) : (index + 1));
       }
    

    The moveTicket action takes two arguments, ticket and direction. It temporarily stores the position of the current ticket and the position of the ticket which needs to be swapped with the current ticket.Based on the direction the positions are swapped. Since the position of each of the tickets is being watched by the tickets computed array, the change in order becomes apparent immediately.

    Now when the User will trigger the save request, the positions of each of the tickets will be updated via a PATCH or POST (if the ticket is new) request.

    Also, the positions of all the tickets maybe affected while adding a new ticket or deleting an existing one. In case of a new ticket, the position of the new ticket should be initialised while creating it and it should be below all the other tickets.

    addTicket(type, position) {
         const salesStartDateTime = moment();
         const salesEndDateTime = this.get('data.event.startsAt');
         this.get('data.event.tickets').pushObject(this.store.createRecord('ticket', {
           type,
           position,
           salesStartsAt : salesStartDateTime,
           salesEndsAt   : salesEndDateTime
         }));
       }
    

    Deleting a ticket requires updating positions of all the tickets below the deleted ticket. All of the positions need to be shifted one place up.

    removeTicket(deleteTicket) {
         const index = deleteTicket.get('position');
         this.get('data.event.tickets').forEach(ticket => {
           if (ticket.get('position') > index) {
             ticket.set('position', ticket.get('position') - 1);
           }
         });
         deleteTicket.deleteRecord();
       }
    

    The tickets whose position is to be updated are filtered by comparison of their position from the position of the deleted ticket.

    Resources

    Implementing Order Statistics API on Tickets Route in Open Event Frontend

    The order statistics API endpoints are used to display the statistics related to tickets, orders, and sales. It contains the details about the total number of orders, the total number of tickets sold and the amount of the sales. It also gives the detailed information about the pending, expired, placed and completed orders, tickets, and sales.

    This article will illustrate how the order statistics can be displayed using the Order Statistics API in Open Event Frontend. The primary end point of Open Event API with which we are concerned with for statistics is

    GET /v1/events/{event_identifier}/order-statistics
    

    First, we need to create a model for the order statistics, which will have the fields corresponding to the API, so we proceed with the ember CLI command:

    ember g model order-statistics-tickets
    

    Next, we need to define the model according to the requirements. The model needs to extend the base model class. The code for the model looks like this:

    import attr from 'ember-data/attr';
    import ModelBase from 'open-event-frontend/models/base';
    
    export default ModelBase.extend({
      orders  : attr(),
      tickets : attr(),
      sales   : attr()
    });
    

    As we need to display the statistics related to orders, tickets, and sales so we have their respective variables inside the model which will fetch and store the details from the API.

    Now, after creating a model, we need to make an API call to get the details. This can be done using the following:

    return this.modelFor('events.view').query('orderStatistics', {});
    

    Since the tickets route is nested inside the event.view route so, first we are getting the model for event.view route and then we’re querying order statistics from the model.

    The complete code can be seen here.

    Now, we need to call the model inside the template file to display the details. To fetch the total orders we can write like this

    {{model.orders.total}}
    

     

    In a similar way, the total sales can be displayed like this.

    {{model.sales.total}}
    

     

    And total tickets can be displayed like this

    {{model.tickets.total}}
    

     

    If we want to fetch other details like the pending sales or completed orders then the only thing we need to replace is the total attribute. In place of total, we can add any other attribute depending on the requirement. The complete code of the template can be seen here.

    The UI for the order statistics on the tickets route looks like this.

    Fig. 1: The user interface for displaying the statistics

    The complete source code can be seen here.

    Resources:

    Implementing Pages API in Open Event Frontend

    The pages endpoints are used to create static pages which such as about page or any other page that doesn’t need to be updated frequently and only a specific content is to be shown. This article will illustrate how the pages can be added or removed from the /admin/content/pages route using the pages API in Open Event Frontend. The primary end point of Open Event API with which we are concerned with for pages is

    GET /v1/pages
    

    First, we need to create a model for the pages, which will have the fields corresponding to the API, so we proceed with the ember CLI command:

    ember g model page
    

    Next, we need to define the model according to the requirements. The model needs to extend the base model class. The code for the page model looks like this:

    import attr from 'ember-data/attr';
    import ModelBase from 'open-event-frontend/models/base';
    
    export default ModelBase.extend({
      name        : attr('string'),
      title       : attr('string'),
      url         : attr('string'),
      description : attr('string'),
      language    : attr('string'),
      index       : attr('number', { defaultValue: 0 }),
      place       : attr('string')
    });
    

    As the page will have name, title, url which will tell the URL of the page, the language, the description, index and the place of the page where it has to be which can be either a footer or an event.

    The complete code for the model can be seen here.

    Now, after creating a model, we need to make an API call to get and post the pages created. This can be done using the following:

    return this.get('store').findAll('page');
    

    The above line will check the store and find all the pages which have been cached in and if there is no record found then it will make an API call and cache the records in the store so that when called it can return it immediately.

    Since in the case of pages we have multiple options like creating a new page, updating a new page, deleting an existing page etc. For creating and updating the page we have a form which has the fields required by the API to create the page.  The UI of the form looks like this.

    Fig. 1: The user interface of the form used to create the page.

    Fig. 2: The user interface of the form used to update and delete the already existing page

    The code for the above form can be seen here.

    Now, if we click the items which are present in the sidebar on the left, it enables us to edit and update the page by displaying the information stored in the form and then the details be later updated on the server by clicking the Update button. If we want to delete the form we can do so using the delete button which first shows a pop up to confirm whether we actually want to delete it or not. The code for displaying the delete confirmation pop up looks like this.

    <button class="ui red button" 
    {{action (confirm (t 'Are you sure you would like to delete this page?') (action 'deletePage' data))}}>
    {{t 'Delete'}}</button>
    

     

    The code to delete the page looks like this

    deletePage(data) {
        if (!this.get('isCreate')) {
          data.destroyRecord();
          this.set('isFormOpen', false);
        }
      }
    

    In the above piece of code, we’re checking whether the form is in create mode or update mode and if it’s in create mode then we can destroy the record and then close the form.

    The UI for the pop up looks like this.

    Fig.3: The user interface for delete confirmation pop up

    The code for the entire process of page creation to deletion can be checked here

    To conclude, this is how we efficiently do the process of page creation, updating and deletion using the Open-Event-Orga pages API  ensuring that there is no unnecessary API call to fetch the data and no code duplication.

    Resources: