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

  • Create a folder called “my-custom-selector” inside the “site/content-types” folder of your project.
  • In that folder create a configuration schema for the “my-custom-selector” 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 (or refer to a service in another app)

  • 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.
  • In that folder create a javascript service file called “my-custom-selector-service.js” (again, the name must match the config schema).
  • Create GET handler method in this service file and make sure it returns JSON in the proper format.

Tip

You can also refer to service file in another application (for example, com.myapplication.app:myservice) instead of adding one to your application.

<?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>com.myapplication.app:my-custom-selector-service</service>
      </config>
    </input>
  </form>
</content-type>

Response format

Format of JSON response from the service:

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)

Example of a simple service file

Below is a simple service file that returns two items in the result set, one with external thumbnail image, and another one with inline SVG markup:

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;
}

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'});
}
../../_images/custom-selector-books.png