mirror of https://github.com/hashicorp/consul
334 lines
10 KiB
JavaScript
334 lines
10 KiB
JavaScript
import Component from 'ember-collection/components/ember-collection';
|
|
import needsRevalidate from 'ember-collection/utils/needs-revalidate';
|
|
import identity from 'ember-collection/utils/identity';
|
|
import Grid from 'ember-collection/layouts/grid';
|
|
import SlotsMixin from 'ember-block-slots';
|
|
import style from 'ember-computed-style';
|
|
import qsaFactory from 'consul-ui/utils/qsa-factory';
|
|
|
|
import { computed, get, set } from '@ember/object';
|
|
/**
|
|
* Heavily extended `ember-collection` component
|
|
* This adds support for z-index calculations to enable
|
|
* Popup menus to pop over either rows above or below
|
|
* the popup.
|
|
* Additionally adds calculations for figuring out what the height
|
|
* of the tabular component should be depending on the other elements
|
|
* in the page.
|
|
* Currently everything is here together for clarity, but to be split up
|
|
* in the future
|
|
*/
|
|
|
|
// ember doesn't like you using `$` hence `$$`
|
|
const $$ = qsaFactory();
|
|
// basic pseudo CustomEvent interface
|
|
// TODO: use actual custom events once I've reminded
|
|
// myself re: support/polyfills
|
|
const createSizeEvent = function(detail) {
|
|
return {
|
|
detail: { width: window.innerWidth, height: window.innerHeight },
|
|
};
|
|
};
|
|
// need to copy Cell in wholesale as there is no way to import it
|
|
// there is no change made to `Cell` here, its only here as its
|
|
// private in `ember-collection`
|
|
// TODO: separate both Cell and ZIndexedGrid out
|
|
class Cell {
|
|
constructor(key, item, index, style) {
|
|
this.key = key;
|
|
this.hidden = false;
|
|
this.item = item;
|
|
this.index = index;
|
|
this.style = style;
|
|
}
|
|
}
|
|
// this is an amount of rows in the table NOT items
|
|
// unlikely to have 10000 DOM rows ever :)
|
|
const maxZIndex = 10000;
|
|
// Adds z-index styling to the default Grid
|
|
class ZIndexedGrid extends Grid {
|
|
formatItemStyle(index, w, h, checked) {
|
|
let style = super.formatItemStyle(index, w, h);
|
|
// count backwards from maxZIndex
|
|
let zIndex = maxZIndex - index;
|
|
// apart from the row that contains an opened dropdown menu
|
|
// this one should be highest z-index, so use max plus 1
|
|
if (checked == index) {
|
|
zIndex = maxZIndex + 1;
|
|
}
|
|
style += 'z-index: ' + zIndex;
|
|
return style;
|
|
}
|
|
}
|
|
// basic DOM closest utility to cope with no support
|
|
// TODO: instead of degrading gracefully
|
|
// add a while polyfill for closest
|
|
const closest = function(sel, el) {
|
|
try {
|
|
return el.closest(sel);
|
|
} catch (e) {
|
|
return;
|
|
}
|
|
};
|
|
const sibling = function(el, name) {
|
|
let sibling = el;
|
|
while ((sibling = sibling.nextSibling)) {
|
|
if (sibling.nodeType === 1) {
|
|
if (sibling.nodeName.toLowerCase() === name) {
|
|
return sibling;
|
|
}
|
|
}
|
|
}
|
|
};
|
|
/**
|
|
* The tabular-collection can contain 'actions' the UI for which
|
|
* uses dropdown 'action groups', so a group of different actions.
|
|
* State makes use of native HTML state using radiogroups
|
|
* to ensure that only a single dropdown can be open at one time.
|
|
* Therefore we listen to change events to do anything extra when
|
|
* a dropdown is opened (the change function is bound to the instance of
|
|
* the `tabular-component` on init, hoisted here for visibility)
|
|
*
|
|
* The extra functionality we have here is to detect whether the opened
|
|
* dropdown menu would be cut off or not if it 'dropped down'.
|
|
* If it would be cut off we use CSS to 'drop it up' instead.
|
|
* We also set this row to have the max z-index here, and mark this
|
|
* row as the 'checked row' for when a scroll/grid re-calculation is
|
|
* performed
|
|
*/
|
|
const change = function(e) {
|
|
if (e instanceof MouseEvent) {
|
|
return;
|
|
}
|
|
// TODO: Why am I getting a jQuery event here?!
|
|
if (e instanceof Event) {
|
|
const value = e.currentTarget.value;
|
|
if (value != get(this, 'checked')) {
|
|
set(this, 'checked', value);
|
|
// 'actions_close' would mean that all menus have been closed
|
|
// therefore we don't need to calculate
|
|
if (e.currentTarget.getAttribute('id') !== 'actions_close') {
|
|
const $tr = closest('tr', e.currentTarget);
|
|
const $group = sibling(e.currentTarget, 'ul');
|
|
const $footer = [...$$('footer[role="contentinfo"]')][0];
|
|
const groupRect = $group.getBoundingClientRect();
|
|
const footerRect = $footer.getBoundingClientRect();
|
|
const groupBottom = groupRect.top + $group.clientHeight;
|
|
const footerTop = footerRect.top;
|
|
if (groupBottom > footerTop) {
|
|
$group.classList.add('above');
|
|
} else {
|
|
$group.classList.remove('above');
|
|
}
|
|
$tr.style.zIndex = maxZIndex + 1;
|
|
}
|
|
} else {
|
|
set(this, 'checked', null);
|
|
}
|
|
} else if (e.detail && e.detail.index) {
|
|
if (e.detail.confirming) {
|
|
this.confirming.push(e.detail.index);
|
|
} else {
|
|
const pos = this.confirming.indexOf(e.detail.index);
|
|
if (pos !== -1) {
|
|
this.confirming.splice(pos, 1);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
export default Component.extend(SlotsMixin, {
|
|
tagName: 'table',
|
|
attributeBindings: ['style'],
|
|
width: 1150,
|
|
height: 500,
|
|
style: style('getStyle'),
|
|
checked: null,
|
|
init: function() {
|
|
this._super(...arguments);
|
|
this.change = change.bind(this);
|
|
this.confirming = [];
|
|
// TODO: The row height should auto calculate properly from the CSS
|
|
this['cell-layout'] = new ZIndexedGrid(get(this, 'width'), 50);
|
|
this.handler = () => {
|
|
this.resize(createSizeEvent());
|
|
};
|
|
},
|
|
getStyle: computed('height', function() {
|
|
return {
|
|
height: get(this, 'height'),
|
|
};
|
|
}),
|
|
willRender: function() {
|
|
this._super(...arguments);
|
|
this.set('hasActions', this._isRegistered('actions'));
|
|
},
|
|
didInsertElement: function() {
|
|
this._super(...arguments);
|
|
// TODO: Consider moving all DOM lookups here
|
|
// this seems to be the earliest place I can get them
|
|
window.addEventListener('resize', this.handler);
|
|
this.didAppear();
|
|
},
|
|
willDestroyElement: function() {
|
|
window.removeEventListener('resize', this.handler);
|
|
},
|
|
didAppear: function() {
|
|
this.handler();
|
|
},
|
|
resize: function(e) {
|
|
const $tbody = [...$$('tbody', this.element)][0];
|
|
const $appContent = [...$$('main > div')][0];
|
|
if ($appContent) {
|
|
const rect = $tbody.getBoundingClientRect();
|
|
const $footer = [...$$('footer[role="contentinfo"]')][0];
|
|
const space = rect.top + $footer.clientHeight;
|
|
const height = new Number(e.detail.height - space);
|
|
this.set('height', Math.max(0, height));
|
|
// TODO: The row height should auto calculate properly from the CSS
|
|
this['cell-layout'] = new ZIndexedGrid($appContent.clientWidth, 50);
|
|
this.updateItems();
|
|
this.updateScrollPosition();
|
|
}
|
|
},
|
|
// `ember-collection` bug workaround
|
|
// https://github.com/emberjs/ember-collection/issues/138
|
|
_needsRevalidate: function() {
|
|
if (this.isDestroyed || this.isDestroying) {
|
|
return;
|
|
}
|
|
if (this._isGlimmer2()) {
|
|
this.rerender();
|
|
} else {
|
|
needsRevalidate(this);
|
|
}
|
|
},
|
|
// need to overwrite this completely so I can pass through the checked index
|
|
// unfortunately the nicest way I could think to do this is to copy this in wholesale
|
|
// to add an extra argument for `formatItemStyle` in 3 places
|
|
// tradeoff between changing as little code as possible in the original code
|
|
updateCells: function() {
|
|
if (!this._items) {
|
|
return;
|
|
}
|
|
const numItems = get(this._items, 'length');
|
|
if (this._cellLayout.length !== numItems) {
|
|
this._cellLayout.length = numItems;
|
|
}
|
|
|
|
var priorMap = this._cellMap;
|
|
var cellMap = Object.create(null);
|
|
|
|
var index = this._cellLayout.indexAt(
|
|
this._scrollLeft,
|
|
this._scrollTop,
|
|
this._clientWidth,
|
|
this._clientHeight
|
|
);
|
|
var count = this._cellLayout.count(
|
|
this._scrollLeft,
|
|
this._scrollTop,
|
|
this._clientWidth,
|
|
this._clientHeight
|
|
);
|
|
var items = this._items;
|
|
var bufferBefore = Math.min(index, this._buffer);
|
|
index -= bufferBefore;
|
|
count += bufferBefore;
|
|
count = Math.min(count + this._buffer, get(items, 'length') - index);
|
|
var i, style, itemIndex, itemKey, cell;
|
|
|
|
var newItems = [];
|
|
|
|
for (i = 0; i < count; i++) {
|
|
itemIndex = index + i;
|
|
itemKey = identity(items.objectAt(itemIndex));
|
|
if (priorMap) {
|
|
cell = priorMap[itemKey];
|
|
}
|
|
if (cell) {
|
|
// additional `checked` argument
|
|
style = this._cellLayout.formatItemStyle(
|
|
itemIndex,
|
|
this._clientWidth,
|
|
this._clientHeight,
|
|
this.checked
|
|
);
|
|
set(cell, 'style', style);
|
|
set(cell, 'hidden', false);
|
|
set(cell, 'key', itemKey);
|
|
cellMap[itemKey] = cell;
|
|
} else {
|
|
newItems.push(itemIndex);
|
|
}
|
|
}
|
|
|
|
for (i = 0; i < this._cells.length; i++) {
|
|
cell = this._cells[i];
|
|
if (!cellMap[cell.key]) {
|
|
if (newItems.length) {
|
|
itemIndex = newItems.pop();
|
|
let item = items.objectAt(itemIndex);
|
|
itemKey = identity(item);
|
|
// additional `checked` argument
|
|
style = this._cellLayout.formatItemStyle(
|
|
itemIndex,
|
|
this._clientWidth,
|
|
this._clientHeight,
|
|
this.checked
|
|
);
|
|
set(cell, 'style', style);
|
|
set(cell, 'key', itemKey);
|
|
set(cell, 'index', itemIndex);
|
|
set(cell, 'item', item);
|
|
set(cell, 'hidden', false);
|
|
cellMap[itemKey] = cell;
|
|
} else {
|
|
set(cell, 'hidden', true);
|
|
set(cell, 'style', 'height: 0; display: none;');
|
|
}
|
|
}
|
|
}
|
|
|
|
for (i = 0; i < newItems.length; i++) {
|
|
itemIndex = newItems[i];
|
|
let item = items.objectAt(itemIndex);
|
|
itemKey = identity(item);
|
|
// additional `checked` argument
|
|
style = this._cellLayout.formatItemStyle(
|
|
itemIndex,
|
|
this._clientWidth,
|
|
this._clientHeight,
|
|
this.checked
|
|
);
|
|
cell = new Cell(itemKey, item, itemIndex, style);
|
|
cellMap[itemKey] = cell;
|
|
this._cells.pushObject(cell);
|
|
}
|
|
this._cellMap = cellMap;
|
|
},
|
|
actions: {
|
|
click: function(e) {
|
|
// click on row functionality
|
|
// so if you click the actual row but not a link
|
|
// find the first link and fire that instead
|
|
const name = e.target.nodeName.toLowerCase();
|
|
switch (name) {
|
|
case 'input':
|
|
case 'label':
|
|
case 'a':
|
|
case 'button':
|
|
return;
|
|
}
|
|
const $a = closest('tr', e.target).querySelector('a');
|
|
if ($a) {
|
|
const click = new MouseEvent('click', {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
view: window,
|
|
});
|
|
$a.dispatchEvent(click);
|
|
}
|
|
},
|
|
},
|
|
});
|