(function () {
'use strict';
angular
.module('fedramp.components')
.component('gridFilter', {
controller: GridFilter,
controllerAs: 'controller',
templateUrl: 'templates/components/grid-filter.html',
require: {
// We require that this component live inside of <grid></grid> so it can
// communicate and share information
gridController: '^grid'
},
bindings: {
// The property on a list to manage. Specify a property expression here.
property: '@',
// User friendly text to describe filter
header: '@',
// Any options that can be selected by this filter. If none are passed in, a distinct list
// of all possible values for the current property are populated.
options: '<',
// Load any initial selected values. For instance, to restore filter state. In the form
// {value: <value>, label: <label>, selected: <boolean>}
selectedOptionValues: '<',
// Whether to initially render expanded mode with panels opened
opened: '<',
// Custom function that returns available filter options. Defaults to default func if not provided
optionsFunc: '<',
// Custom function that performs a comparison when filtering. Defaults to default func if not provides
filterFunc: '<',
// Identifier for filter
id: '@'
}
});
GridFilter.$inject = ['$location', '$element', '$httpParamSerializer', 'Searcher'];
/**
* A generic filtering component that utilizes a property expression to extract all available options for a given
* property.
*
* If a property expression is not provided, a custom filterFunc and optionsFunc must be provided
* Requires to be nested within a [Grid]{@link Components.Grid} component.
*
* @example
* // Given the following object being searched:
*
* {
* name: 'John Doe',
* nickname: 'JD',
* counts: [1,2,3,4],
* products: [{
* name: 'Prod',
* related: [{
* relatedItemName: 'Some related item'
* }]
* }]
*
* }
*
*
*
* Property expression examples:
*
* 'i.relatedItemName in products.related' => would search everything in relatedItemName
* 'i.name in products' => would search everything in the name key in products
* 'i in counts' => would searching everything in the counts array
* 'nickname' => would search in nickname
* If a property expression is not provided, a filterFunc and optionsFunc must be provided.
*
* @example
* // Example HTML
* <grid-filter property="name" header="Name" options="" opened="true"></grid-filter>
*
* // Example of custom filterFunc
*
* function myCustomFilterFunc(myObj, index, arr, selectedOptions){
* selectedOptionValues.forEach(function(selectedOption){
* if(myObj.someProperty === selectedOption.value){
* return myObj;
* }
* return null;
* });
* }
*
* // Example of custom optionsFunc
* function myCustomOptionsFunc(dataset){
* return [{
* label: 'Option 1',
* value: 'My val'
* }];
* }
*
* @constructor
* @memberof Components
*/
function GridFilter ($location, $element, $httpParamSerializer, Searcher) {
var self = this;
var selectedCss = 'grid-filter-selected';
var OBJECT_PARAM_REGEX = /\:\((.+?)\),{0,}/;
var PARAM_DELIMITER = ';';
// Options available to filter based on property
self.options = [];
// Options that have been selected
self.selectedOptionValues = self.selectedOptionValues || [];
// List of filtered data based on this particular filter
self.filtered = [];
// Whether to initially render expanded mode with panels opened
self.opened = (angular.isDefined(self.opened) ? self.opened : true);
// Exposed public functions
self.$onInit = $onInit;
self.applyFilter = applyFilter;
self.selectOption = selectOption;
self.clear = clear;
self.loadOptions = loadOptions;
self.restoreState = restoreState;
self.toggleExpand = toggleExpand;
function $onInit () {
if (!self.id) {
throw 'Please add an id attribute';
}
if (!self.property && (!self.optionsFunc || !self.filterFunc)) {
throw 'If property is not specified, optionsFunc and filterFunc must be passed in';
}
// Allow custom optionsFunc and filterFuncs to be passed for custom filtering
self.optionsFunc = self.optionsFunc || optionsFunc;
// Wrap custom func
self.filterFunc = (self.filterFunc ? wrapFilterFunc(self.filterFunc) : filterFunc);
// We give the parent controller a reference to this filter
self.gridController.addFilter(self);
// If no options have been loaded, we load default ones
// Also, if tab is not expanded, then don't load anything
if (self.opened && self.options.length === 0) {
self.loadOptions(self.gridController.rawItems);
}
restoreState();
}
/**
* Checks if any relevant query params exist containing filter values to load and then
* adds them. Non-primitive objects are stored in the query param as follows:
*
* paramName=:(<json_representation>),:(<json_representation>)
*
* @public
* @memberof Components.GridFilter
*
*/
function restoreState () {
var params = self.gridController.state;
if (!(self.id in params)) {
return null;
}
var values = params[self.id];
var selected = [];
self.opened = true;
self.loadOptions(self.gridController.rawItems);
// Check if loading non-primitive object
var m = values.match(OBJECT_PARAM_REGEX);
if (m) {
// Split the values, remove empty values, convert to an array
// of Javascript objects, and then loop through each item.
values = values
.split(OBJECT_PARAM_REGEX)
.filter(Boolean)
.map(x => angular.fromJson(x))
.forEach(function (val) {
selected.push({
value: val,
selected: true
});
self.options.forEach(function (option) {
if (angular.equals(option.value, val)) {
option.selected = true;
}
});
});
} else {
// Handle basic primitive options
values.split(PARAM_DELIMITER).forEach(function (val) {
selected.push({
value: (val),
selected: true
});
self.options.forEach(function (option) {
if (option.value === val) {
option.selected = true;
}
});
});
}
// Ensure label is set for options
selected.forEach(x => {
self.options.forEach(o => {
if(angular.equals(o.value, x.value)){
x.label = o.label;
}
});
});
self.selectedOptionValues = selected;
if (self.selectedOptionValues) {
applyFilter();
}
}
/**
* Toggles the selection of an option and then executes filter.
* @public
* @memberof Components.GridFilter
*/
function selectOption (option) {
option.selected = !option.selected;
var pos = self.selectedOptionValues.findIndex(x => angular.equals(x.value,option.value));
if (pos == -1) {
self.selectedOptionValues.push(option);
} else {
self.selectedOptionValues.splice(pos,1);
}
saveState();
applyFilter();
}
/**
* Stores all selected values to the grid state. Primitive values get stored as a comma-separated list
* of strings. Non-primitive values (objects) get stored as a json string.
*
* @public
* @memberof Components.GridFilter
*/
function saveState () {
if (self.selectedOptionValues && self.selectedOptionValues.length > 0) {
var options = self.selectedOptionValues.map(function (option) {
// When non-primitive object, store as json
if (angular.isObject(option.value)) {
return ':(' + angular.toJson(option.value) + ')';
}
// Handle basic primitive value
return option.value;
}).join(PARAM_DELIMITER);
self.gridController.state[self.id] = options;
} else {
delete self.gridController.state[self.id];
}
}
/**
* Filter using current property or filterFunc to populate a list containing items relevant to current filter.
* Then, we call the doFilter() on the parent gridController which will consolidate and merge all filtered
* data from other filters.
* @public
* @memberof Components.GridFilter
*/
function applyFilter () {
toggleCss();
self.filtered = self.gridController.rawItems.filter(self.filterFunc);
self.gridController.doFilter();
}
/**
* Executes a filter on the current data set.
*
* If custom behavior is required, this method may be overriden by passing in
* a function for this component.
*
* @param {object} obj Current object in dataset being filtered
* @param {int} index Index of current object
* @param {array} arr Array containing entire dataset being filtered
* @param {array} selectedOptionValues Array of options that have been selected by the user
*
* @return
* whether an object was found within the selected options.
*/
function filterFunc (obj, index, arr, selectedOptionValues) {
// When no option is selected, return everything
if (self.selectedOptionValues.length === 0) {
return obj;
}
return self.selectedOptionValues.find(function (option) {
let found = new Searcher().prop(self.property).equals(obj, option.value);
if (found.length > 0) {
return obj;
}
return null;
});
}
/**
* Loads an array of available options
*
* @public
* @memberof Components.GridFilter
*
* @param {array} source
* Dataset from which to generate available options from.
*/
function loadOptions (source) {
self.options = self.optionsFunc(source);
}
/**
* Creates a set of options for a particular property to be filtered on. If an optionsFunc
* is NOT passed in, utilizes the property expression passed into `property` to automatically
* traverse the dataset for available options.
*
* You can also override this method to add custom options.
*
* @example
* //Sample dataset to return if overriding
* {
* value: value,
* label: label,
* selected: boolean
* }
*
* @public
* @memberof Components.GridFilter
*
* @param {array} source
* The dataset to generate options from.
*
* @returns {array}
* An array of options that can be selected to filter.
*/
function optionsFunc (source) {
var options = [];
var cache = {};
new Searcher().prop(self.property).criteriaFunc(source, function (obj, value) {
if (!cache[value]) {
cache[value] = true;
options.push({
label: value,
value: value,
selected: false
});
return value;
}
});
options.sort(function (o1, o2) {
if (o1.label < o2.label) {
return -1;
}
if (o1.label > o2.label) {
return 1;
}
return 0;
});
return options;
}
/**
* Clears filter and resets dataset
* @public
* @memberof Components.GridFilter
*/
function clear () {
self.selectedOptionValues = [];
self.options.forEach(x => x.selected = false);
delete self.gridController.state[self.id];
applyFilter();
}
/**
* Wraps a custom filter func with some additonal pre-processing logic to ensure
* that a filter without any selected options is returned. We also ensure to pass an additonal
* parameter selectedOptionValues to the callers.
*
* @public
* @memberof Components.GridFilter
*/
function wrapFilterFunc (func) {
return function (obj, index, arr) {
if (self.selectedOptionValues.length === 0) {
return obj;
}
return func(obj, index, arr, self.selectedOptionValues);
};
}
/**
* Toggles the selected css class.
*
* @public
* @memberof Components.GridFilter
*/
function toggleCss () {
if (self.selectedOptionValues.length > 0) {
$element.addClass(selectedCss);
} else {
$element.removeClass(selectedCss);
}
}
/**
* Toggles the opening and closing of filter options. If a filter was initially closed,
* the options are then generated.
*/
function toggleExpand () {
if (!self.opened && self.options.length === 0) {
self.loadOptions(self.gridController.rawItems);
}
self.opened = !self.opened;
}
}
})();