mirror of https://github.com/rancher/dashboard.git
618 lines
15 KiB
JavaScript
618 lines
15 KiB
JavaScript
import $ from 'jquery';
|
|
import { isMore, isRange, suppressContextMenu, isAlternate } from '@shell/utils/platform';
|
|
import { get } from '@shell/utils/object';
|
|
import { filterBy } from '@shell/utils/array';
|
|
|
|
export const ALL = 'all';
|
|
export const SOME = 'some';
|
|
export const NONE = 'none';
|
|
|
|
export default {
|
|
mounted() {
|
|
const $table = $('> TABLE', this.$el);
|
|
|
|
this._onRowClickBound = this.onRowClick.bind(this);
|
|
this._onRowMousedownBound = this.onRowMousedown.bind(this);
|
|
this._onRowContextBound = this.onRowContext.bind(this);
|
|
|
|
$table.on('click', '> TBODY > TR', this._onRowClickBound);
|
|
$table.on('mousedown', '> TBODY > TR', this._onRowMousedownBound);
|
|
$table.on('contextmenu', '> TBODY > TR', this._onRowContextBound);
|
|
},
|
|
|
|
beforeDestroy() {
|
|
const $table = $('> TABLE', this.$el);
|
|
|
|
$table.off('click', '> TBODY > TR', this._onRowClickBound);
|
|
$table.off('mousedown', '> TBODY > TR', this._onRowMousedownBound);
|
|
$table.off('contextmenu', '> TBODY > TR', this._onRowContextBound);
|
|
},
|
|
|
|
computed: {
|
|
// Used for the table-level selection check-box to show checked (all selected)/intermediate (some selected)/unchecked (none selected)
|
|
howMuchSelected() {
|
|
const total = this.pagedRows.length;
|
|
const selected = this.selectedRows.length;
|
|
|
|
if ( selected >= total && total > 0 ) {
|
|
return ALL;
|
|
} else if ( selected > 0 ) {
|
|
return SOME;
|
|
}
|
|
|
|
return NONE;
|
|
},
|
|
|
|
// NOTE: The logic here could be simplified and made more performant
|
|
bulkActionsForSelection() {
|
|
let disableAll = false;
|
|
// pagedRows is all rows in the current page
|
|
const all = this.pagedRows;
|
|
const allRows = this.arrangedRows;
|
|
let selected = this.selectedRows;
|
|
|
|
// Nothing is selected
|
|
if ( !this.selectedRows.length ) {
|
|
// and there are no rows
|
|
if ( !allRows ) {
|
|
return [];
|
|
}
|
|
|
|
const firstNode = allRows[0];
|
|
|
|
selected = firstNode ? [firstNode] : [];
|
|
disableAll = true;
|
|
}
|
|
|
|
const map = {};
|
|
|
|
// Find and add all the actions for all the nodes so that we know
|
|
// what all the possible actions are
|
|
for ( const node of all ) {
|
|
if (node.availableActions) {
|
|
for ( const act of node.availableActions ) {
|
|
if ( act.bulkable ) {
|
|
_add(map, act, false);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Go through all the selected items and add the actions (which were already identified above)
|
|
// as available for some (or all) of the selected nodes
|
|
for ( const node of selected ) {
|
|
if (node.availableActions) {
|
|
for ( const act of node.availableActions ) {
|
|
if ( act.bulkable && act.enabled ) {
|
|
_add(map, act, false);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// If there's no items actually selected, we want to see all the actions
|
|
// so you know what exists, but have them all be disabled since there's nothing to do them on.
|
|
const out = _filter(map, disableAll);
|
|
|
|
// Enable a bulkaction if some of the selected items can perform the action
|
|
out.forEach((bulkAction) => {
|
|
const actionEnabledForSomeSelected = this.selectedRows.some((node) => {
|
|
const availableActions = node.availableActions || [];
|
|
|
|
return availableActions.some(action => action.action === bulkAction.action && action.enabled);
|
|
});
|
|
|
|
bulkAction.enabled = this.selectedRows.length > 0 && actionEnabledForSomeSelected;
|
|
});
|
|
|
|
return out.sort((a, b) => (b.weight || 0) - (a.weight || 0));
|
|
}
|
|
},
|
|
|
|
data() {
|
|
return {
|
|
// List of selected items in the table
|
|
selectedRows: [],
|
|
prevNode: null,
|
|
};
|
|
},
|
|
|
|
watch: {
|
|
// On page change
|
|
pagedRows() {
|
|
// When the table contents changes:
|
|
// - Remove items that are in the selection but no longer in the table.
|
|
|
|
const content = this.pagedRows;
|
|
const toRemove = [];
|
|
|
|
for (const node of this.selectedRows) {
|
|
if (!content.includes(node) ) {
|
|
toRemove.push(node);
|
|
}
|
|
}
|
|
|
|
this.update([], toRemove);
|
|
}
|
|
},
|
|
|
|
methods: {
|
|
onToggleAll(value) {
|
|
if ( value ) {
|
|
this.update(this.pagedRows, []);
|
|
|
|
return true;
|
|
} else {
|
|
this.update([], this.pagedRows);
|
|
|
|
return false;
|
|
}
|
|
},
|
|
|
|
onRowMousedown(e) {
|
|
if ( isRange(e) || this.isSelectionCheckbox(e.target) ) {
|
|
e.preventDefault();
|
|
}
|
|
},
|
|
|
|
onRowMouseEnter(e) {
|
|
const tr = $(e.target).closest('TR');
|
|
|
|
if (tr.hasClass('sub-row')) {
|
|
const trMainRow = tr.prev('TR');
|
|
|
|
trMainRow.toggleClass('sub-row-hovered', true);
|
|
}
|
|
},
|
|
|
|
onRowMouseLeave(e) {
|
|
const tr = $(e.target).closest('TR');
|
|
|
|
if (tr.hasClass('sub-row')) {
|
|
const trMainRow = tr.prev('TR');
|
|
|
|
trMainRow.toggleClass('sub-row-hovered', false);
|
|
}
|
|
},
|
|
|
|
nodeForEvent(e) {
|
|
const tagName = e.target.tagName;
|
|
const tgt = $(e.target);
|
|
const actionElement = tgt.closest('.actions')[0];
|
|
|
|
if ( tgt.hasClass('select-all-check') ) {
|
|
return;
|
|
}
|
|
|
|
if ( !actionElement ) {
|
|
if (
|
|
tagName === 'A' ||
|
|
tagName === 'BUTTON' ||
|
|
tgt.parents('.btn').length
|
|
) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
const tgtRow = $(e.currentTarget);
|
|
|
|
return this.nodeForRow(tgtRow);
|
|
},
|
|
|
|
nodeForRow(tgtRow) {
|
|
if ( tgtRow?.hasClass('separator-row') ) {
|
|
return;
|
|
}
|
|
|
|
while ( tgtRow && tgtRow.length && !tgtRow.hasClass('main-row') ) {
|
|
tgtRow = tgtRow.prev();
|
|
}
|
|
|
|
if ( !tgtRow || !tgtRow.length ) {
|
|
return;
|
|
}
|
|
|
|
const nodeId = tgtRow.data('node-id');
|
|
|
|
if ( !nodeId ) {
|
|
return;
|
|
}
|
|
|
|
const node = this.pagedRows.find( x => get(x, this.keyField) === nodeId );
|
|
|
|
return node;
|
|
},
|
|
|
|
async onRowClick(e) {
|
|
const node = this.nodeForEvent(e);
|
|
const td = $(e.target).closest('TD');
|
|
const skipSelect = td.hasClass('skip-select');
|
|
|
|
if (skipSelect) {
|
|
return;
|
|
}
|
|
const selection = this.selectedRows;
|
|
const isCheckbox = this.isSelectionCheckbox(e.target) || td.hasClass('row-check');
|
|
const isExpand = td.hasClass('row-expand');
|
|
const content = this.pagedRows;
|
|
|
|
this.$emit('rowClick', e);
|
|
|
|
if ( !node ) {
|
|
return;
|
|
}
|
|
|
|
if ( isExpand ) {
|
|
this.toggleExpand(node);
|
|
|
|
return;
|
|
}
|
|
|
|
const actionElement = $(e.target).closest('.actions')[0];
|
|
|
|
if ( actionElement ) {
|
|
let resources = [node];
|
|
|
|
if ( this.mangleActionResources ) {
|
|
const i = $('i', actionElement);
|
|
|
|
i.removeClass('icon-actions');
|
|
i.addClass(['icon-spinner', 'icon-spin']);
|
|
|
|
try {
|
|
resources = await this.mangleActionResources(resources);
|
|
} finally {
|
|
i.removeClass(['icon-spinner', 'icon-spin']);
|
|
i.addClass('icon-actions');
|
|
}
|
|
}
|
|
|
|
this.$store.commit(`action-menu/show`, {
|
|
resources,
|
|
event: e.originalEvent || e, // Handle jQuery event and raw event
|
|
elem: actionElement
|
|
});
|
|
|
|
return;
|
|
}
|
|
|
|
const isSelected = selection.includes(node);
|
|
let prevNode = this.prevNode;
|
|
|
|
// PrevNode is only valid if it's in the current content
|
|
if ( !prevNode || !content.includes(prevNode) ) {
|
|
prevNode = node;
|
|
}
|
|
|
|
if ( isMore(e) ) {
|
|
this.toggle(node);
|
|
} else if ( isRange(e) ) {
|
|
const toToggle = this.nodesBetween(prevNode, node);
|
|
|
|
if ( isSelected ) {
|
|
this.update([], toToggle);
|
|
} else {
|
|
this.update(toToggle, []);
|
|
}
|
|
} else if ( isCheckbox ) {
|
|
this.toggle(node);
|
|
} else {
|
|
this.update([node], content);
|
|
}
|
|
|
|
this.prevNode = node;
|
|
},
|
|
|
|
async onRowContext(e) {
|
|
const node = this.nodeForEvent(e);
|
|
|
|
if ( suppressContextMenu(e) ) {
|
|
return;
|
|
}
|
|
|
|
if ( !node ) {
|
|
return;
|
|
}
|
|
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
this.prevNode = node;
|
|
const isSelected = this.selectedRows.includes(node);
|
|
|
|
if ( !isSelected ) {
|
|
this.update([node], this.selectedRows.slice());
|
|
}
|
|
|
|
let resources = this.selectedRows;
|
|
|
|
if ( this.mangleActionResources ) {
|
|
resources = await this.mangleActionResources(resources);
|
|
}
|
|
|
|
this.$store.commit(`action-menu/show`, {
|
|
resources,
|
|
event: e.originalEvent,
|
|
});
|
|
},
|
|
|
|
keySelectRow(row, more = false) {
|
|
const node = this.nodeForRow(row);
|
|
const content = this.pagedRows;
|
|
|
|
if ( !node ) {
|
|
return;
|
|
}
|
|
|
|
if ( more ) {
|
|
this.update([node], []);
|
|
} else {
|
|
this.update([node], content);
|
|
}
|
|
|
|
this.prevNode = node;
|
|
},
|
|
|
|
isSelectionCheckbox(element) {
|
|
return element.tagName === 'INPUT' &&
|
|
element.type === 'checkbox' &&
|
|
($(element).closest('.selection-checkbox').length > 0);
|
|
},
|
|
|
|
nodesBetween(a, b) {
|
|
let toToggle = [];
|
|
const key = this.groupBy;
|
|
|
|
if ( key ) {
|
|
// Grouped has 2 levels to look through
|
|
const grouped = this.groupedRows;
|
|
|
|
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++ ) {
|
|
const items = grouped[i].rows;
|
|
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
|
|
const content = this.pagedRows;
|
|
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);
|
|
}
|
|
|
|
// check if there is already duplicate content selected (selectedRows) on the list to toggle...
|
|
toToggle = toToggle.filter(item => !this.selectedRows.includes(item));
|
|
|
|
return toToggle;
|
|
},
|
|
|
|
groupIdx(node) {
|
|
const grouped = this.groupedRows;
|
|
|
|
for ( let i = 0 ; i < grouped.length ; i++ ) {
|
|
const rows = grouped[i].rows;
|
|
|
|
for ( let j = 0 ; j < rows.length ; j++ ) {
|
|
if ( rows[j] === node ) {
|
|
return {
|
|
group: i,
|
|
item: j
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
},
|
|
|
|
toggle(node) {
|
|
const add = [];
|
|
const remove = [];
|
|
|
|
if (this.selectedRows.includes(node)) {
|
|
remove.push(node);
|
|
} else {
|
|
add.push(node);
|
|
}
|
|
|
|
this.update(add, remove);
|
|
},
|
|
|
|
update(toAdd, toRemove) {
|
|
toRemove.forEach((row) => {
|
|
const index = this.selectedRows.findIndex(r => r === row);
|
|
|
|
if (index !== -1) {
|
|
this.selectedRows.splice(index, 1);
|
|
}
|
|
});
|
|
|
|
this.selectedRows.push(...toAdd);
|
|
|
|
// Uncheck and check the checkboxes of nodes that have been added/removed
|
|
if (toRemove.length) {
|
|
this.$nextTick(() => {
|
|
for ( let i = 0 ; i < toRemove.length ; i++ ) {
|
|
this.updateInput(toRemove[i], false, this.keyField);
|
|
}
|
|
});
|
|
}
|
|
|
|
if (toAdd.length) {
|
|
this.$nextTick(() => {
|
|
for ( let i = 0 ; i < toAdd.length ; i++ ) {
|
|
this.updateInput(toAdd[i], true, this.keyField);
|
|
}
|
|
});
|
|
}
|
|
|
|
this.$nextTick(() => {
|
|
this.$emit('selection', this.selectedRows);
|
|
});
|
|
},
|
|
|
|
updateInput(node, on, keyField) {
|
|
const id = get(node, keyField);
|
|
|
|
if ( id ) {
|
|
// Note: This is looking for the checkbox control for the row
|
|
const input = $(`div[data-checkbox-ctrl][data-node-id="${ id }"]`);
|
|
|
|
if ( input && input.length && !input[0].disabled ) {
|
|
const label = $(input[0]).find('label');
|
|
|
|
if (label) {
|
|
label.prop('value', on);
|
|
}
|
|
let tr = input.closest('tr');
|
|
let first = true;
|
|
|
|
while ( tr && (first || tr.hasClass('sub-row') ) ) {
|
|
tr.toggleClass('row-selected', on);
|
|
tr = tr.next();
|
|
first = false;
|
|
}
|
|
}
|
|
}
|
|
},
|
|
|
|
select(nodes) {
|
|
nodes.forEach((node) => {
|
|
const id = get(node, this.keyField);
|
|
const input = $(`label[data-node-id="${ id }"]`);
|
|
|
|
input.trigger('click');
|
|
});
|
|
},
|
|
|
|
applyTableAction(action, args, event) {
|
|
const opts = { alt: event && isAlternate(event), event };
|
|
|
|
// Go through the table selection and filter out those actions that can't run the chosen action
|
|
const executableSelection = this.selectedRows.filter((row) => {
|
|
const matchingResourceAction = row.availableActions.find(a => a.action === action.action);
|
|
|
|
return matchingResourceAction?.enabled;
|
|
});
|
|
|
|
_execute(executableSelection, action, args, opts, this);
|
|
|
|
this.actionOfInterest = null;
|
|
},
|
|
|
|
clearSelection() {
|
|
this.update([], this.selectedRows);
|
|
},
|
|
|
|
}
|
|
};
|
|
|
|
// ---------------------------------------------------------------------
|
|
// --- Helpers that were in selectionStore.js --------------------------
|
|
// ---------------------------------------------------------------------
|
|
|
|
let anon = 0;
|
|
|
|
function _add(map, act, incrementCounts = true) {
|
|
let id = act.action;
|
|
|
|
if ( !id ) {
|
|
id = `anon${ anon }`;
|
|
anon++;
|
|
}
|
|
|
|
let obj = map[id];
|
|
|
|
if ( !obj ) {
|
|
obj = Object.assign({}, act);
|
|
map[id] = obj;
|
|
obj.allEnabled = false;
|
|
}
|
|
|
|
if ( !act.enabled ) {
|
|
obj.allEnabled = false;
|
|
} else {
|
|
obj.anyEnabled = true;
|
|
}
|
|
|
|
if ( incrementCounts ) {
|
|
obj.available = (obj.available || 0) + (!act.enabled ? 0 : 1 );
|
|
obj.total = (obj.total || 0) + 1;
|
|
}
|
|
|
|
return obj;
|
|
}
|
|
|
|
function _filter(map, disableAll = false) {
|
|
const out = filterBy(Object.values(map), 'anyEnabled', true);
|
|
|
|
for ( const act of out ) {
|
|
if ( disableAll ) {
|
|
act.enabled = false;
|
|
} else {
|
|
act.enabled = ( act.available >= act.total );
|
|
}
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
function _execute(resources, action, args, opts = {}, ctx) {
|
|
args = args || [];
|
|
|
|
// New pattern for extensions - always call invoke
|
|
if (action.invoke) {
|
|
const actionOpts = {
|
|
action,
|
|
event: opts.event,
|
|
isAlt: !!opts.alt,
|
|
};
|
|
|
|
return action.invoke.apply(ctx, [actionOpts, resources || [], args]);
|
|
}
|
|
|
|
if ( resources.length > 1 && action.bulkAction && !opts.alt ) {
|
|
const fn = resources[0][action.bulkAction];
|
|
|
|
if ( fn ) {
|
|
return fn.call(resources[0], resources, ...args);
|
|
}
|
|
}
|
|
|
|
const promises = [];
|
|
|
|
for ( const resource of resources ) {
|
|
let fn;
|
|
|
|
if (opts.alt && action.altAction) {
|
|
fn = resource[action.altAction];
|
|
} else {
|
|
fn = resource[action.action];
|
|
}
|
|
|
|
if ( fn ) {
|
|
promises.push(fn.apply(resource, args));
|
|
}
|
|
}
|
|
|
|
return Promise.all(promises);
|
|
}
|