diff --git a/ui-v2/.gitignore b/ui-v2/.gitignore index a76bc38188..8496799743 100644 --- a/ui-v2/.gitignore +++ b/ui-v2/.gitignore @@ -8,3 +8,5 @@ /yarn-error.log /testem.log +/public/consul-api-double + diff --git a/ui-v2/GNUmakefile b/ui-v2/GNUmakefile index 9b5cb8ba9b..49ac334569 100644 --- a/ui-v2/GNUmakefile +++ b/ui-v2/GNUmakefile @@ -13,3 +13,18 @@ format: yarn run format:js .PHONY: server dist lint format + +.DEFAULT_GOAL=all +.PHONY: deps test all build start +all: deps +deps: node_modules yarn.lock package.json +node_modules: + yarn +build: + yarn run build +start: + yarn run start +test: + yarn run test +test-view: + yarn run test:view diff --git a/ui-v2/app/components/tabular-collection.js b/ui-v2/app/components/tabular-collection.js index c86081f105..180652dc21 100644 --- a/ui-v2/app/components/tabular-collection.js +++ b/ui-v2/app/components/tabular-collection.js @@ -1,25 +1,67 @@ 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 + */ -const $$ = document.querySelectorAll.bind(document); +// 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) { - let style = super.formatItemStyle(...arguments); - style += 'z-index: ' + (10000 - index); + 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; } } -// TODO instead of degrading gracefully +// 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 { @@ -28,6 +70,32 @@ const closest = function(sel, el) { 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; @@ -37,6 +105,23 @@ const change = function(e) { 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); } @@ -62,7 +147,7 @@ export default Component.extend(SlotsMixin, { this._super(...arguments); this.change = change.bind(this); this.confirming = []; - // TODO: This should auto calculate properly from the CSS + // 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()); @@ -79,23 +164,34 @@ export default Component.extend(SlotsMixin, { }, 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.handler(); + this.didAppear(); }, willDestroyElement: function() { window.removeEventListener('resize', this.handler); }, + didAppear: function() { + this.handler(); + }, resize: function(e) { - const $footer = [...$$('#wrapper > footer')][0]; - const $thead = [...$$('main > div')][0]; - if ($thead) { - // TODO: This should auto calculate properly from the CSS - this.set('height', Math.max(0, new Number(e.detail.height - ($footer.clientHeight + 218)))); - this['cell-layout'] = new ZIndexedGrid($thead.clientWidth, 50); + 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; @@ -106,8 +202,115 @@ export default Component.extend(SlotsMixin, { 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': diff --git a/ui-v2/app/controllers/dc/kv/folder.js b/ui-v2/app/controllers/dc/kv/folder.js new file mode 100644 index 0000000000..5f88002bfd --- /dev/null +++ b/ui-v2/app/controllers/dc/kv/folder.js @@ -0,0 +1,2 @@ +import Controller from './index'; +export default Controller.extend(); diff --git a/ui-v2/app/controllers/dc/kv/index.js b/ui-v2/app/controllers/dc/kv/index.js new file mode 100644 index 0000000000..20a2399058 --- /dev/null +++ b/ui-v2/app/controllers/dc/kv/index.js @@ -0,0 +1,18 @@ +import Controller from '@ember/controller'; +import { get } from '@ember/object'; +import WithFiltering from 'consul-ui/mixins/with-filtering'; +import rightTrim from 'consul-ui/utils/right-trim'; +export default Controller.extend(WithFiltering, { + queryParams: { + s: { + as: 'filter', + replace: true, + }, + }, + filter: function(item, { s = '' }) { + const key = rightTrim(get(item, 'Key'), '/') + .split('/') + .pop(); + return key.toLowerCase().indexOf(s.toLowerCase()) !== -1; + }, +}); diff --git a/ui-v2/app/controllers/dc/nodes/show.js b/ui-v2/app/controllers/dc/nodes/show.js index 0f48b55155..0884b58489 100644 --- a/ui-v2/app/controllers/dc/nodes/show.js +++ b/ui-v2/app/controllers/dc/nodes/show.js @@ -1,7 +1,11 @@ import Controller from '@ember/controller'; import { get, set } from '@ember/object'; +import { getOwner } from '@ember/application'; import WithFiltering from 'consul-ui/mixins/with-filtering'; +import qsaFactory from 'consul-ui/utils/qsa-factory'; +import getComponentFactory from 'consul-ui/utils/get-component-factory'; +const $$ = qsaFactory(); export default Controller.extend(WithFiltering, { queryParams: { s: { @@ -14,13 +18,30 @@ export default Controller.extend(WithFiltering, { set(this, 'selectedTab', 'health-checks'); }, filter: function(item, { s = '' }) { + const term = s.toLowerCase(); return ( get(item, 'Service') .toLowerCase() - .indexOf(s.toLowerCase()) !== -1 + .indexOf(term) !== -1 || + get(item, 'Port') + .toString() + .toLowerCase() + .indexOf(term) !== -1 ); }, actions: { + change: function(e) { + set(this, 'selectedTab', e.target.value); + const getComponent = getComponentFactory(getOwner(this)); + // Ensure tabular-collections sizing is recalculated + // now it is visible in the DOM + [...$$('.tab-section input[type="radio"]:checked + div table')].forEach(function(item) { + const component = getComponent(item); + if (component && typeof component.didAppear === 'function') { + getComponent(item).didAppear(); + } + }); + }, sortChecksByImportance: function(a, b) { const statusA = get(a, 'Status'); const statusB = get(b, 'Status'); diff --git a/ui-v2/app/helpers/left-trim.js b/ui-v2/app/helpers/left-trim.js index 4f36778317..bb49e028f8 100644 --- a/ui-v2/app/helpers/left-trim.js +++ b/ui-v2/app/helpers/left-trim.js @@ -1,7 +1,6 @@ import { helper } from '@ember/component/helper'; +import leftTrim from 'consul-ui/utils/left-trim'; -export function leftTrim([str = '', search = ''], hash) { - return str.indexOf(search) === 0 ? str.substr(search.length) : str; -} - -export default helper(leftTrim); +export default helper(function([str = '', search = ''], hash) { + return leftTrim(str, search); +}); diff --git a/ui-v2/app/helpers/right-trim.js b/ui-v2/app/helpers/right-trim.js index 8ad3cceb6a..27c2721374 100644 --- a/ui-v2/app/helpers/right-trim.js +++ b/ui-v2/app/helpers/right-trim.js @@ -1,8 +1,7 @@ import { helper } from '@ember/component/helper'; -export function rightTrim([str = '', search = ''], hash) { - const pos = str.length - search.length; - return str.indexOf(search) === pos ? str.substr(0, pos) : str; -} +import rightTrim from 'consul-ui/utils/right-trim'; -export default helper(rightTrim); +export default helper(function([str = '', search = ''], hash) { + return rightTrim(str, search); +}); diff --git a/ui-v2/app/mixins/click-outside.js b/ui-v2/app/mixins/click-outside.js index c7c216b06f..92c2d89639 100644 --- a/ui-v2/app/mixins/click-outside.js +++ b/ui-v2/app/mixins/click-outside.js @@ -3,9 +3,13 @@ import Mixin from '@ember/object/mixin'; import { next } from '@ember/runloop'; import { get } from '@ember/object'; const isOutside = function(element, e) { - const isRemoved = !e.target || !document.contains(e.target); - const isInside = element === e.target || element.contains(e.target); - return !isRemoved && !isInside; + if (element) { + const isRemoved = !e.target || !document.contains(e.target); + const isInside = element === e.target || element.contains(e.target); + return !isRemoved && !isInside; + } else { + return false; + } }; const handler = function(e) { const el = get(this, 'element'); diff --git a/ui-v2/app/models/acl.js b/ui-v2/app/models/acl.js index e467b43772..7c55bb64ef 100644 --- a/ui-v2/app/models/acl.js +++ b/ui-v2/app/models/acl.js @@ -7,7 +7,12 @@ export const SLUG_KEY = 'ID'; export default Model.extend({ [PRIMARY_KEY]: attr('string'), [SLUG_KEY]: attr('string'), - Name: attr('string'), + Name: attr('string', { + // TODO: Why didn't I have to do this for KV's? + // this is to ensure that Name is '' and not null when creating + // maybe its due to the fact that `Key` is the primaryKey in Kv's + defaultValue: '', + }), Type: attr('string'), Rules: attr('string'), CreateIndex: attr('number'), diff --git a/ui-v2/app/routes/dc/kv/index.js b/ui-v2/app/routes/dc/kv/index.js index a39615aa88..01529d64db 100644 --- a/ui-v2/app/routes/dc/kv/index.js +++ b/ui-v2/app/routes/dc/kv/index.js @@ -5,6 +5,12 @@ import { get } from '@ember/object'; import WithKvActions from 'consul-ui/mixins/kv/with-actions'; export default Route.extend(WithKvActions, { + queryParams: { + s: { + as: 'filter', + replace: true, + }, + }, repo: service('kv'), model: function(params) { const key = params.key || '/'; diff --git a/ui-v2/app/styles/components/action-group.scss b/ui-v2/app/styles/components/action-group.scss index 51685c970a..b4f324c114 100644 --- a/ui-v2/app/styles/components/action-group.scss +++ b/ui-v2/app/styles/components/action-group.scss @@ -70,19 +70,30 @@ %action-group ul { position: absolute; right: -10px; - top: 35px; padding: 1px; } %action-group ul::before { position: absolute; right: 18px; - top: -6px; content: ''; display: block; width: 10px; height: 10px; +} +%action-group ul:not(.above) { + top: 35px; +} +%action-group ul:not(.above)::before { + top: -6px; transform: rotate(45deg); } +%action-group ul.above { + bottom: 35px; +} +%action-group ul.above::before { + bottom: -6px; + transform: rotate(225deg); +} %action-group li { position: relative; z-index: 1; diff --git a/ui-v2/app/styles/components/filter-bar.scss b/ui-v2/app/styles/components/filter-bar.scss index 96365857b1..d540acc2df 100644 --- a/ui-v2/app/styles/components/filter-bar.scss +++ b/ui-v2/app/styles/components/filter-bar.scss @@ -44,6 +44,7 @@ margin-left: 12px; } %filter-bar fieldset { + min-width: 210px; width: auto; } } diff --git a/ui-v2/app/styles/components/form-elements.scss b/ui-v2/app/styles/components/form-elements.scss index 683678c079..fa2eae5e06 100644 --- a/ui-v2/app/styles/components/form-elements.scss +++ b/ui-v2/app/styles/components/form-elements.scss @@ -14,8 +14,10 @@ display: block; max-width: 100%; min-width: 100%; - padding: 0.625em; + min-height: 70px; + padding: 0.625em 15px; resize: vertical; + line-height: 1.5; } %form-element [type='text'], %form-element [type='password'] { @@ -37,14 +39,16 @@ box-shadow: none; border-radius: $radius-small; } -.has-error > input { +.has-error > input, +.has-error > textarea { border: 1px solid; } %form-element > span { color: $text-gray; } %form-element [type='text'], -%form-element [type='password'] { +%form-element [type='password'], +%form-element textarea { color: $user-text-gray; } %form-element [type='text'], diff --git a/ui-v2/app/styles/core/typography.scss b/ui-v2/app/styles/core/typography.scss index 43ccd83c3d..9527d094f8 100644 --- a/ui-v2/app/styles/core/typography.scss +++ b/ui-v2/app/styles/core/typography.scss @@ -63,6 +63,7 @@ h2, body, pre code, input, +textarea, td { font-size: $size-6; } diff --git a/ui-v2/app/templates/components/hashicorp-consul.hbs b/ui-v2/app/templates/components/hashicorp-consul.hbs index cecae95c7d..f3396a4557 100644 --- a/ui-v2/app/templates/components/hashicorp-consul.hbs +++ b/ui-v2/app/templates/components/hashicorp-consul.hbs @@ -51,7 +51,7 @@
{{yield}}
-