Build a Custom Selector

Contents

Build a Custom Selector

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

Create a content type

  1. Create a folder called my-custom-selector inside the site/content-types folder of your project.

  2. In that folder create a configuration schema called my-custom-selector.xml for the new content type.

    <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
    <content-type>
      <display-name>Custom Selector</display-name>
      <super-type>base:structured</super-type>
      <form>
        <input name="my-custom-selector" type="CustomSelector">
          <label>My Custom Selector</label>
          <occurrences minimum="0" maximum="0"/>
          <config>
            <service>my-custom-selector-service</service>
          </config>
        </input>
      </form>
    </content-type>

Create a service

  1. Create a folder called my-custom-selector-service (folder name must match the one specified in the config schema) inside the resources/services folder of your project.

  2. In that folder create a javascript service file called my-custom-selector-service.js (again, the name must match the config schema).

  3. Create GET handler method in this service file and make sure it returns JSON in proper format.

    var portalLib = require('/lib/xp/portal');
    
    exports.get = handleGet;
    
    function handleGet(req) {
    
        var params = parseparams(req.params);
    
        var body = createresults(getItems(), params);
    
        return {
            contentType: 'application/json',
            body: body
        }
    }
    
    function getItems() {
        return [{
            id: 1,
            displayName: "Option number 1",
            description: "External SVG file is used as icon",
            iconUrl: portalLib.assetUrl({path: 'images/number_1.svg'}),
            icon: null
        }, {
            id: 2,
            displayName: "Option number 2",
            description: "Inline SVG markup is used as icon",
            iconUrl: null,
            icon: {
                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"
            }
        }];
    }
    
    function parseparams(params) {
    
        var query = params['query'],
            ids, start, count;
    
        try {
            ids = JSON.parse(params['ids']) || []
        } catch (e) {
            log.warning('Invalid parameter ids: %s, using []', params['ids']);
            ids = [];
        }
    
        try {
            start = Math.max(parseInt(params['start']) || 0, 0);
        } catch (e) {
            log.warning('Invalid parameter start: %s, using 0', params['start']);
            start = 0;
        }
    
        try {
            count = Math.max(parseInt(params['count']) || 15, 0);
        } catch (e) {
            log.warning('Invalid parameter count: %s, using 15', params['count']);
            count = 15;
        }
    
        return {
            query: query,
            ids: ids,
            start: start,
            end: start + count,
            count: count
        }
    }
    
    function createresults(items, params, total) {
    
        var body = {};
    
        log.info('Creating results with params: %s', params);
    
        var hitCount = 0, include;
        body.hits = items.sort(function (hit1, hit2) {
            if (!hit1 || !hit2) {
                return !!hit1 ? 1 : -1;
            }
            return hit1.displayName.localeCompare(hit2.displayName);
        }).filter(function (hit) {
            include = true;
    
            if (!!params.ids && params.ids.length > 0) {
                include = params.ids.some(function (id) {
                    return id == hit.id;
                });
            } else if (!!params.query && params.query.trim().length > 0) {
                var qRegex = new RegExp(params.query, 'i');
                include = qRegex.test(hit.displayName) || qRegex.test(hit.description) || qRegex.test(hit.id);
            }
    
            if (include) {
                hitCount++;
            }
            return include && hitCount > params.start && hitCount <= params.end;
        });
        body.count = Math.min(params.count, body.hits.length);
        body.total = params.query ? hitCount : (total || items.length);
    
        return body;
    }
    You have to manually handle paging of items in your service based on start and count parameters of the GET request sent to the service.
You can also refer to 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>

Response format

JSON response object must contain three properties:

hits

Array of item objects

count

Number of items in response

total

Total number of items

hits is an object containing array of items that will be listed in the selector’s dropdown. Format of the object is described below:

id

Unique Id of the option

displayName

Option title

description

(optional) Detailed description

iconUrl

(optional) Path to the thumbnail image file

icon

(optional) Inline image content (for example, SVG)

Sample JSON response:
{
  "hits": [
    {
      "id": 1,
      "displayName": "Option number 1",
      "description": "External SVG file is used as icon",
      "iconUrl": "\/admin\/portal\/edit\/draft\/_\/asset\/com.enonic.app.features:1524061998\/images\/number_1.svg"
    },
    {
      "id": 2,
      "displayName": "Option number 2",
      "description": "Inline SVG markup is used as icon",
      "icon": {
        "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,
  "total": 2
}

Integration with Google Books API

And here’s a bit more advanced version of the service file that fetches book titles from the Google Books API:

var portalLib = require('/lib/xp/portal');
var httpClient = require('/lib/xp/http-client');
var cacheLib = require('/lib/xp/cache');

var bookIdCache = cacheLib.newCache({
    size: 100
});

var searchQueriesCache = cacheLib.newCache({
    size: 100,
    expire: 60 * 10
});

var apiKey = "AIzaSyDZnJCAzEXznkeBzaDDoKdj0u6nfEDFcAU";

exports.get = handleGet;

function handleGet(req) {

    var params = req.params;
    var ids;
    try {
        ids = JSON.parse(params.ids) || []
    } catch (e) {
        ids = [];
    }

    var tracks;
    if (ids.length > 0) {
        tracks = fetchBooksByIds(ids);
    } else {
        tracks = searchBooks(params.query, params.start || 0, params.count || 10);
    }

    return {
        contentType: 'application/json',
        body: tracks
    }
}

function fetchBooksByIds(ids) {
    var tracks = [];

    for (var i = 0; i < ids.length; i++) {
        var id = ids[i];

        var track = bookIdCache.get(id, function () {
            var bookResponse = fetchBookById(id);
            return bookResponse ? parseBookResponse(bookResponse) : null;
        });

        if (track) {
            tracks.push(track);
        }
    }

    return {
        count: tracks.length,
        total: tracks.length,
        hits: tracks
    };
}

function searchBooks(text, start, count) {
    text = (text || '').trim();
    if (!text) {
        return {
            count: 0,
            total: 0,
            hits: []
        };
    }

    return searchQueriesCache.get(searchKey(text, start, count), function () {
        var googleResponse = fetchBooks(text, start, count);
        return parseSearchResults(googleResponse);
    });
}

function searchKey(text, start, count) {
    return start + '-' + count + '-' + text;
}

function fetchBookById(id) {
    log.info('Fetching books from Google Bookds API by id: ' + id);
    try {
        var response = httpClient.request({
            url: 'https://www.googleapis.com/books/v1/volumes/' + id,
            method: 'GET',
            contentType: 'application/json',
            connectTimeout: 5000,
            readTimeout: 10000
        });
        if (response.status === 200) {
            return JSON.parse(response.body);
        }

    } catch (e) {
        log.error('Could not retrieve the book', e);
    }

    return null;
}

function fetchBooks(text, start, count) {
    if (!text) {
        return emptyResponse();
    }

    log.info('Querying Google Books API: ' + start + ' + ' + count + ' "' + text + '"');
    try {
        var response = httpClient.request({
            url: 'https://www.googleapis.com/books/v1/volumes',
            method: 'GET',
            contentType: 'application/json',
            connectTimeout: 5000,
            readTimeout: 10000,
            params: {
                'key': apiKey,
                'q': text,
                'printType': 'books',
                'maxResults': count,
                'startIndex': start
            }
        });

        if (response.status === 200) {
            return JSON.parse(response.body);
        }
        log.error('Could not fetch books: error ' + JSON.parse(response));

    } catch (e) {
        log.error('Could not fetch books: ', e);
    }

    return emptyResponse();
}

function emptyResponse() {
    return {
        "kind": "books#volumes",
        "totalItems": 0
    };
}

function parseSearchResults(resp) {
    var options = [];
    var books = resp.items, i, option, book;
    for (i = 0; i < books.length; i++) {
        book = books[i];
        option = bookIdCache.get(book.id, function () {
            return parseBook(book);
        });
        options.push(option);
    }

    return {
        count: resp.items.length,
        total: resp.totalItems,
        hits: options
    };
}

function parseBookResponse(resp) {

    if (!resp.id) {
        return null;
    }

    return parseBook(resp);
}

function parseBook(book) {
    var option = {};
    option.id = book.id;
    var volume = book.volumeInfo;

    var author = volume.authors && volume.authors.length > 0 ? volume.authors[0] : '';
    option.displayName = volume.title + (author ? ' (by ' + author + ')' : '');
    option.description = volume.description;

    if (volume.imageLinks) {
        option.iconUrl = volume.imageLinks.thumbnail || volume.imageLinks.smallThumbnail;
    } else {
        option.iconUrl = defaultIcon();
    }

    return option;
}

function defaultIcon() {
    return portalLib.assetUrl({path: 'noimage.png'});
}

The result should look something like this:

Custom selector

Check our Features app for examples of more advanced content- and input-types.

Need help? Ask questions on our forum or answer questions from others.

Contents