Build a Custom Selector

Contents

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/http-client');
var cacheLib = require('/lib/cache');

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

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

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: {
                '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

Customize with Guillotine Lib

By default, in a GraphQL schema the type of Custom Selector is an array of strings with selected ids. You may want to override this type in your custom GraphQL schema, for instance replace array of strings with an array of custom GoogleBook GraphQL type.

Let’s add a new content type with content selector which will use data from Google Books API.

<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<content-type>
  <display-name>Google Books Selector</display-name>
  <super-type>base:structured</super-type>
  <form>
    <input name="googleBooks" type="CustomSelector">
      <label>Google Books Selector</label>
      <occurrences minimum="0" maximum="0"/>
      <config>
        <service>books-service</service>
      </config>
    </input>
  </form>
</content-type>

In order to customize GraphQL schema we must use the creationCallbacks option from Guillotine Lib context.

There are two ways to do that:

  • The first way is based on integration with an existing GraphQL schema:

In this case, developer will be able to create new GraphQL object types and register them when creating a Guillotine context.

const context = guillotineLib.createContext();

context.types.googleBookType = context.schemaGenerator.createObjectType({
    name: 'GoogleBook',
    fields: {
        id: {
            type: graphQlLib.GraphQLString,
        },
        title: {
            type: graphQlLib.GraphQLString,
        },
        subtitle: {
            type: graphQlLib.GraphQLString,
        },
        authors: {
            type: graphQlLib.list(graphQlLib.GraphQLString),
        },
        publisher: {
            type: graphQlLib.GraphQLString,
        },
        publishedDate: {
            type: graphQlLib.GraphQLString,
        },
        description: {
            type: graphQlLib.GraphQLString,
        },
        pageCount: {
            type: graphQlLib.GraphQLInt,
        },
        language: {
            type: graphQlLib.GraphQLString,
        },
        averageRating: {
            type: graphQlLib.GraphQLFloat,
        },
        ratingsCount: {
            type: graphQlLib.GraphQLFloat,
        },
    }
});

After a new GraphQL type is registered in Guillotine context we can use it in the creationCallbacks:

context.options.creationCallbacks = {
    'com_enonic_app_myapp_GoogleBooksSelector_Data': (context, params) => {
        params.fields.googleBooks = {
            type: graphQlLib.list(context.types.googleBookType),
            resolve: (env) => {
                const bookIds = env.source['googleBooks'];
                const books = [];
                (bookIds || []).forEach(bookId => {
                    books.push(fetchBookById(bookId)) // make request to Google Books API
                });
                return books;
            }
        };
    },
};

Then we can create a GraphQL schema. More details here.

function createSchema() {
    const context = guillotineLib.createContext();

    // create and register custom object types if needed

    return context.schemaGenerator.createSchema({
        query: createRootQueryType(context),
        dictionary: context.dictionary,
    });
}
  • The second way can be used when a GraphQL schema is created dynamically by the Guillotine Lib.

In this case developer has access to Guillotine context only in the creation callback function. More details here.

function createGoogleBookGraphQLObject(context) {
    return context.schemaGenerator.createObjectType({
        name: 'GoogleBook',
        fields: {
            id: {
                type: graphQlLib.GraphQLString,
            },
            title: {
                type: graphQlLib.GraphQLString,
            },
            subtitle: {
                type: graphQlLib.GraphQLString,
            },
            authors: {
                type: graphQlLib.list(graphQlLib.GraphQLString),
            },
            publisher: {
                type: graphQlLib.GraphQLString,
            },
            publishedDate: {
                type: graphQlLib.GraphQLString,
            },
            description: {
                type: graphQlLib.GraphQLString,
            },
            pageCount: {
                type: graphQlLib.GraphQLInt,
            },
            language: {
                type: graphQlLib.GraphQLString,
            },
            averageRating: {
                type: graphQlLib.GraphQLFloat,
            },
            ratingsCount: {
                type: graphQlLib.GraphQLFloat,
            },
        }
    });
}

const schema = guillotineLib.createSchema({
    creationCallbacks: {
        'com_enonic_app_myapp_GoogleBooksSelector_Data': (context, params) => {
            context.types.googleBookType = createGoogleBookGraphQLObject(context);
            params.fields.googleBooks = {
                type: graphQlLib.list(context.types.googleBookType),
                resolve: (env) => {
                    const bookIds = env.source['googleBooks'];
                    const books = [];
                    (bookIds || []).forEach(bookId => {
                        books.push(fetchBookById(bookId))
                    });
                    return books;
                }
            };
        },
    }
});

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