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 type of selector that gets its dataset from a service.

Custom selector

The image above is an example of a Custom Selector that uses an external API to populate the items to be selected.

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

Custom selector

To read more about specifics of the CustomSelector input type, visit its documentation.

Implementation

This tutorial assumes that you have a basic understanding of Enonic XP concepts, such as applications, content types, and services. It also assumes that you have an existing XP project to add a Custom Selector to. If you are new to Enonic XP, we recommend that you first read the Introduction to Enonic. If you don’t have an existing application, we recommend you check out the TLDR section in the end of this tutorial.

Our goal in this tutorial will be to implement a Custom Selector that lists all countries in the world, and then use it 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 Custom Selector input type which uses this service

  • Create a new content type which contains this Custom Selector

  • Our service will fetch data from an external API. We will use lib http client to consume that API and lib cache to avoid unnecessary 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 of 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>

Now let’s test it out…​

  1. Create a site in Content Studio

  2. Assign the application that contains the 'Person' content type to the site and Save

  3. Inside the site create a new content of type 'Person'

  4. Inside the content edit form you should see 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 interact with each other.

Custom Selector will request data from the service from a HTTP GET request with some query params:

ids

Array of item ids already selected in the Custom Selector. 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’s responsibility to properly use those params to ensure performant and complex logic in order to retrieve the items from the service. For instance, pagination can be achieved based on start and count parameters.

On the other hand, our service needs to return a specific JSON data structure 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 for the items in the response.

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 unnecessary requests to this API

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

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

    1. Start by installing the necessary dependencies:

          include 'com.enonic.lib:lib-http-client:3.2.2'
          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_BASE_URL = 'https://countriesnow.space/api/v0.1/countries';
      const API_COUNTRIES_URL = `${API_BASE_URL}/iso`;
      const API_CITIES_URL = `${API_BASE_URL}/cities/q`;
      
      exports.get = function (request) {
          const query = request.params.query;
          let status = 200, data, error;
      
          try {
              data = processApiResponse(fetchCountryList(), query);
          } catch(err) {
              status = 500;
              error = err.toString();
          }
      
          const body = JSON.stringify(status === 200 ? data : { error });
      
          return {
              status,
              body,
              contentType: 'application/json'
          };
      }
      
      const fetchCountryList = () => {
          const cacheData = CACHE.getIfPresent(CACHE_KEY);
          if (cacheData) {
              return cacheData;
          }
      
          const response = httpClient.request({
              url: API_COUNTRIES_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;
      }
      
      exports.fetchCityList = (countryName) => {
          const CITY_CACHE_KEY = `${CACHE_KEY}_${countryName.toUpperCase()}`;
          const cacheData = CACHE.getIfPresent(CITY_CACHE_KEY);
          if (cacheData) {
              return cacheData;
          }
      
          const response = httpClient.request({
              url: API_CITIES_URL,
              method: 'GET',
              contentType: 'application/json',
              queryParams: { country: countryName }
          });
      
          if (response.body.error){
              throw new Error('Error calling countriesnow API.');
          }
      
          const responseBody = JSON.parse(response.body);
      
          CACHE.put(CITY_CACHE_KEY, responseBody.data);
      
          return responseBody.data;
      
      }
      
      const 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:

  • fetchCountryList will consume the API to fetch the list of all countries and store the returned JSON on cache.

  • fetchCityList will do the same for fetching the list of all cities of a specific country. This will be used later on in the tutorial.

  • processApiResponse is a function that processes response from fetchCountryList and returns a specific data structure expected by Custom Selector when it consumes our service.

It is important to make sure that our service always returns data in this specific format, 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 already stored in the cache, we’ll retrieve it, process it based on the provided query param and return the JSON as a response, skipping the API call.

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

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

Integration with GraphQL API (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.

The Headless API provides access to all the content within your project. It is based on the GraphQL query language, which is a powerful and flexible way to interact with your data.

Want to know more about GraphQL in general? Visit the official GraphQL documentation.

In Enonic XP the Headless API is provided by the Guillotine application. If you have followed our TLDR guide, you should already have Guillotine installed in your XP instance. If not, install it from the Enonic Market.

Click the GraphQL icon in the Content Studio’s left hand menu to open Query Playground. Here you may test and play with the GraphQL API directly.

GraphQL schema

Once installed, Guillotine provides a default schema that includes all the content types and their fields. This schema is automatically generated based on the content types you have in your project.

Let’s say you have created a site called my-site and a person (content of Person content type) in that site called "John Doe".

Person form

You can query it from the Query Playground using the following GraphQL query:

{
  guillotine {
    get(key: "/my-site/john-doe") {
      ... on com_example_myproject_Person {
        data {
          firstName
          lastName
          country
        }
      }
    }
  }
}

Person query

Schema extension

But what if we want to add fields to the response which are not a part of the content type? For example, we want to add a countryDetails field to the Person content type, which will include the country’s name and a list of its cities.

Extending a Guillotine schema can easily be done by adding a file called guillotine.js to the src/main/resources/guillotine folder of your project. This file should contain a special exports.extensions function which implements new types, new fields and resolvers of the field values.

Need help extending your Guillotine schema? Find more details in the official documentation.

Here’s an example of how to extend the Person content type with a countryDetails field containing the country’s name and a list of its cities:

const service = require('/services/countries/countries');

const appTypePrefix = app.name.replace(/\./g, '_') + '_';
const PERSON_DATA_TYPE = `${appTypePrefix}Person_Data`;
const COUNTRY_DETAILS_TYPE = 'CountryDetails';
const COUNTRY_DETAILS_FIELD = 'countryDetails';

exports.extensions = (graphQL) => {
    return {
        types: {
            [COUNTRY_DETAILS_TYPE]: {
                description: COUNTRY_DETAILS_TYPE,
                fields: {
                    country: {
                        type: graphQL.GraphQLString,
                    },
                    cities: {
                        type: graphQL.list(graphQL.GraphQLString),
                    }
                }
            }
        },
        creationCallbacks: {
            [PERSON_DATA_TYPE]: (params) => {
                params.addFields({
                    [COUNTRY_DETAILS_FIELD]: {
                        type: graphQL.reference(COUNTRY_DETAILS_TYPE),
                    },
                });
            }
        },
        resolvers: {
            [PERSON_DATA_TYPE]: {
                [COUNTRY_DETAILS_FIELD]: (env) => {
                    const countryName = env.source.country || '';

                    if (!countryName) {
                        return null;
                    }
                    return {
                        country: countryName,
                        cities: service.fetchCityList(countryName)
                    };
                }
            }
        },
    }
};

Assuming we still have our "John Doe" person with a country selected, let’s add the new fields to our GraphQL query in the Query Playground:

{
  guillotine {
    get(key: "/my-site/john-doe") {
      ... on com_example_myproject_Person {
        data {
          firstName
          lastName
          country
          countryDetails {
            country
            cities
          }
        }
      }
    }
  }
}

and here’s our response:

Person data including countryDetails extension

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

TLDR;

If you want to skip the implementation details and just see the final result, you can download the source code of the project using Enonic CLI.

  1. Create a new project using Enonic CLI based on the guide-custom-selector starter:

enonic create com.example.myproject -r guide-custom-selector
If you already have a sandbox, you can instantly link the new project to it by providing -s parameter:
enonic create com.example.myproject -r guide-custom-selector -s <sandbox-name>
  1. If you don’t have a sandbox yet, answer "Yes" when asked if you want to create a sandbox for the project, and select Essentials when asked about the sandbox template. This will install Content Studio and Guillotine applications, needed for this tutorial.

  2. Give the new sandbox any name and agree to using the latest version of Enonic XP.

  3. Go inside the project folder and deploy it to your Enonic XP instance:

    cd myproject
    enonic project deploy
  4. Open http://localhost:8080 in your browser (or just click the link).

  5. Log in as Guest and open Content Studio

  6. Create a new site, assign the application called "Guide to custom selector" to the site and Save.

  7. Create one or several content items of type "Person" inside the site. You should see a Custom Selector with the list of countries inside the dropdown.

  8. Use the Query Playground (GraphQL icon in the menu on the left) to query the person’s country details, including the country’s name and a list of its cities.


Contents

Contents