Home Reference Source Repository

js/models/base.js

import API from 'api';
import validator from 'models/validator';
import {pubsub, PubSub} from 'pubsub';
import Sifter from 'sifter';

export const DEFAULT_PAGE_SIZE = 10;

/**
 * An empty JSON schema factory
 * @return {Object} An empty JSON schema
 */
function empty_schema() {
    return {properties: {}, required: []};
}

/**
 * Common class behaviors.
 *
 * Provide:
 *     - PubSub
 *     - Scoped API access
 *     - Vue.js compatible setter
 */
export class Base {
    constructor() {
        this.$pubsub = new PubSub();
    }

    /**
     * The class name
     * @return {[type]} [description]
     */
    get __class__() {
        return this.constructor.name
    }

    /**
     * Property setter which handle vue.js $set method if object is binded.
     * @param {String} name  The name of the object property to set.
     * @param {Object} value The property value to set.
     */
    _set(name, value) {
        if (this.hasOwnProperty('$set')) {
            this.$set(name, value);
        } else {
            this[name] = value;
        }
    }

    /**
     * Emit an event on this model instance
     * @param  {String} name    The event unique name
     * @param  {Array}  args    A variable number of parameters
     */
    $emit(name, ...args) {
        var prefix = this.__class__.toLowerCase(),
            topic = prefix + ':' + name;
        pubsub.publish(topic, this, ...args);
        this.$pubsub.publish(name, this, ...args);
    }

    /**
     * Register a listener on an event.
     * @param  {String}   name   The event name to subscribe
     * @param  {Function} handler The callback to register
     * @return {Object}   An object with a single method remove
     *                       allowing to unregister the callback
     */
    $on(name, handler) {
        return this.$pubsub.subscribe(name, handler);
    }

    /**
     * Unregister a listener on an event.
     * @param  {String}   name   The event name to subscribe
     * @param  {Function} handler The callback to register
     */
    $off(name, handler) {
        return this.$pubsub.unsubscribe(name, handler);
    }

    /**
     * Register once a listener on an event.
     * @param  {String}   name   The event name to subscribe
     * @param  {Function} handler The callback to register
     */
    $once(name, handler) {
        return this.$pubsub.once(name, handler);
    }

    /**
     * Call an API endpoint.
     * Callbacks are scoped to the model instance.
     *
     * @param  {String}   endpoint The API endpoint to call
     * @param  {Object}   data     The data object to submit
     * @param  {Function} callback The callback function to call on success.
     */
    $api(endpoint, data, callback) {
        var parts = endpoint.split('.'),
            namespace = parts[0],
            method = parts[1],
            operation = API[namespace][method];

        return operation(data, callback.bind(this));
    }
};

/**
 * A base class for schema based models.
 */
export class Model extends Base {
    constructor(options) {
        super();
        this.$options = options || {};
        this.empty();
        if (this.$options.data) {
            Object.assign(this, this.$options.data);
        }
    }

    /**
     * Get the JSON schema for a given Class from Swagger definitions.
     * @param  {Vue} cls A model Class with a declared name.
     * @return {Object} A JSON schema
     */
    get __schema__() {
        return API.definitions[this.__class__] || {};
    }

    /**
     * Empty or clear a data object based on a schema.
     * @return {Object}     The instance
     */
    empty() {
        var schema = this.__schema__
        for (var key in schema.properties) {
            if (schema.properties.hasOwnProperty(key)) {
                this[key] = schema.required.indexOf(key) >= 0 ? null : undefined;
            }
        }
        return this;
    }

    on_fetched(data) {
        for (let prop in data.obj) {
            let value = data.obj[prop];
            this._set(prop, value);
        }
        this.$emit('updated');
        this.loading = false;
    }

    /**
     * Perform a model validation given its schema
     * @return {Object} A TV4 validation descriptor.
     */
    validate() {
        return validator.validateMultiple(this, this.__schema__);
    }

    /**
     * Empty the model
     * @return {Object} Return itself allowing to chain methods.
     */
    clear() {
        this.empty();
        return this;
    }
};


/**
 * A base class for unpaginated list
 */
export class List extends Base {
    constructor(options) {
        super();
        this.$options = options || {};

        this.items = this.$options.data || [];
        this.query = this.$options.query || {};
        this.loading = this.$options.loading || false;

        this.sorted = null;
        this.reversed = false;
        this.filtered = [];
        this._search = '';
        this._sifter = new Sifter(this.items);
        this.populate();
    }

    get has_search() {
        return this.$options.search !== undefined;
    }

    get data() {
        return this.filtered;
    }

    set data(value) {
        this._set('filtered', value);
    }

    /**
     * Populate the data view (filtered and sorted)
     */
    populate() {
        let options = {};

        if (this.$options.search) {
            options.fields = Array.isArray(this.$options.search) ? this.$options.search: [this.$options.search];
        } else {
            options.fields = [];
        }

        if (this.sorted) {
            options.sort = [{
                field: this.sorted,
                direction: this.reversed ? 'desc' : 'asc'
            }];
        }
        this.data =  this._sifter.search(this._search, options).items.map((result) => {
            return this.items[result.id];
        });
    }

    /**
     * Fetch an unpaginated list.
     * @param  {[type]} options [description]
     * @return {[type]}         [description]
     */
    fetch(options) {
        options = Object.assign(this.query, options);
        this.loading = true;
        this.$api(this.$options.ns + '.' + this.$options.fetch, options, this.on_fetched);
        return this;
    }

    on_fetched(data) {
        this.items = data.obj;
        this._sifter = new Sifter(this.items);
        this.populate();
        this.$emit('updated');
        this.loading = false;
    }

    /**
     * Get an item given its ID
     */
    by_id(id) {
        var filtered = this.items.filter((item) => {
            return item.hasOwnProperty('id') && item.id === id;
        });
        return filtered.length === 1 ? filtered[0] : null;
    }

    /**
     * Empty the list
     * @return {Object} Return itself allowing to chain methods.
     */
    clear() {
        this.items = [];
        this.populate();
        this.$emit('updated');
        return this;
    }


    /**
     * Perform a client-side sort
     * @param {String} field The object attribute to sort on.
     * @param {Boolean} reversed If true, sort is descending.
     * @return {Object} Return itself allowing to chain methods.
     */
    sort(field, reversed) {
        this.sorted = field;
        this.reversed = reversed !== undefined ? reversed : !this.reversed;
        this.page = 1;
        this.populate();
        return this;
    }

    /**
     * Perform a client-side search
     * @param  {String} query The query string to perform the search on.
     * @return {Object} Return itself allowing to chain methods.
     */
    search(query) {
        this._search = query;
        this.sorted = null;
        this.populate();
        return this;
    }
};

/**
 * A base class for server-side paginated list.
 */
export class ModelPage extends Model {
    constructor(options) {
        super(options);
        this.query = this.$options.query || {};
        this.cumulative = this.$options.cumulative || false;
        this.loading = true;
        this.serverside = true;
        this._data = [];
    }

    /**
     * Total amount of pages
     * @return {int}
     */
    get pages() {
        return Math.ceil(this.total / this.page_size);
    }

    /**
     * Field name used for sorting
     * @return {string}
     */
    get sorted() {
        if (!this.query.sort) {
            return;
        }
        return this.query.sort[0] == '-'
            ? this.query.sort.substring(1, this.query.sort.length)
            : this.query.sort;
    }

    /**
     * Wether the sort is reversed (descending) or not (ascending)
     * @return {boolean}
     */
    get reversed() {
        if (!this.query.sort) {
            return;
        }
        return this.query.sort[0] == '-';
    }

    get has_search() {
        var op = API[this.$options.ns].operations[this.$options.fetch];

        return op.parameters.filter((p) => {
            return p.name == 'q';
        }).length > 0;
    }

    /**
     * Fetch page from server.
     * @param  {Object} options An optionnal query object
     * @return {Object} Return itself allowing to chain methods.
     */
    fetch(options) {
        this.query = Object.assign(this.query, options || {});
        this.loading = true;
        this.$api(this.$options.ns + '.' + this.$options.fetch, this.query, this.on_fetched);
        return this;
    }

    on_fetched(data) {
        if (this.cumulative && this._data.length) {
            data.obj.data = this._data.concat(data.obj.data);
        }
        super.on_fetched(data);
        this._data = data.obj.data;
        this.$emit('updated');
    }

    /**
     * Fetch the next page.
     * @param {Object} options An optionnal query object for fetch.
     * @return {Object} Return itself allowing to chain methods.
     */
    nextPage(options) {
        if (this.page && this.page < this.pages) {
            this.query.page = this.page + 1;
            this.fetch(options);
        }
        return this;
    }

    /**
     * Fetch the previous page.
     * @return {Object} Return itself allowing to chain methods.
     */
    previousPage() {
        if (this.page && this.page > 1) {
            this.query.page = this.page - 1;
            this.fetch();
        }
        return this;
    }

    /**
     * Fetch a page given its index.
     * @param {Number} page The page index to fetch.
     * @return {Object} Return itself allowing to chain methods.
     */
    go_to_page(page) {
        this.fetch({page: page});
        return this;
    }

    /**
     * Perform a server-side sort
     * @param {String} field The object attribute to sort on.
     * @param {Boolean} reversed If true, sort is descending.
     * @return {Object} Return itself allowing to chain methods.
     */
    sort(field, reversed) {
        if (this.sorted !== field) {
            this.query.sort = '-' + field;
        } else {
            reversed = reversed || (this.sorted == field ? !this.reversed : false);
            this.query.sort = reversed ? '-' + field : field;
        }
        this.fetch({page: 1}); // Clear the pagination
        return this;
    }

    /**
     * Perform a server-side search
     * @param  {String} query The query string to perform the search on.
     * @return {Object} Return itself allowing to chain methods.
     */
    search(query) {
        this.query.q = query;
        this.query.sort = undefined;
        this.fetch({page: 1}); // Clear the pagination
        return this;
    }
};

/**
 * A client-side pager wrapper for list.
 */
export class PageList extends List {
    constructor(options) {
        super(options);
        this.page = 1;
        this.page_size = this.$options.page_size || DEFAULT_PAGE_SIZE;
    }

    get data() {
        return super.data.slice(
            Math.max(this.page - 1, 0) * this.page_size,
            this.page * this.page_size
        );
    }

    set data(value) {
        super.data = value;
    }

    /**
     * Total amount of pages
     * @return {int}
     */
    get pages() {
        return Math.ceil(super.data.length / this.page_size);
    }

    /**
     * Display the next page.
     * @return {Object} Return itself allowing to chain methods.
     */
    nextPage() {
        if (this.page && this.page < this.pages) {
            this.page = this.page + 1;
        }
        this.populate();
        return this;
    }

    /**
     * Display the previous page.
     * @return {Object} Return itself allowing to chain methods.
     */
    previousPage() {
        if (this.page && this.page > 1) {
            this.page = this.page - 1;
        }
        this.populate();
        return this;
    }

    /**
     * Display a page given its index.
     * @param {Number} page The page index to fetch.
     * @return {Object} Return itself allowing to chain methods.
     */
    go_to_page(page) {
        this.page = page;
        this.populate();
        return this;
    }
};