tmcgee/cmv-widgets

View on GitHub
widgets/Search/QueryBuilder/QueryBuilderMixin.js

Summary

Maintainability
D
1 day
Test Coverage
define([
    'dojo/_base/declare',
    'dojo/_base/lang',
    'dojo/_base/array',
    'dojo/Deferred',
    'dojo/promise/all',
    'dojo/when',
    'dojo/topic',
    'module',

    'esri/request',

    'jquery',

    '../GetDistinctValues',

    'xstyle/css!./lib/query-builder.default.min.css',
    'xstyle/css!./lib/querybuilder-bootstrap-shim.min.css',
    'xstyle/css!./lib/selectize.default.min.css',
    'xstyle/css!./lib/selectize.bootstrap3.min.css',
    'xstyle/css!../css/AdvancedSearch.css'
], function (
    declare,
    lang,
    array,
    Deferred,
    allPromise,
    when,
    topic,
    module,

    esriRequest,

    $,

    GetDistinctValues
) {

    return declare(null, {
        queryBuilder: null,

        defaultQueryBuilderOptions: {
            icons: {
                'add_group': 'fas fa-plus-square',
                'add_rule': 'fas fa-plus-circle',
                'remove_group': 'fas fa-minus-square',
                'remove_rule': 'fas fa-minus-circle',
                'error': 'fas fa-exclamation-triangle'
            },
            'display_errors': true,
            'allow_groups': false,

            filters: []
        },

        firstValidationError: null,

        postCreate: function () {
            this.inherited(arguments);

            this.showLoadingSpinnerWhile(lang.hitch(this, function () {
                var deferred = new Deferred();

                // load the query-builder scripts as modules
                var modulesPath = module.uri.substring(0, module.uri.lastIndexOf('/')) + '/lib';
                window.dojoConfig.packages.push({
                    name: 'query-builder',
                    location: modulesPath,
                    main: 'query-builder.standalone.min'
                });
                window.dojoConfig.packages.push({
                    name: 'selectize',
                    location: modulesPath,
                    main: 'selectize.min'
                });

                require(window.dojoConfig, [
                    'query-builder',
                    'selectize'
                ], lang.hitch(this, function () {
                    this.loadQueryBuilder().then(function () {
                        deferred.resolve();
                    }, function (e) {
                        deferred.reject(e);
                    });
                }));

                return deferred;
            }));
        },

        loadQueryBuilder: function () {
            var qbOptions = this.mixinDeep(this.defaultQueryBuilderOptions, this.queryBuilderOptions || {});

            // Namespace the configuration variables in this mixin
            this.queryBuilder = {
                _queryBuilderDOM: null,
                _queryBuilder: null,

                search: null,
                url: null,

                _fields: {},

                options: {
                    targetDOM: null,
                    qbOptions: qbOptions
                },

                init: function (options) {
                    lang.mixin(this.options, options);
                },

                setSearch: function (search) {
                    if (!search || !search.queryParameters) {
                        return when(null);
                    }

                    this.search = search;
                    this.url = this.getLayerURL(search.queryParameters);
                    if (!this.url) {
                        return when(null);
                    }

                    if (this._queryBuilder) {
                        this._queryBuilder.destroy();
                        delete this._queryBuilder;
                    }

                    this._initQBOptions();

                    this._fields = {};

                    var queries = [];
                    var defaultToCaseInsensitive = false;
                    var advancedSearchOptions = search.advancedSearchOptions;
                    if (advancedSearchOptions.defaultToCaseInsensitive) {
                        defaultToCaseInsensitive = true;
                    }
                    if (advancedSearchOptions.fields && advancedSearchOptions.fields.length) {
                        array.forEach(advancedSearchOptions.fields, function (field) {
                            field.id = field.id || field.field;
                            if (field.id) {
                                this._fields[field.id] = lang.clone(field);
                                if (field.unique) {
                                    queries.push(this._fetchSelectOptions(this._fields[field.id]));
                                }
                            }
                        }, this);
                    }

                    if (!advancedSearchOptions.fields || advancedSearchOptions.fetchAllFields) {
                        queries.push(this.getLayerJSON(this.url).then(lang.hitch(this, function (data) {
                            array.forEach(data.fields, function (f) {
                                var parsed = this._parseESRIField(f);
                                parsed = lang.mixin(parsed, {
                                    caseInsensitive: defaultToCaseInsensitive
                                });
                                this._fields[parsed.id] = lang.mixin(parsed, this._fields[parsed.id]); // Local config takes precedence
                            }, this);
                        })));
                    }

                    allPromise(queries).then(lang.hitch(this, function () {
                        this._createQueryBuilder();
                    }));

                    return when(null);
                },

                getLayerJSON: function (url) {
                    var deferred = new Deferred();
                    var layerJSON = this.options.layerJSON;

                    if (layerJSON[url]) {
                        if (layerJSON[url].promise) {
                            return layerJSON[url].promise;
                        }
                        deferred.resolve(layerJSON[url]);
                    } else {
                        layerJSON[url] = deferred;
                        esriRequest({
                            url: url,
                            parameters: {
                                f: 'json'
                            },
                            content: {
                                f: 'json'
                            },
                            handleAs: 'json',
                            callbackParamName: 'callback'
                        }, {
                            disableIdentityLookup: false,
                            usePost: false,
                            useProxy: false
                        }).then(
                            lang.hitch(this, function (data) {
                                layerJSON[url] = data;
                                deferred.resolve(layerJSON[url]);
                            }),
                            lang.hitch(this, function () {
                                layerJSON[url] = {};
                                deferred.resolve(layerJSON[url]);
                            })
                        );
                    }

                    return deferred.promise;
                },

                getLayerURL: function (qp) {
                    var url = qp.url;

                    if (url && qp.sublayerID) {
                        var len = url.length;
                        if (url.substring(len - 1, len) === '/') {
                            url = url.substring(0, len - 1);
                        }
                        url += '/' + qp.sublayerID;
                    }

                    if (!url && qp.layerID) {
                        var layer = this.options.map.getLayer(qp.layerID);
                        if (layer) {
                            if (layer.declaredClass === 'esri.layers.FeatureLayer') { // Feature Layer
                                url = layer.url;
                            } else if (layer.declaredClass === 'esri.layers.ArcGISDynamicMapServiceLayer') { // Dynamic Layer
                                if (qp.sublayerID !== null) {
                                    url = layer.url + '/' + qp.sublayerID;
                                } else if (layer.visibleLayers && layer.visibleLayers.length === 1) {
                                    url = layer.url + '/' + layer.visibleLayers[0];
                                }
                            }
                        }
                    }
                    return url;
                },

                _initQBOptions: function () {
                    this.qbOptions = lang.clone(this.options.qbOptions);

                    // Use <> instead of != in SQL output
                    this.qbOptions.sqlOperators = {
                        'not_equal': {
                            op: '<> ?'
                        },
                        'is_not_empty': {
                            op: '<> \'\''
                        }
                    };
                    this.qbOptions.sqlRuleOperators = {
                        '<>': function (v) {
                            return {
                                val: v,
                                op: v === '' ? 'is_not_empty' : 'not_equal'
                            };
                        }
                    };
                    this.qbOptions.filters = [];
                },

                _fetchSelectOptions: function (field) {
                    var distinct = new GetDistinctValues(this.url, field.id, this.search.where);
                    return distinct.executeQuery()
                        .then(lang.hitch(this, function (fieldID, results) {
                            this._fields[fieldID].options = array.map(results, function (result) {
                                return {
                                    id: result,
                                    name: result
                                };
                            });
                        }, field.id));
                },

                _qbOnUpdateOperator: function (e, rule) {
                    if (rule.filter.plugin === 'selectize') {
                        var el = rule.$el.find('.rule-value-container input')[0];
                        if (!el || !el.selectize) {
                            return;
                        }
                        el.selectize.settings.maxItems = (rule.operator.multiple ? null : 1);
                        var mode = el.selectize.settings.mode = (rule.operator.multiple ? 'multi' : 'single');

                        // SUPER HACKY
                        rule.$el.find('.rule-value-container .selectize-control').removeClass('multi').removeClass('single').addClass(mode);
                        rule.$el.find('.rule-value-container .selectize-dropdown').removeClass('multi').removeClass('single').addClass(mode);
                    }
                },

                _createQueryBuilder: function () {
                    var fieldNames = Object.keys(this._fields).sort(function (a, b) {
                        a = a.toLowerCase();
                        b = b.toLowerCase();
                        if (a < b) {
                            return -1;
                        }
                        if (a > b) {
                            return 1;
                        }
                        return 0;
                    });
                    var filterableFields = null;
                    if (this.search.advancedSearchOptions) {
                        filterableFields = this.search.advancedSearchOptions.filterableFields;
                    }

                    array.forEach(fieldNames, lang.hitch(this, function (fname) {
                        if (!this._fields[fname].hidden) {
                            if (!filterableFields || array.indexOf(filterableFields, fname) !== -1) {
                                this._addFilter(this._fields[fname]);
                            }
                        }
                    }));

                    // Make sure we successfully created at least one filter
                    if (this.qbOptions.filters.length === 0) {
                        return;
                    }

                    this._queryBuilder = $(this.options.targetDOM).queryBuilder(this.qbOptions)[0].queryBuilder;

                    // Selectize fix
                    this._queryBuilder.on('afterCreateRuleInput.queryBuilder', function (e, rule) {
                        if (rule.filter.plugin === 'selectize') {
                            rule.$el.find('.rule-value-container').css('min-width', '200px')
                                .find('.selectize-control').removeClass('form-control');
                        }
                    });

                    // Multiple-selectize on IN / NOT IN operators
                    this._queryBuilder.on('afterUpdateRuleFilter.queryBuilder', lang.hitch(this, this._qbOnUpdateOperator));
                    this._queryBuilder.on('afterUpdateRuleOperator.queryBuilder', lang.hitch(this, this._qbOnUpdateOperator));

                    this._queryBuilder.on('validationError.queryBuilder', lang.hitch(this, function (e, rule, error /*, value */) {
                        if (!this.firstValidationError) {
                            this.firstValidationError = this._queryBuilder.lang.errors[error];
                        }
                    }));
                    this._queryBuilder.on('validate.queryBuilder.filter', lang.hitch(this, function (e) {
                        if (!e.value) {
                            topic.publish('growler/growl', {
                                title: 'Advanced Search',
                                message: this.firstValidationError || 'Unknown QueryBuilder error.',
                                level: 'error',
                                timeout: 3000
                            });
                            this.firstValidationError = null;
                        }
                        return e.value;
                    }));

                    // Support case-insensitivity
                    // NB: This will break when sql is exported and then re-imported, but that functionality is currently disabled anyway
                    // This function replicates much of the functionality of query-builder's getSQL function, adding value whitespace trimming and case insensitivity for singular and multiple values.
                    function sqlFnShim (qb, rule) {
                        var value = '';
                        var sql = qb.settings.sqlOperators[rule.operator];
                        var Utils = $.fn.queryBuilder.constructor.utils;
                        rule.value.forEach(function (v, i) {
                            if (i > 0) {
                                value += sql.sep;
                            }

                            if (rule.type === 'integer' || rule.type === 'double' || rule.type === 'boolean') {
                                v = Utils.changeType(v, rule.type, true);
                            } else {
                                v = Utils.escapeString(v);
                            }

                            if (sql.mod) {
                                v = Utils.fmt(sql.mod, v);
                            }

                            if (typeof v === 'string') {
                                v = '\'' + v.trim() + '\'';

                                if (rule.data.caseInsensitive) {
                                    v = 'UPPER(' + v + ')';
                                }
                            }

                            value += v;
                        });
                        return value;
                    }
                    this._queryBuilder.on('ruleToSQL.filter', lang.hitch(this, function (e, rule, value, sqlFn) {
                        if (rule.data.type === 'string' && rule.data.caseInsensitive) { // check rule.data.type so we don't use UPPER for select inputs
                            e.value = 'UPPER(' + rule.field + ') ' + sqlFn(sqlFnShim(this._queryBuilder, rule));
                        }
                    }));
                },

                _addFilter: function (field) {
                    var filter = this._buildFilterConfig(field);

                    switch (filter.type) {
                    case 'string':
                    case 'integer':
                    case 'double':
                    case 'datetime':
                    case 'date':
                        this.qbOptions.filters.push(filter);
                        return;
                    default:
                        return;
                    }
                },

                clear: function () {
                    if (this._queryBuilder) {
                        this._queryBuilder.reset();
                    }
                },

                toSQL: function () {
                    if (!this._queryBuilder.getRules()) {
                        return null;
                    }
                    return this._queryBuilder.getSQL();
                },

                fromSQL: function (sql) {
                    if (!sql) {
                        return null;
                    }
                    return this._queryBuilder.setRulesFromSQL(sql);
                },

                _buildFilterConfig: function (field) {
                    var filter = {
                        id: field.id,
                        label: field.label,
                        type: field.type,
                        'value_separator': ','
                    };
                    if (field.type === 'date' || field.type === 'datetime') {
                        filter = {
                            id: field.id,
                            label: field.label,
                            type: 'date',
                            // Validation requires MomentJS to be defined globally, which we don't want.  Just break if an invalid date is passed for now.
                            // validation: {
                            //   format: 'MM/DD/YYYY'
                            // },
                            placeholder: 'MM/DD/YYYY'
                            // plugin: 'datepicker', // jQuery UI doesn't easily play well with Dojo
                            // plugin_config: {
                            //   format: 'yyyy/mm/dd',
                            //   todayBtn: 'linked',
                            //   todayHighlight: true,
                            //   autoclose: true
                            // }
                        };
                    } else if (field.unique || field.options) {
                        filter = {
                            id: field.id,
                            label: field.label,
                            type: field.type,
                            operators: ['equal', 'not_equal', 'in', 'not_in'],
                            plugin: 'selectize',
                            'value_separator': ',',
                            'plugin_config': {
                                valueField: 'id',
                                labelField: 'name',
                                searchField: 'name',
                                sortField: 'name',
                                plugins: [], // 'remove_button' would be nice, but doesn't play well with single/multi switching customizations
                                delimeter: ',', // WARNING: This could cause issues if there's a comma in a value
                                onInitialize: function () {
                                    if (!field.options) {
                                        return;
                                    }
                                    array.forEach(field.options, function (item) {
                                        if (typeof item !== 'object') {
                                            item = {
                                                id: item,
                                                name: item
                                            };
                                        }
                                        this.addOption(item);
                                    }, this);
                                    // this.refreshItems();
                                }
                            },
                            valueSetter: function (rule, value) {
                                rule.$el.find('.rule-value-container input')[0].selectize.setValue(value);
                            },
                            valueGetter: function (rule) {
                                var val = rule.$el.find('.rule-value-container input')[0].selectize.getValue();
                                return val.split(',');
                            }
                        };
                    }

                    if (field.range) { // Can apply to integers, doubles, dates, strings, selects (count min/max)
                        filter.validation = {
                            min: field.range.min,
                            max: field.range.max
                        };
                    }

                    filter.data = lang.clone(field);

                    return filter;
                },

                _parseESRIField: function (field) {
                    var parsed = {
                        id: field.name,
                        label: field.alias || field.name,
                        type: this._convertFieldTypeFromESRI(field.type)
                    };

                    if (field.domain) {
                        if (field.domain.type === 'codedValue' && parsed.type === 'string') {
                            parsed.options = array.map(field.domain.codedValues, function (cv) {
                                return {
                                    id: cv.code,
                                    name: cv.name
                                };
                            });
                        } else if (field.domain.type === 'range') {
                            parsed.range = {
                                min: field.domain.minValue,
                                max: field.domain.maxValue
                            };
                        }
                    }

                    return parsed;
                },

                _convertFieldTypeFromESRI: function (esriType) {
                    switch (esriType) {
                    case 'esriFieldTypeSmallInteger':
                    case 'esriFieldTypeInteger':
                        return 'integer';
                    case 'esriFieldTypeSingle': // Can a single-precision float accept a double-precision float as query input?  For now we presume yes
                    case 'esriFieldTypeDouble':
                        return 'double';
                    case 'esriFieldTypeString':
                        return 'string';
                    case 'esriFieldTypeDate':
                        return 'datetime';
                    case 'esriFieldTypeOID':
                    case 'esriFieldTypeGlobalID':
                        return 'string';
                    case 'esriFieldTypeGeometry':
                    case 'esriFieldTypeBlob':
                    case 'esriFieldTypeRaster':
                    case 'esriFieldTypeXML':
                        // Complex types are currently unimplemented
                        return null;
                    default:
                        return esriType;
                    }
                },

                _showLoadingSpinner: function () {
                    //future
                },

                _hideLoadingSpinner: function () {
                    //future
                }
            };

            this.queryBuilder.init({
                targetDOM: this.divAdvancedQueryBuilder,
                map: this.map,
                layerJSON: this.layerJSON
            });
            return this.getQueryBuilder();
        },

        getQueryBuilder: function () {
            var layer = this.layers[this.attributeLayer];
            var searchIndex = this.searchIndex || 0;
            if (!layer || !layer.attributeSearches || !layer.attributeSearches[searchIndex]) {
                return when(null);
            }

            var search = layer.attributeSearches[searchIndex];

            if (!this.checkAdvancedSearchEnabled(layer, search)) {
                return when(null);
            }

            var advancedSearchOptions = lang.clone(search.advancedSearchOptions || layer.advancedSearchOptions || {});
            var queryParameters = lang.clone(search.queryParameters || layer.queryParameters || {});
            var where = this.getDefaultWhereClause(layer, search);
            return this.queryBuilder ? this.queryBuilder.setSearch({
                where: where,
                queryParameters: queryParameters,
                advancedSearchOptions: advancedSearchOptions
            }) : when(null);

        }
    });
});