Make an Endlessly Scrolling Card

If you need to display a large number of items in a Card, it may be useful to implement an endlessly scrolling list. This way only a set number of items will be loaded, then once the user scrolls to the bottom of the list, the same number of new items will be loaded and appended, repeating until there are no more items to display.

In this guide, we will cover an example Card with an endlessly scrolling list of newly hired employees, using some test data provided by an external API. This will require firstly creating a custom Connector to fetch and paginate the items, and then the Card which displays the items provided by the Connector.

Create the Connector Template

Navigate to Development → Manage Spaces → Global → Connectors. Select Create Folder. In the Template drop-down, choose API Connector (custom authentication), give the Connector a unique name such as employee-list-connector, then press Create.

The connector template creation screen

Now we need to define the Connector template. Firstly, enter the folder you just created, find _definition.yaml and press Edit. We need to change two of the values in here, ConnectorTitle and ApiEndpoint, to the following:

ConnectorTitle: Employee List (Test Data)
ApiEndPoint: https://randomuser.me/api

ConnectorTitle defines the name that will appear when selecting the Connector type for your Now Card later on. ApiEndpoint is the address we will be polling to provide the data, in this case a free API which provides random user data for testing purposes.

The remaining values can be left as-is. Save and return to the folder.

Define a custom service

Next, we need to add a custom service for our Connector so that we only retrieve the data we want from the API, first we will add the template for this, and later, the logic itself.

Select New File and create a YAML file called _service.employee_search. No template is needed. Press Create and once you are back in the folder, find the file and select Edit.

Place the following definitions into the file and save:

ServiceName: employee
InterfaceName: items
Description: employee search
APIDoc: ''
Interfaces: 'search,pagination'
RequiredScopes: ''
AdditionalParameters: 
Method: ''

Here, ServiceName defines the name of the service's implementation file which we will create later, and InterfaceName defines the name used to pass API data into the Card itself.

Create the Connector

With the template complete, we should now create the Connector that uses it. Navigate to Content Manager → Service Connectors and select Create New Connector. Give the Connector a unique name and title, select our Employee List (Test Data) template from the Connector Type drop-down, and save.

The connector creation screen

Add custom service implementation

With the template in place and the Connector itself configured, we now need to implement the custom service logic so that our Connector becomes functional.

Navigate back to Development → Manage Spaces → Global → Connectors → employee-list-connector. Create a new file of type JavaScript called employee - the service name we defined in _service.employee_search.yaml earlier.

The purpose of this file is to make API requests and then pass the response data to the Component inside activity.Response.Data. In here we will customise the requests and strip irrelevant information from, or restructure, the response data.

If you want to gain more of an understanding about how the API itself works, take a look in the api.js file in the Connector's folder.

We will break down the construction of our service's implementation to better understand how it functions. To begin with, click Edit on your employee.js file and paste the following into the editor:

const api = require('./api');

module.exports = async function (activity) {

    try {

        api.initialize(activity);  

    } catch (error) {

        // Return error response
        var m = error.message;    
        if (error.stack) m = m + ": " + error.stack;

        activity.Response.ErrorCode = (error.response && error.response.statusCode) || 500;
        activity.Response.Data = { ErrorText: m };

    }

};

Here is the basic structure for our service file. First we import the API, then expose a function which takes an activity object from the Component. The API is initialised with this activity.

As we will later be adding an await statement when fetching the reponse, we need to surround our logic in a try / catch block to handle any errors that occur and report these back to the Component.

We will start with the construction of a request. Add the new lines from the updated file below so that yours matches.

const api = require('./api');

module.exports = async function (activity) {

    try {

        api.initialize(activity); 

        var action = "firstpage";

        // items: search
        let page = parseInt(activity.Request.Query.page) || 1;
        let pageSize = parseInt(activity.Request.Query.pageSize) || 20;

        // nextpage request
        if ((activity.Request.Data && activity.Request.Data.args && activity.Request.Data.args.atAgentAction == "nextpage")) {

            page = parseInt(activity.Request.Data.args._page) || 2;
            pageSize = parseInt(activity.Request.Data.args._pageSize) || 20;
            action = "nextpage";

        }

        if (page < 0) page = 1;
        if (pageSize < 1 || pageSize > 99) pageSize = 20;

        let url = "/?seed=adenin";
        url += "&page=" + page;
        url += "&results=" + pageSize;
        url += "&inc=name,email,location,picture"

        const response = await api(url);

    } catch (error) {

        // Return error response
        var m = error.message;    
        if (error.stack) m = m + ": " + error.stack;

        activity.Response.ErrorCode = (error.response && error.response.statusCode) || 500;
        activity.Response.Data = { ErrorText: m };

    }

};

To break down what is happening in these lines:

  1. Set the default action to display the first page of results.
  2. Parse the page number and page size from the request query, and set defaults if this fails.
  3. Check whether the activity is requesting the next page, and if so, parse the activity's incremented arguments, then update the action to "nextpage".
  4. Ensure no invalid values have been parsed and correct to defaults if they have.
  5. Construct the URL for the API request using a seed for the random data, attaching our determined queries (page number and size), and the types of data we'd like returned about each employee, then submit the request and capture the response.

Now that we have the response, we need to process the data to return it to the Component. Before we do this, we will create a helper function to convert the returned items into objects usable by our Component. Add the following function within our exported function but outside of the try / catch block.

function convert_item(_item) {
    var item = {};

    let id = _item.picture.large;
    id = id.substring(id.lastIndexOf("/") + 1); // extract id from image name
    item.id = id.substring(0, id.indexOf("."));

    item.title = _item.name.first + " " + _item.name.last;
    item.description = _item.email;
    item.picture = _item.picture.large;

    return item;
}

This function simply takes a raw item from the response, and parses the relevant data from it into a new item object to return.

Finally, we will add one more block of code to return the response data. The final employee.js file should look like this:

const api = require('./api');

module.exports = async function (activity) {

    try {

        api.initialize(activity); 

        var action = "firstpage";

        // items: search
        let page = parseInt(activity.Request.Query.page) || 1;
        let pageSize = parseInt(activity.Request.Query.pageSize) || 20;

        // nextpage request
        if ((activity.Request.Data && activity.Request.Data.args && activity.Request.Data.args.atAgentAction == "nextpage")) {

            page = parseInt(activity.Request.Data.args._page) || 2;
            pageSize = parseInt(activity.Request.Data.args._pageSize) || 20;
            action = "nextpage";

        }

        if (page < 0) page = 1;
        if (pageSize < 1 || pageSize > 99) pageSize = 20;

        let url = "/?seed=adenin";
        url += "&page=" + page;
        url += "&results=" + pageSize;
        url += "&inc=name,email,location,picture"

        const response = await api(url);

        if ( ( !response || response.statusCode != 200 ) ) {

            activity.Response.ErrorCode = response.statusCode || 500;
            activity.Response.Data = { ErrorText: "request failed" }; 

        } else {                                

            let items = response.body.results;

            activity.Response.Data._action = action;
            activity.Response.Data._page = page;
            activity.Response.Data._pageSize = pageSize;
            activity.Response.Data.items = [];

            for (let i = 0; i < items.length; i++) {

                let item = convert_item(items[i]);
                activity.Response.Data.items.push(item);

            }

        }

    } catch (error) {

        // Return error response
        var m = error.message;    
        if (error.stack) m = m + ": " + error.stack;

        activity.Response.ErrorCode = (error.response && error.response.statusCode) || 500;
        activity.Response.Data = { ErrorText: m };

    }

    function convert_item(_item) {

        var item = {};

        let id = _item.picture.large;
        id = id.substring(id.lastIndexOf("/") + 1); // extract id from image name
        item.id = id.substring(0, id.indexOf("."));

        item.title = _item.name.first + " " + _item.name.last;
        item.description = _item.email;
        item.picture = _item.picture.large;

        return item;

    }

};

The if / else block we added firstly checks that the API provided a successful response, setting error information if not. If the response was successful, we capture the items from it, attach the arguments that returned it to activity.Response.Data so that the activity is aware of where to increment from, then populate an array within activity.Response.Data by iterating through the raw items and converting them using the function we defined earlier.

Creating the Card

The final step is to design our Card. Navigate to Development → Manage Spaces → Global → Components. Select New Component, then set the Template type to card, Template to Basic Card (NodeJs v9 API proxy async), then give the Component a unique name such as new-hires. Under Service choose Service Connectors, set Connector to Employee List Connector, and Service to Employee List Connector: employee search (items) - the custom service we just implemented.

The Card creation screen

Once the Card is created, enter the Designer for its default.card file. Under the Elements tab, select and remove the content element, then add a Virtual list element from the Add Element tab. Set the value of itemComponent to new-hires/new-hires-item-card. This configures the list to use a custom Component item to render each element in the list, we will create this Component next.

Defining itemComponent

If you named your Card differently to new-hires, you will need to change both instances of new-hires in this line accordingly to your Card's unique name.

Exit the Designer, and within the Card's folder, create a new file of type card called item, and a template of List Item Card. This will define the component picked up by the virtual list in our main Card, used to display each item.

Creating a List Item Card

Enter the Designer for item.card and set the view markup for the Liquid view to the following:

<div class="layout-horizontal layout-center">
  <iron-image src="" style="width:60px; height:60px; background-color: lightgray;" sizing="cover" preload></iron-image>
  <div class="m">
    Make an Endlessly Scrolling Card
  </div>
</div>

This configures each list item to display the employee's name and photograph.

Test the Card

Navigate to Now Workplace → My Cards, find your New Hires card and pin it to the Workplace. Click the Workplace tab, and you should see your Card displaying a list of employee data. Scroll to the bottom of the list and watch as more items are loaded - this should repeat endlessly.

The completed endlessly-scrolling New Hires Card