Build a Custom Selector
Contents
Build a Custom Selector
Create a content type
-
Create a folder called
my-custom-selector
inside thesite/content-types
folder of your project. -
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
-
Create a folder called
my-custom-selector-service
(folder name must match the one specified in the config schema) inside theresources/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 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
andcount
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)
{
"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: