blob: bf12ef28e0f5236e95609667a8cce5f270654c85 [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import angular from 'angular';
import jssha from 'jssha';
import brUtils from '../utils/general';
import 'angular-multiple-transclusion';
import template from './index.html';
const TEMPLATE_CONTAINER_URL = 'br/template/table/table.html';
const MODULE_NAME = 'brooklyn.components.table';
angular.module(MODULE_NAME, ['angular-multiple-transclusion', brUtils])
.directive('brTable', ['$log', brTableDirective])
.directive('bindHtmlCompile', ['$compile', brBindHtmlCompile])
.filter('deepFilter', ['$filter', '$parse', brDeepFilter])
.run(['$templateCache', brTableRun]);
export default MODULE_NAME;
/**
* BASIC
*
* <br-table ng-model="data" columns="columns">
*
* where for example `data = [ { name: "Babs", age: 20 }, { name: "Bob", age: 21 } ]`
* and `columns = [ { field: 'name' }, { header: 'Age', template: '{{ item.age }} years old' } ]`
*
*
* COMPLETE
*
* <br-table
* ng-model="..." column="..." // required, as above
* row-ui-state="app" row-ui-state-params="getUiStateParams" // ui-router state to redirect on row click, with function giving parameters
* col-width="100" // minimum size in px to require for underlying <table> columns (unless overridden)
* >
*
* Each column map entry may also include:
* field, // optional, if supplied it provides a default for header, template, orderBy, and id
* header, // required, unless field is specified in which case it provides a default value (with "camelCaseEXAMPLE" rendered as "Camel Case EXAMPLE")
* template, templateUrl, // exactly one of these is required, unless field is specified in which case `{{ item[field] }}` is a default value
* orderBy: 'name', // a field on the object to use for sorting; if omitted, and field is not set, the column is not sortable
* id: 'name', // a unique ID for the column, used for column-specific searching, and set as a class for styling;
* this ID is used in regexes so should not contain spaces or regex special characters (`.` or `(`);
* if omitted, `field` is used if specified, otherwise a `col-N` identifier is used (and column-specific searches are not offered as suggestions)
* tdClass: 'fancy-column', // a string to set as a class on the <td> items in this column; if omitted and `id` is explicit, that is used as a default; failing that no column-specific classes are set
* colspan: 3, // number of <table> columns to use for this logical column - useful to control relative widths as the <table> columns are all the same width
* width: 100, // width of the column in px; if used with colspan this applies to _one_ column and the colspan-1 others are an additional relative width
* hidden: false, // whether column is initially hidden
*
*
* WIDTHS
*
* The combo of colspan and width allows fine control over column widths, e.g.:
*
* [ { header: "A", width: 200, colspan: 2 }, { header: "B", colspan: 3 } ]
* will set up a table with 5 <table> columns, with A spanning 2 and B spanning 3.
* One of A's columns is fixed at 200px; the other 4 columns share equally the remaining width.
* So the table's minimum width is 200px, and at that size B will not be visible.
* At 300px, there is 100px "remaining" split among the four regular columns, so
* A gets 225px (a 200px column plus one regular column) and B gets 75x (three regular columns).
* At 600px, the two logical columns - A and B - are equally sized at 300px.
* Beyond this, column B is wider.
*
* If additionally `col-width="100"` is set on the `br-table`, then the table will start
* with minimum width 600px (possibly requiring horizontal scrolling to see in a small browser).
* Individual columns can be resized by a user subsequently.
*
* For convenience (and compatibility for tables before colspan support was added)
* if no column entries define a colspan, the table layout is auto,
* with any explicit `width` being respected and other columns based on content width.
*
*
* SEARCH
*
* When searching, phrases in double quotes "like this" search for that group of words with no spaces
* using angular case-insensitive containment (non-regex).
* Any remaining term of the form `key:value` where `key` is one of the column's `id`
* will do a regex search for that value in the respective column.
* All other terms are searched for as individual words using angular case-insensitive containment (non-regex).
* The angular (non-regex) search treats a leading `!` as negation, to search for entries _not_ containing the term.
*/
export function brTableDirective($log) {
return {
require: 'ngModel',
transclude: true,
restrict: 'E',
link: link,
controller: ['$templateCache', 'brUtilsGeneral', controller],
controllerAs: 'ctrl',
scope: true,
templateUrl: function(element, attrs) {
return attrs.templateUrl || TEMPLATE_CONTAINER_URL;
}
};
function link(scope, element, attrs, ngModelCtrl) {
scope.ctrl.rowUiState = attrs.rowUiState;
scope.ctrl.rowUiStateParams = scope.$eval(attrs.rowUiStateParams);
scope.ctrl.state = {
columns: scope.$eval(attrs.columns),
sorts: [],
search: '',
filters: {}
};
if (!(scope.ctrl.state.columns instanceof Array)) {
throw new Error('Field "columns" in table options must be of type "Array"');
}
scope.ctrl.state.columnSpanCount = 0;
scope.ctrl.state.columnExplicitWidthCount = 0;
scope.ctrl.state.columnExplicitWidthSum = 0;
scope.ctrl.tableLayoutFixed = false;
scope.ctrl.state.columns.forEach((column, index) => {
if (!(column instanceof Object)) {
throw new Error(`Column with index "${index}" must be of type "Object"`);
}
let field = column.field;
if (!column.hasOwnProperty('header')) {
if (field) {
column.header = field.replace(/([a-z])([A-Z])/g, (_, a, A) => a+' '+A).replace(/^([a-z])(.*)/, (_, a, bc) => a.toUpperCase()+bc)
} else {
throw new Error(`Column with index ${index} does not has the required field "header"`);
}
}
if (!column.hasOwnProperty('template') && !column.hasOwnProperty('templateUrl')) {
if (field) {
column.template = `{{ item['${field}'] }}`;
} else {
throw new Error(`Column with index ${index} requires either "template" or "templateUrl" field`);
}
}
if (!column.hasOwnProperty('id')) {
if (field) {
column.id = field;
} else {
column.id = 'col-'+index;
column.idAutogenerated = true;
}
} else {
column.tdClass = column.tdClass || column.id;
}
if (!column.hasOwnProperty('orderBy') && field) {
column.orderBy = field;
}
column.hidden = column.hidden || false;
column.regex = new RegExp(`(?:\\s|^)${column.id}:(\\S*)(?:\\s|$)`, 'i')
if (!column.idAutogenerated) {
column.idForTypeahead = column.id;
}
});
function recomputeSpanCount() {
// this should be recomputed when columns are hidden/shown
// without this, the "No results" message may be slightly too wide when columns are hidden
var columnSpanCount = 0,
columnExplicitWidthCount = 0,
columnExplicitWidthSum = 0,
tableLayoutFixed = false;
scope.ctrl.state.columns.forEach((column, index) => {
if (column.hidden) return;
columnSpanCount += (column.colspan || 1);
tableLayoutFixed |= column.colspan;
if (column.width) {
columnExplicitWidthCount ++;
columnExplicitWidthSum += column.width;
}
});
scope.ctrl.state.columnSpanCount = columnSpanCount;
scope.ctrl.state.columnExplicitWidthCount = columnExplicitWidthCount;
scope.ctrl.state.columnExplicitWidthSum = columnExplicitWidthSum;
scope.ctrl.tableLayoutFixed = tableLayoutFixed;
if (attrs.colWidth) {
var minWidth = (scope.ctrl.state.columnExplicitWidthSum +
(scope.ctrl.state.columnSpanCount - scope.ctrl.state.columnExplicitWidthCount) * attrs.colWidth);
if (isNaN(parseFloat(minWidth)) || !isFinite(minWidth)) {
// not a valid number in the end: could install units-css library and do unit maths, but not worth it
$log.error(`Error computing column widths (got ${scope.ctrl.minWidth}): ensure no values have units`);
} else {
scope.ctrl.minWidth = minWidth + 'px';
}
}
}
recomputeSpanCount();
scope.hideColumn = (column,) => {
column.hidden = !column.hidden;
recomputeSpanCount();
};
let sha = new jssha('SHA-512', 'TEXT');
sha.update(scope.ctrl.rowUiState || '');
sha.update(JSON.stringify(scope.ctrl.rowUiStateParams) || '');
sha.update(JSON.stringify(scope.ctrl.state.columns) || '');
let hash = sha.getHash('HEX');
if (sessionStorage) {
let state = sessionStorage.getItem(`${MODULE_NAME}.state.${hash}`);
if (state !== null) {
scope.ctrl.state = Object.assign(scope.ctrl.state, JSON.parse(state));
scope.ctrl.state.columns.forEach(column => column.regex = new RegExp(`(?:\\s|^)${column.id}:(\\S*)(?:\\s|$)`, 'i'));
}
scope.$watch('ctrl.state', (newValue, oldValue) => {
if (!angular.equals(newValue, oldValue)) {
sessionStorage.setItem(`${MODULE_NAME}.state.${hash}`, JSON.stringify(newValue));
}
}, true);
}
scope.$watchCollection('ctrl.items', function(value) {
ngModelCtrl.$setViewValue(value);
ngModelCtrl.$validate();
});
ngModelCtrl.$render = function() {
if (!ngModelCtrl.$viewValue) {
ngModelCtrl.$viewValue = [];
}
scope.ctrl.items = ngModelCtrl.$viewValue;
};
scope.$applyAsync(() => {
element[0].querySelectorAll('th div').forEach(elm => {
angular.element(elm).data('initialWidth', elm.offsetWidth);
});
element[0].querySelectorAll('span.column-resizer').forEach(elm => {
elm.ondragstart = function() { return false; };
elm.addEventListener('mousedown', function(e) {
if (e.which === 1) {
// left mouse click
scope.ctrl.dragStart(e);
}
}, false);
});
});
scope.$watch('ctrl.state.search', (newValue, oldValue) => {
if (newValue === oldValue) {
return;
}
let filters = {};
let remaining = newValue;
let words = [];
// get any phrases in double quotes
let qw = /(?:\s|^)"([^"]*)"(?:\s|$)/;
var match;
while (match=qw.exec(remaining)) {
words.push(match[1]);
remaining = remaining.replace(qw, ' ');
}
// now get anything that matches column prefix
scope.ctrl.state.columns.forEach(column => {
if (column.hidden) {
return;
}
let matches = remaining.match(column.regex);
if (matches === null) {
return;
}
filters[column.id] = matches[1];
remaining = remaining.replace(column.regex, ' ');
});
// remaining items are split
remaining = remaining.trim();
if (remaining.length > 0) {
words = words.concat(remaining.split(/\s+/));
}
words = words.length==0 ? null : words.length==1 ? words[0] : words;
if (Object.keys(filters).length > 0) {
if (words) {
filters[''] = words;
}
}
scope.ctrl.state.filters = Object.keys(filters).length > 0 ? filters : words;
});
}
function findAncestor($el, tag) {
while ($el[0] && $el[0].tagName.toUpperCase() != tag.toUpperCase()) {
$el = $el.parent();
};
return $el;
}
function controller($templateCache, brUtilsGeneral) {
this.getColumnTemplate = (id) => {
let column = this.state.columns.find(column => column.id === id);
return column.hasOwnProperty('templateUrl') ? $templateCache.get(column.templateUrl) : column.template;
};
this.dragStart = (e) => {
this.resizerTarget = angular.element(e.target);
this.thTarget = findAncestor(this.resizerTarget, 'th');
this.tableTarget = findAncestor(this.thTarget, 'table');
if (!this.tableTarget[0]) throw new Error('Resizer tag hierarchy not as expected; cannot drag');
this.tableWidth = this.tableTarget[0].offsetWidth;
if (this.tableTarget[0].style.minWidth != 0) {
// if a width is defined, we need to hardcode all column widths
// this.tableTarget.find('colgroup').children().forEach(
angular.forEach( this.tableTarget.find('thead').find("tr").children(), th => { th.width = th.offsetWidth; } );
angular.forEach( this.tableTarget.find('colgroup').children(), col => { col.style.width = null; } );
this.tableTarget[0].style.minWidth = 0;
}
this.width = this.thTarget[0].offsetWidth;
this.start = e.clientX;
document.addEventListener('mouseup', this.dragEnd, false);
document.addEventListener('mousemove', this.dragging, false);
this.resizerTarget.addClass('dragging');
// Disable highlighting while dragging
if (e.stopPropagation) e.stopPropagation();
if (e.preventDefault) e.preventDefault();
};
this.dragging = (e) => {
// 23px wide is bare minimum given padding settings
// if user goes below this we could give some visual indication the column is being hidden
var newWidth = Math.max(this.width - this.start + e.clientX, 23);
this.thTarget[0].style['width'] = `${newWidth}px`;
this.tableTarget[0].style['width'] = `${this.tableWidth + newWidth - this.width}px`;
};
this.dragEnd = (e) => {
document.removeEventListener('mouseup', this.dragEnd, false);
document.removeEventListener('mousemove', this.dragging, false);
this.resizerTarget.removeClass('dragging');
};
this.sortBy = (orderBy) => {
let sort = '+';
let currentOrderBy = this.getSortByFrom(orderBy);
let currentOrderByIndex = this.state.sorts.indexOf(currentOrderBy);
if (currentOrderBy) {
let currentSort = this.getSortByDirectionFrom(orderBy);
if (currentSort === '-') {
this.state.sorts.splice(currentOrderByIndex, 1);
return;
}
if (currentSort === '+') {
sort = '-';
}
this.state.sorts.splice(currentOrderByIndex, 1, `${sort}${orderBy}`);
} else {
this.state.sorts.push(`${sort}${orderBy}`);
}
};
this.getSortByFrom = (orderBy) => {
return this.state.sorts.find(sort => sort.substr(1) === orderBy);
};
this.getSortByDirectionFrom = (orderBy) => {
let sortBy = this.getSortByFrom(orderBy);
return sortBy ? sortBy.slice(0, 1) : '';
};
this.suggestionFormatter = (input) => {
return brUtilsGeneral.isNonEmpty(input) ? `${input}:` : '';
};
}
}
export function brBindHtmlCompile($compile) {
return {
restrict: 'A',
link: function (scope, element, attrs) {
scope.$watch(function () {
return scope.$eval(attrs.bindHtmlCompile);
}, function (value) {
element.html(value);
$compile(element.contents())(scope);
});
}
};
}
export function brDeepFilter($filter, $parse) {
return function(input, opts) {
if (angular.isString(opts)) {
return $filter('filter')(input, opts);
}
if (angular.isArray(opts)) {
opts = { '': opts };
}
if (angular.isObject(opts) && Object.keys(opts).length > 0) {
return input.filter(item => {
return Object.keys(opts).reduce((ret, key) => {
if (!ret) return false;
if (key == '') {
// empty key used for text search
let searchList = opts[key];
if (!angular.isArray(searchList)) searchList = [ searchList ];
return searchList.every( word => $filter('filter')([ item ], word).length );
}
let value = $parse(key)(item);
if (!value) return false;
let vv = JSON.stringify(value);
if (vv[0]=='"') vv = vv.slice(1, vv.length - 1);
return new RegExp(opts[key], 'ig').test(vv);
}, true);
});
}
return input;
};
}
export function brTableRun($templateCache) {
$templateCache.put(TEMPLATE_CONTAINER_URL, template);
}