Build a Custom Selector

Contents

This guide will lead you through the required steps to build an input of type Custom Selector.

What is a custom selector

Custom selector is a specific input type that get its items from a service.

Custom selector

The image above is an example of a custom selector that uses an API to populate the items to be selected.

The flow of execution is really simple, and is described by the following diagram:

Custom selector

To read more about the details of a custom selector, visit its documentation.

Strategy

Our goal in this tutorial will be to implement a custom selector that lists all countries in the world, and then use this custom selector in a new content type.

In order to do that we will:

  • Create a service that returns the list of all countries in the world

  • Create a new content type and add a custom selector input type that uses that service

  • Use an API to retrieve the data of all countries in the world

  • Use lib http client to consume that API and lib cache to avoid unecessary calls to the API

Create a service

Let’s start our work by creating a service.

  1. In order to do that create a folder countries inside src/main/resources/services and then create a countries.js inside the countries folder.

  2. Place the following code in countries.js

    exports.get = function (request) {
        return {
            status: 200,
            body: JSON.stringify({
                hits: [
                    {
                        id: 'norway',
                        displayName: 'Norway',
                        description: 'this is Norway'
                    },
                    {
                        id: 'usa',
                        displayName: 'United states of america',
                        description: 'this is USA'
                    },
                    {
                        id: 'france',
                        displayName: 'France',
                        description: 'this is France'
                    },
                    {
                        id: 'portugal',
                        displayName: 'Portugal',
                        description: 'this is Portugal'
                    },
                    {
                        id: 'iceland',
                        displayName: 'Iceland',
                        description: 'this is Iceland'
                    }
                ],
                count: 5,
                total: 5
            }),
            contentType: 'application/json'
        };
     }

    This service basically returns a hardcoded data structure with some country items. We’ll of course improve this service later, but for now this is enough to get our understanding on how the service will provide data to our custom selector.

Create a content type

Now that we have our simple countries service, let’s use it in a specific content type.

  1. Create a folder named person inside the site/content-types folder of your project.

  2. In that folder create a configuration schema named person.xml for the new content type.

    <?xml version="1.0" encoding="UTF-8"?>
    <content-type>
      <display-name>Person</display-name>
      <display-name-expression>${firstName} ${lastName}</display-name-expression>
      <description>A minimal Person content type</description>
      <super-type>base:structured</super-type>
      <form>
        <input type="TextLine" name="firstName">
            <label>First name</label>
            <occurrences minimum="1" maximum="1"/>
        </input>
        <input type="TextLine" name="lastName">
            <label>Last name</label>
            <occurrences minimum="1" maximum="1"/>
        </input>
        <input type="CustomSelector" name="country">
          <label>Country</label>
          <occurrences minimum="1" maximum="1"/>
          <config>
            <service>countries</service>
          </config>
        </input>
      </form>
    </content-type>
  3. Now let’s test it out…​ you should get a selector with the items that you defined in the previously created countries service:

    Custom selector exaple 1

You can also refer to a service file in another application (for example, com.myapplication.app:myservice) instead of adding one to your application.
 <config>
    <service>com.myapplication.app:my-custom-selector-service</service>
 </config>

Request / Response format

Before we start properly coding our service, let’s understand how our custom selector and our service interacts with each other.

Custom selector will request data from our service from a HTTP get request with some query params:

ids

Array of item ids already selected in the CustomSelector. The service is expected to return the items with the specified ids.

start

Index of the first item expected. Used for pagination of the results.

count

Maximum number of items expected. Used for pagination of the results.

query

String with the search text typed by the user in the CustomSelector input field.

It is the developer responsability to properly use those params in its favor to come up with performant and complex logic in order to retrieve the items from the service. For instance, pagination can be achieved based on start and count paramaters.

On the other hand, our service needs to return a specific JSON data structre in order to be able to communicate with our custom selector:

Sample JSON response:
{
  "hits": [ (1)
    {
      "id": 1, (2)
      "displayName": "Option number 1", (3)
      "description": "External SVG file is used as icon", (4)
      "iconUrl": "\/admin\/portal\/edit\/draft\/_\/asset\/com.enonic.app.features:1524061998\/images\/number_1.svg" (5)
    },
    {
      "id": 2,
      "displayName": "Option number 2",
      "description": "Inline SVG markup is used as icon",
      "icon": { (6)
        "data": "<svg version=\"1.1\" xmlns=\"http:\/\/www.w3.org\/2000\/svg\" width=\"32\" height=\"32\" viewBox=\"0 0 32 32\"><path fill=\"#000\" d=\"M16 3c-7.18 0-13 5.82-13 13s5.82 13 13 13 13-5.82 13-13-5.82-13-13-13zM16 27c-6.075 0-11-4.925-11-11s4.925-11 11-11 11 4.925 11 11-4.925 11-11 11zM17.564 17.777c0.607-0.556 1.027-0.982 1.26-1.278 0.351-0.447 0.607-0.875 0.77-1.282 0.161-0.408 0.242-0.838 0.242-1.289 0-0.793-0.283-1.457-0.848-1.99s-1.342-0.8-2.331-0.8c-0.902 0-1.654 0.23-2.256 0.69s-0.96 1.218-1.073 2.275l1.914 0.191c0.036-0.56 0.173-0.96 0.41-1.201s0.555-0.361 0.956-0.361c0.405 0 0.723 0.115 0.952 0.345 0.23 0.23 0.346 0.56 0.346 0.988 0 0.387-0.133 0.779-0.396 1.176-0.195 0.287-0.727 0.834-1.592 1.64-1.076 0.998-1.796 1.799-2.16 2.403s-0.584 1.242-0.656 1.917h6.734v-1.781h-3.819c0.101-0.173 0.231-0.351 0.394-0.534 0.16-0.183 0.545-0.552 1.153-1.109z\"><\/path><\/svg>",
        "type": "image\/svg+xml"
      }
    }
  ],
  "count": 2, (7)
  "total": 2 (8)
}
1 Array of item objects
2 Unique Id of the option
3 Option title
4 Detailed description
5 Path to the thumbnail image file
6 Inline image content (for example, SVG)
7 Number of items in response
8 Total number of items

hits is an object containing array of items that will be listed in the selector’s dropdown. In this last example we’re using the optional icon and iconUrl to provide icons to the items.

Integration with CountriesNow API

Our goal now is to improve our current countries service. We’ll do that by:

  • Consuming an API endpoint that will retrieve the list of all countries in the world

  • Use cache to avoid unecessary requests to that API

  • Filter items based on query parameter in the GET request that comes from our custom selector.

  • Format that data to the proper data structure seen in the previous section

  1. Start by installing the necessary dependencies:

        include 'com.enonic.lib:lib-http-client:3.2.1'
        include 'com.enonic.lib:lib-cache:2.2.0'
  2. Then replace countries.js service controller with the updated version of it:

    const httpClient = require('/lib/http-client');
    const cacheLib = require('/lib/cache');
    
    const CACHE = cacheLib.newCache({ size: 1, expire: 3600 });
    const CACHE_KEY = 'COUNTRIES_API_RESPONSE';
    const API_URL = "https://countriesnow.space/api/v0.1/countries/iso";
    
    exports.get = function (request) {
        const query = request.params.query;
        const cacheData = CACHE.getIfPresent(CACHE_KEY);
        let status, data, error;
     
        if(cacheData) {
            status = 200;
            data = processApiResponse(cacheData, query);
        } else {
            try {
                status = 200;
                data = processApiResponse(requestApiData(), query);
            } catch(err) {
                status = 500;
                error = err.toString();
            }
        }
       
        let body;
        if (status === 200) body = JSON.stringify(data);
        if (status === 500) body = JSON.stringify({ error });
        
        return { status, body, contentType: 'application/json' };
    }
    
    function requestApiData() {
        const response = httpClient.request({
            url: API_URL,
            method: 'GET',
            contentType: 'application/json'
        });
    
        if(response.body.error) throw new Error('Error calling countriesnow API.');
    
        const responseBody = JSON.parse(response.body);
    
        CACHE.put(CACHE_KEY, responseBody);
    
        return responseBody;
    }
    
    function processApiResponse(responseBody, query = '') {
        let countries = [];
        responseBody.data.forEach((d) => countries = countries.concat(d.name));
    
        const hits = countries
            .filter(country => query ? country.toLowerCase().indexOf(query.toLowerCase()) > -1 : true)
            .map(country => ({ id: country, displayName: country, description: ' ' }));
    
        return { hits, count: hits.length, total: hits.length };
    }

Here are some notes on this new service controller code:

  • requestApiData will get consume the API and store the returned JSON on cache.

  • processApiResponse is a pure function that will get the response from the API and return a specific data structure that is the one expected from our custom selector when it is consuming our service.

  • It is important to make sure that our service, on success, will always returns that data structure, otherwise our custom selector will not work.

  • In this example we’ve opted to only use the query parameter that comes from the GET request triggered by our custom selector

  • If we have a response from the API stored on the cache, we’ll get it, process based on the provided query param and return the json as a response.

  • If we don’t have it stored on the cache, we’ll get it from the API, and then do the same thing.

Here’s a diagram that summarizes the flow between our custom selector, service, API and cache:

Custom selector and service flow

And this is the final result:

Custom selector example 2

Customizing with Guilliotine Lib (optional)

This is an optional part of the tutorial, and demonstrates how you may extend the GraphQL API to include additional info from the CountriesNow API.

Strategy

Our goal will be to come up with a custom API endpoint (using Guillotine library), in which we’ll extend the data we have under our Person content type. This guillotine extension will provide the list of all cities of the selected country that used our previously created custom selector.

This may be beneficial, as your front-end will be able to avoid direct integrations with the API.

Setting up a custom API

Lets get the new API endpoint setup first:

  1. Add the following line to the dependencies{…​} section of your build file:

    build.gradle
        include "com.enonic.lib:lib-guillotine:6.0.5"
  2. Add a controller to your project:

    resources/controllers/customapi.js
      const guillotineLib = require('/lib/guillotine');
      const graphQlLib = require('/lib/graphql');
    
      var schema = guillotineLib.createSchema();
    
      exports.post = function (req) {
        const body = JSON.parse(req.body);
        const result = graphQlLib.execute(schema, body.query, body.variables);
        return {
            contentType: 'application/json',
            body: JSON.stringify(result)
        };
      };
  3. Finally, update site.xml with a mapping to expose the controller as an endpoint

    resources/site/site.xml
      <mappings>
        <mapping controller="/controllers/customapi.js" order="50">
          <pattern>/customapi</pattern>
        </mapping>
      </mappings>

After deploying the code, we should be able to access our endpoint. If for instance you used the hmdb-app as your project, the API will now be available here:

http://localhost:8080/site/hmdb/draft/hmdb/myapi

Visiting with your browser should give a 405 error, as GraphQL uses the HTTP POST method.

Your controller can also implement a GET handler if desired.

Adding a new Type

With the custom API deployed, we can now extend it with a new GraphQL type, that will represent our country details, in which we’ll include all cities of the selected country.

In order to customize the API, you can use creationCallbacks.

The full controller code is available at the end of this section
  1. This is how you can define the type of the data you’ll extend>

const context = guillotineLib.createContext();

context.types.countryDetails = context.schemaGenerator.createObjectType({
    name: 'CountryDetails',
    fields: {
        country: {
            type: graphQlLib.GraphQLString,
        },
        cities: {
            type: graphQlLib.list(graphQlLib.GraphQLString),
        }
    }
});

Extending

  1. Once the new type is registered you may use it in the creationCallbacks:

context.options.creationCallbacks = {
    'com_example_myproject_Person_Data': (ctx, params) => {
        params.fields.countryDetails = {
            type: context.types.countryDetails,
            resolve: (env) => {
                const countryName = env.source.country || '';

                if (!countryName) {
                    return {};
                }

                return {
                    country: countryName,
                    cities: requestApiData(countryName)
                }
            }
        };
    },
};
The com_example_myproject_Person_Data must match the GraphQL type you want to override.
  1. The final trick is to create the GraphQL schema. More details on extending schemas.

function createSchema() {
    return context.schemaGenerator.createSchema({
        query: createRootQueryType(context),
        dictionary: context.dictionary
    });
}

Final controller code

  1. Try it all in your project by replacing your existing customapi controller with the following code:

resources/controllers/customapi.js
const httpClient = require('/lib/http-client');
const guillotineLib = require('/lib/guillotine');
const graphQlLib = require('/lib/graphql');

const API_URL = "https://countriesnow.space/api/v0.1/countries/cities/q";

const context = guillotineLib.createContext();

context.types.countryDetails = context.schemaGenerator.createObjectType({
    name: 'CountryDetails',
    fields: {
        country: {
            type: graphQlLib.GraphQLString,
        },
        cities: {
            type: graphQlLib.list(graphQlLib.GraphQLString),
        }
    }
});

context.options.creationCallbacks = {
    'com_example_myproject_Person_Data': (ctx, params) => {
        params.fields.countryDetails = {
            type: context.types.countryDetails,
            resolve: (env) => {
                const countryName = env.source.country || '';

                if (!countryName) {
                    return {};
                }

                return {
                    country: countryName,
                    cities: requestApiData(countryName)
                }
            }
        };
    },
};

const schema = createSchema();

exports.post = function (req) {
    const body = JSON.parse(req.body);
    const result = graphQlLib.execute(schema, body.query, body.variables);
    return {
        contentType: 'application/json',
        body: JSON.stringify(result)
    };
};

//

function createSchema() {
    return context.schemaGenerator.createSchema({
        query: createRootQueryType(context),
        dictionary: context.dictionary
    });
}

function createRootQueryType(context) {
    return context.schemaGenerator.createObjectType({
        name: 'Query',
        fields: {
            guillotine: {
                type: guillotineLib.createHeadlessCmsType(context),
                resolve: function () {
                    return {};
                }
            }
        }
    });
}

//

function requestApiData(countryName = '') {
    const response = httpClient.request({
        url: API_URL,
        method: 'GET',
        contentType: 'application/json',
        queryParams: { country: countryName }
    });

    if (response.body.error) throw new Error('Error calling countriesnow API.');

    return JSON.parse(response.body).data;
}

Manual testing

Let’s finally consume our custom endpoint to check if everything is working as expected.

Person data on query playground

In this case we’ve ran the following query:

query {
  guillotine {
    get(key: "/mysite/bruno-reis") {
      ... on com_example_myproject_Person {
        data {
            firstName
            lastName
            country
        }
      }
    }
  }
}

in content studio query playground just to demonstrate the return of the data.

If you don’t see Query playground in the Content Studio menu, it has probably not been installed yet. Simply install the Guillotine application, and it will appear.

Queries executed on query playground will run in an endpoint that is different from the custom one we created, and therefore there is will be no countryDetails field in Person’s content type data field:

countryDetails fields missing on query playground

Now let’s execute a POST request to our custom endpoint, requesting not only the fields from our content type, but also the field we extended:

query {
  guillotine {
    get(key: "/mysite/bruno-reis") {
      ... on com_example_myproject_Person {
        data {
            firstName
            country
            countryDetails {
                cities
            }
        }
      }
    }
  }
}

and here’s our response

Person data including countryDetails extension

In this last image we’ve used chrome’s extension Altair GraphQL Client to ran the query in a different url and better visualize the results.

Need help? Ask questions on our forum visit our community Slack.

Contents

Contents