import { or, alias } from '@ember/object/computed'; import Component from '@ember/component'; import Sortable from 'shared/mixins/sortable-base'; import StickyHeader from 'shared/mixins/sticky-table-header'; import layout from './template'; import pagedArray from 'ember-cli-pagination/computed/paged-array'; import { computed } from '@ember/object'; import { get, set } from '@ember/object'; import { inject as service } from '@ember/service' import { isArray } from '@ember/array'; import { observer } from '@ember/object' import { run } from '@ember/runloop'; import { isAlternate, isMore, isRange } from 'shared/utils/platform'; export function matches(fields, token, item) { let tokenMayBeIp = /^[0-9a-f\.:]+$/i.test(token); for ( let i = 0 ; i < fields.length ; i++ ) { let field = fields[i]; if ( field ) { // Modifiers: // id: The token must match id format (i.e. 1i123) let idx = field.indexOf(':'); let modifier = null; if ( idx > 0 ) { modifier = field.substr(idx+1); field = field.substr(0,idx); } let val = (get(item,field)+'').toLowerCase(); if ( !val ) { continue; } switch ( modifier ) { case 'exact': if ( val === token ) { return true; } break; case 'ip': if ( tokenMayBeIp ) { let re = new RegExp("(?:^|\.)" + token + "(?:\.|$)"); if ( re.test(val) ) { return true; } } break; case 'prefix': if ( val.indexOf(token) === 0) { return true; } break; default: if ( val.indexOf(token) >= 0) { return true; } } } } return false; } export default Component.extend(Sortable, StickyHeader, { layout, prefs: service(), intl: service(), bulkActionHandler: service(), body: null, groupByKey: null, groupByRef: null, preSorts: null, sortBy: null, descending: false, headers: null, extraSearchFields: null, extraSearchSubFields: null, prefix: false, suffix: false, bulkActions: true, rowActions: true, search: true, paging: true, subRows: false, checkWidth: 40, actionsWidth: 40, availableActions: null, selectedNodes: null, prevNode: null, searchText: null, isVisible: true, page: 1, pagingLabel: 'pagination.generic', showHeader: or('bulkActions','search','paging'), didReceiveAttrs: function() { this._super(...arguments); if (this.get('isVisible')) { this.triggerResize(); } }, init: function() { this._super(...arguments); this.set('selectedNodes', []); if (this.get('bulkActions')) { this.actionsChanged(); } run.schedule('afterRender', () => { let table = $(this.element).find('> TABLE'); let self = this; // need this context in click function and can't use arrow func there table.on('click', '> TBODY > TR', function(e) { self.rowClick(e); }); table.on('mousedown', '> TBODY > TR', function(e) { if ( isRange(e) || e.target.tagName === 'INPUT') { e.preventDefault(); } }); }); let watchKey = 'pagedContent.[]'; if ( this.get('groupByKey') ) { watchKey = `pagedContent.@each.${this.get('groupByKey').replace(/\..*/g,'')}`; } this.set('groupedContent', computed(watchKey, () => { let ary = []; let map = {}; let groupKey = this.get('groupByKey'); let refKey = this.get('groupByRef'); this.get('pagedContent').forEach((obj) => { let group = obj.get(groupKey)||''; let ref = obj.get(refKey); let entry = map[group]; if ( entry ) { entry.items.push(obj); } else { entry = {group: group, ref: ref, items: [obj]}; map[group] = entry; ary.push(entry); } }); return ary; })); }, perPage: computed('paging', 'prefs.tablePerPage', function() { if ( this.get('paging') ) { return this.get('prefs.tablePerPage'); } else { return 100000; } }), actions: { clearSearch() { this.set('searchText', ''); }, executeBulkAction(name, e) { e.preventDefault(); let handler = this.get('bulkActionHandler'); let nodes = this.get('selectedNodes'); if (isAlternate(e)) { var available= this.get('availableActions'); var action = available.findBy('action', name); let alt = get(action, 'altAction'); if ( alt ) { name = alt; } } if ( typeof handler[name] === 'function' ) { this.get('bulkActionHandler')[name](nodes); } else { nodes.forEach((node) => { node.send(name); }); } }, executeAction(action) { var node = this.get('selectedNodes')[0]; node.send(action); }, }, // ----- // Table content // Flow: body [-> sortableContent] -> arranged -> filtered -> pagedContent [-> groupedContent] // ----- sortableContent: alias('body'), pagedContent: pagedArray('filtered', { page: alias("parent.page"), perPage: alias("parent.perPage") }), // For data-title properties on s dt: computed('headers.@each.{name,label,translationKey}','intl.locale', function() { let intl = this.get('intl'); let out = { select: intl.t('generic.select') + ': ', actions: intl.t('generic.actions') + ': ', }; this.get('headers').forEach((header) => { let name = get(header, 'name'); let dtKey = get(header,'dtTranslationKey'); let key = get(header,'translationKey'); if ( dtKey ) { out[name] = intl.t(dtKey) + ': '; } else if ( key ) { out[name] = intl.t(key) + ': '; } else { out[name] = (get(header, 'label') || name) + ': '; } }); return out; }), // Pick a new sort if the current column disappears. headersChanged: observer('headers.@each.name', function() { let sortBy = this.get('sortBy'); let headers = this.get('headers')||[]; if ( headers && headers.get('length') ) { let cur = headers.findBy('name', sortBy); if ( !cur ) { run.next(this, function() { this.send('changeSort', headers.get('firstObject.name')); }); } } }), fullColspan: computed('headers.length','bulkActions','rowActions', function() { return (this.get('headers.length')||0) + (this.get('bulkActions') ? 1 : 0 ) + (this.get('rowActions') ? 1 : 0); }), searchFields: computed('headers.@each.{searchField,name}','extraSearchFields.[]', function() { let out = headersToSearchField(this.get('headers')); return out.addObjects(this.get('extraSearchFields')||[]); }), subFields: computed('subHeaders.@each.{searchField,name}','extraSearchSubFields.[]', function() { let out = headersToSearchField(this.get('subHeaders')); return out.addObjects(this.get('extraSearchSubFields')||[]); }), filtered: computed('arranged.[]','searchText', function() { let out = this.get('arranged').slice(); let searchFields = this.get('searchFields'); let searchText = (this.get('searchText')||'').trim().toLowerCase(); let subSearchField = this.get('subSearchField'); let subFields = this.get('subFields'); let subMatches = null; if ( searchText.length ) { subMatches = {}; let searchTokens = searchText.split(/\s*[, ]\s*/); for ( let i = out.length-1 ; i >= 0 ; i-- ) { let hits = 0; let row = out[i]; let mainFound = true; for ( let j = 0 ; j < searchTokens.length ; j++ ) { let expect = true; let token = searchTokens[j]; if ( token.substr(0,1) === '!' ) { expect = false; token = token.substr(1); } if ( token && matches(searchFields, token, row) !== expect ) { mainFound = false; break; } } if ( subFields && subSearchField) { let subRows = (row.get(subSearchField)||[]); for ( let k = subRows.length-1 ; k >= 0 ; k-- ) { let subFound = true; for ( let l = 0 ; l < searchTokens.length ; l++ ) { let expect = true; let token = searchTokens[l]; if ( token.substr(0,1) === '!' ) { expect = false; token = token.substr(1); } if ( matches(subFields, token, subRows[k]) !== expect ) { subFound = false; break; } } if ( subFound ) { hits++; } } subMatches[row.get('id')] = hits; } if ( !mainFound && hits === 0 ) { out.removeAt(i); } } } this.set('subMatches', subMatches); return out; }), cleanupOrphans() { // Remove selected items not in the current content let content = this.get('pagedContent'); let nodesToAdd = []; let nodesToRemove = []; this.get('selectedNodes').forEach((node) => { if ( content.includes(node) ) { nodesToAdd.push(node); } else { nodesToRemove.push(node); } }); this.toggleMulti(nodesToAdd, nodesToRemove); }, pagedContentChanged: observer('pagedContent.[]', function() { this.cleanupOrphans(); }), indexFrom: computed('page','perPage', function() { var current = this.get('page'); var perPage = this.get('perPage'); return Math.max(0, 1 + perPage*(current-1)); }), indexTo: computed('indexFrom','perPage','filtered.length', function() { return Math.min(this.get('filtered.length'), this.get('indexFrom') + this.get('perPage') - 1); }), pageCountContent: computed('indexFrom','indexTo','pagedContent.totalPages', function() { let from = this.get('indexFrom') || 0; let to = this.get('indexTo') || 0; let count = this.get('filtered.length') || 0; let pages = this.get('pagedContent.totalPages') || 0; let out = ''; if ( pages <= 1 ) { out = `${count} Item` + (count === 1 ? '' : 's'); } else { out = `${from} - ${to} of ${count}`; } return out; }), pageCountChanged: observer('indexFrom', 'filtered.length', function() { // Go to the last page if we end up past the last page let from = this.get('indexFrom'); let last = this.get('filtered.length'); var perPage = this.get('perPage'); if ( this.get('page') > 1 && from > last) { let page = Math.ceil(last/perPage); this.set('page', page); } }), sortKeyChanged: observer('sortBy', function() { this.set('page',1); }), // ------ // Clicking // ------ rowClick(e) { let tagName = e.target.tagName; let tgt = $(e.target); if ( tagName === 'A' || tagName === 'BUTTON' || tgt.parents('.btn').length || typeof tgt.data('ember-action') !== 'undefined' || tgt.hasClass('copy-btn') ) { return; } let content = this.get('pagedContent'); let selection = this.get('selectedNodes'); let isCheckbox = tagName === 'INPUT' || tgt.hasClass('row-check'); let tgtRow = $(e.currentTarget); if ( tgtRow.hasClass('separator-row') || tgt.hasClass('select-all-check')) { return; } while ( tgtRow && tgtRow.length && !tgtRow.hasClass('main-row') ) { tgtRow = tgtRow.prev(); } if ( !tgtRow || !tgtRow.length ) { return; } let nodeId = tgtRow.find('input[type="checkbox"]').attr('nodeid'); if ( !nodeId ) { return; } let node = content.findBy('id', nodeId); if ( !node ) { return; } let isSelected = selection.includes(node); let prevNode = this.get('prevNode'); // PrevNode is only valid if it's in the current content if ( !prevNode || !content.includes(prevNode) ) { prevNode = node; } if ( isMore(e) ) { this.toggleSingle(node); } else if ( isRange(e) ) { let toToggle = this.nodesBetween(prevNode, node); if ( isSelected ) { this.toggleMulti([], toToggle); } else { this.toggleMulti(toToggle,[]); } } else if ( isCheckbox ) { this.toggleSingle(node); } else { this.toggleMulti([node], content); } this.set('prevNode', node); }, nodesBetween(a,b) { let toToggle = []; let key = this.get('groupByKey'); if ( key ) { // Grouped has 2 levels to look through let grouped = this.get('groupedContent'); let from = this.groupIdx(a); let to = this.groupIdx(b); if ( !from || !to ) { return []; } // From has to come before To if ( (from.group > to.group) || ((from.group === to.group) && (from.item > to.item)) ) { [from, to] = [to,from]; } for ( let i = from.group ; i <= to.group ; i++ ) { let items = grouped.objectAt(i).items; let j = (from.group === i ? from.item : 0); while ( items[j] && ( i < to.group || j <= to.item )) { toToggle.push(items[j]); j++; } } } else { // Ungrouped is much simpler let content = this.get('pagedContent'); let from = content.indexOf(a); let to = content.indexOf(b); [from, to] = [Math.min(from,to), Math.max(from,to)]; toToggle = content.slice(from,to+1); } return toToggle; }, groupIdx(node) { let grouped = this.get('groupedContent'); for ( let i = 0 ; i < grouped.get('length') ; i++ ) { let items = grouped.objectAt(i).items; for ( let j = 0 ; j < items.get('length') ; j++ ) { if ( items.objectAt(j) === node ) { return {group: i, item: j}; } } } return null; }, isAll: computed('selectedNodes.length', 'pagedContent.length', { get() { return this.get('selectedNodes.length') === this.get('pagedContent.length'); }, set(key, value) { var content = this.get('pagedContent'); if ( value ) { this.toggleMulti(content, []); return true; } else { this.toggleMulti([], content); return false; } } }), toggleSingle(node) { let selectedNodes = this.get('selectedNodes'); if ( selectedNodes.includes(node) ) { this.toggleMulti([], [node]); } else { this.toggleMulti([node], []); } }, toggleMulti(nodesToAdd, nodesToRemove) { let selectedNodes = this.get('selectedNodes'); if (nodesToRemove.length) { // removeObjects doesn't use ArrayProxy-safe looping if ( typeof nodesToRemove.toArray === 'function' ) { nodesToRemove = nodesToRemove.toArray(); } selectedNodes.removeObjects(nodesToRemove); toggle(nodesToRemove, false); } if (nodesToAdd.length) { selectedNodes.addObjects(nodesToAdd); toggle(nodesToAdd, true); } function toggle(nodes, on) { run.next(function() { nodes.forEach((node) => { let id = get(node,'id'); if ( id ) { let input = $(`input[nodeid=${id}]`); if ( input && input.length ) { // can't reuse the input ref here because the table has rerenderd and the ref is no longer good $(`input[nodeid=${id}]`).prop('checked', on); let tr = $(`input[nodeid =${id}]`).closest('tr'); let first = true; while ( tr && (first || tr.hasClass('sub-row') ) ) { tr.toggleClass('row-selected', on); tr = tr.next(); first = false; } } } }); }); } }, actionsChanged: observer('selectedNodes.@each.availableActions','pagedContent.firstObject.availableActions', function() { if (!this.get('bulkActions')) { return; } let nodes = this.get('selectedNodes'); let out = null; let disableAll = false; if ( !nodes.length ) { disableAll = true; let firstNode = this.get('pagedContent.firstObject'); if ( firstNode ) { nodes = [firstNode]; } } if (nodes.length >= 1) { // Find all the bulkable actions from the first item (all items have all the actions, but some will be disabled out = nodes.get('firstObject.availableActions').filterBy('bulkable',true).map((act) => { return $().extend(true, {}, act); }); // Make a list of ones that should be disabled because they are not available for one or more selected items let toDisable = []; nodes.forEach((node) => { let bad; if ( disableAll ) { bad = get(node,'availableActions').map(act => act.action); } else { bad = get(node,'availableActions').filterBy('enabled',false).map(act => act.action); } toDisable.addObjects(bad); }); // Disable the bulk actions from the toDisable list toDisable.forEach((name) => { let obj = out.findBy('action', name); if ( obj ) { set(obj, 'enabled', false); } }); } this.set('availableActions', out); }), }); function headersToSearchField(headers) { let out = []; (headers||[]).forEach((header) => { let field = get(header, 'searchField'); if ( field ) { if ( typeof field === 'string' ) { out.addObject(field); } else if ( isArray(field) ) { out.addObjects(field); } } else if ( field === false ) { // Don't add the name } else { out.addObject(get(header,'name')); } }); return out.filter(x => !!x); }