ui-modules/utils/table/index.js (304 lines of code) (raw):

/* * 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); }