diff --git a/ui/app/components/global-search/control.js b/ui/app/components/global-search/control.js new file mode 100644 index 00000000000..1a061ba8943 --- /dev/null +++ b/ui/app/components/global-search/control.js @@ -0,0 +1,172 @@ +import Component from '@ember/component'; +import { tagName } from '@ember-decorators/component'; +import { task } from 'ember-concurrency'; +import EmberObject, { action, computed, set } from '@ember/object'; +import { alias } from '@ember/object/computed'; +import { inject as service } from '@ember/service'; +import { run } from '@ember/runloop'; +import Searchable from 'nomad-ui/mixins/searchable'; +import classic from 'ember-classic-decorator'; + +const SLASH_KEY = 191; + +@tagName('') +export default class GlobalSearchControl extends Component { + @service dataCaches; + @service router; + @service store; + + searchString = null; + + constructor() { + super(...arguments); + + this.jobSearch = JobSearch.create({ + dataSource: this, + }); + + this.nodeSearch = NodeSearch.create({ + dataSource: this, + }); + } + + keyDownHandler(e) { + const targetElementName = e.target.nodeName.toLowerCase(); + + if (targetElementName != 'input' && targetElementName != 'textarea') { + if (e.keyCode === SLASH_KEY) { + e.preventDefault(); + this.open(); + } + } + } + + didInsertElement() { + this.set('_keyDownHandler', this.keyDownHandler.bind(this)); + document.addEventListener('keydown', this._keyDownHandler); + } + + willDestroyElement() { + document.removeEventListener('keydown', this._keyDownHandler); + } + + @task(function*(string) { + try { + set(this, 'searchString', string); + + const jobs = yield this.dataCaches.fetch('job'); + const nodes = yield this.dataCaches.fetch('node'); + + set(this, 'jobs', jobs.toArray()); + set(this, 'nodes', nodes.toArray()); + + const jobResults = this.jobSearch.listSearched; + const nodeResults = this.nodeSearch.listSearched; + + return [ + { + groupName: `Jobs (${jobResults.length})`, + options: jobResults, + }, + { + groupName: `Clients (${nodeResults.length})`, + options: nodeResults, + }, + ]; + } catch (e) { + // eslint-disable-next-line + console.log('exception searching', e); + } + }) + search; + + @action + open() { + if (this.select) { + this.select.actions.open(); + } + } + + @action + selectOption(model) { + const itemModelName = model.constructor.modelName; + + if (itemModelName === 'job') { + this.router.transitionTo('jobs.job', model.name, { + queryParams: { namespace: model.get('namespace.name') }, + }); + } else if (itemModelName === 'node') { + this.router.transitionTo('clients.client', model.id); + } + } + + @action + storeSelect(select) { + if (select) { + this.select = select; + } + } + + @action + openOnClickOrTab(select, { target }) { + // Bypass having to press enter to access search after clicking/tabbing + const targetClassList = target.classList; + const targetIsTrigger = targetClassList.contains('ember-power-select-trigger'); + + // Allow tabbing out of search + const triggerIsNotActive = !targetClassList.contains('ember-power-select-trigger--active'); + + if (targetIsTrigger && triggerIsNotActive) { + run.next(() => { + select.actions.open(); + }); + } + } + + calculatePosition(trigger) { + const { top, left, width } = trigger.getBoundingClientRect(); + return { + style: { + left, + width, + top, + }, + }; + } +} + +@classic +class JobSearch extends EmberObject.extend(Searchable) { + @computed + get searchProps() { + return ['id', 'name']; + } + + @computed + get fuzzySearchProps() { + return ['name']; + } + + @alias('dataSource.jobs') listToSearch; + @alias('dataSource.searchString') searchTerm; + + fuzzySearchEnabled = true; +} + +@classic +class NodeSearch extends EmberObject.extend(Searchable) { + @computed + get searchProps() { + return ['id', 'name']; + } + + @computed + get fuzzySearchProps() { + return ['name']; + } + + @alias('dataSource.nodes') listToSearch; + @alias('dataSource.searchString') searchTerm; + + fuzzySearchEnabled = true; +} diff --git a/ui/app/services/data-caches.js b/ui/app/services/data-caches.js new file mode 100644 index 00000000000..7617c61c583 --- /dev/null +++ b/ui/app/services/data-caches.js @@ -0,0 +1,33 @@ +import Service, { inject as service } from '@ember/service'; + +export const COLLECTION_CACHE_DURATION = 60000; // one minute + +export default class DataCachesService extends Service { + @service router; + @service store; + @service system; + + collectionLastFetched = {}; + + async fetch(modelName) { + const modelNameToRoute = { + job: 'jobs', + node: 'clients', + }; + + const route = modelNameToRoute[modelName]; + const lastFetched = this.collectionLastFetched[modelName]; + const now = Date.now(); + + if (this.router.isActive(route)) { + // TODO Incorrect because it’s constantly being fetched by watchers, shouldn’t be marked as last fetched only on search + this.collectionLastFetched[modelName] = now; + return this.store.peekAll(modelName); + } else if (lastFetched && now - lastFetched < COLLECTION_CACHE_DURATION) { + return this.store.peekAll(modelName); + } else { + this.collectionLastFetched[modelName] = now; + return this.store.findAll(modelName); + } + } +} diff --git a/ui/app/styles/components.scss b/ui/app/styles/components.scss index 1bfd76d9b42..c0f81710ec7 100644 --- a/ui/app/styles/components.scss +++ b/ui/app/styles/components.scss @@ -11,6 +11,8 @@ @import './components/exec-button'; @import './components/exec-window'; @import './components/fs-explorer'; +@import './components/global-search-control'; +@import './components/global-search-dropdown'; @import './components/gutter'; @import './components/gutter-toggle'; @import './components/image-file.scss'; diff --git a/ui/app/styles/components/global-search-control.scss b/ui/app/styles/components/global-search-control.scss new file mode 100644 index 00000000000..e42243b5021 --- /dev/null +++ b/ui/app/styles/components/global-search-control.scss @@ -0,0 +1,37 @@ +.global-search { + width: 30em; + + .ember-power-select-trigger { + background: $nomad-green-darker; + + .icon { + margin-top: 1px; + margin-left: 2px; + + fill: white; + opacity: 0.7; + } + + .placeholder { + opacity: 0.7; + display: inline-block; + padding-left: 2px; + transform: translateY(-1px); + } + + &.ember-power-select-trigger--active { + background: white; + + .icon { + fill: black; + opacity: 1; + } + } + } + + .ember-basic-dropdown-content-wormhole-origin { + position: absolute; + top: 0; + width: 100%; + } +} diff --git a/ui/app/styles/components/global-search-dropdown.scss b/ui/app/styles/components/global-search-dropdown.scss new file mode 100644 index 00000000000..4e9c2f5ef3e --- /dev/null +++ b/ui/app/styles/components/global-search-dropdown.scss @@ -0,0 +1,45 @@ +.global-search-dropdown { + background: transparent; + border: 0; + position: fixed; + + .ember-power-select-search { + margin-left: $icon-dimensions; + border: 0; + } + + input, + input:focus { + background: transparent; + border: 0; + outline: 0; + } + + .ember-power-select-options { + background: white; + padding: 0.35rem; + + &[role='listbox'] { + border: 1px solid $grey-blue; + box-shadow: 0 6px 8px -2px rgba($black, 0.05), 0 8px 4px -4px rgba($black, 0.1); + } + + .ember-power-select-option { + padding: 0.2rem 0.4rem; + border-radius: $radius; + + &[aria-current='true'] { + background: transparentize($blue, 0.8); + color: $blue; + } + } + } + + .ember-power-select-group-name { + text-transform: uppercase; + display: inline; + color: darken($grey-blue, 20%); + font-size: $size-7; + font-weight: $weight-semibold; + } +} diff --git a/ui/app/styles/core/navbar.scss b/ui/app/styles/core/navbar.scss index 48a2a065d76..9018db8afb8 100644 --- a/ui/app/styles/core/navbar.scss +++ b/ui/app/styles/core/navbar.scss @@ -8,6 +8,8 @@ padding-left: 20px; padding-right: 20px; overflow: hidden; + align-items: center; + justify-content: space-between; .navbar-item { color: rgba($primary-invert, 0.8); @@ -35,7 +37,6 @@ display: block; position: absolute; left: 0px; - top: 1.25em; } } } @@ -44,7 +45,7 @@ display: flex; align-items: stretch; justify-content: flex-end; - margin-left: auto; + margin-left: inherit; } .navbar-end > a.navbar-item { @@ -100,7 +101,7 @@ display: flex; align-items: center; justify-content: flex-end; - margin-left: auto; + margin-left: inherit; } .navbar-end > a.navbar-item { diff --git a/ui/app/templates/components/global-header.hbs b/ui/app/templates/components/global-header.hbs index 23c5aa05ccf..e129a42c321 100644 --- a/ui/app/templates/components/global-header.hbs +++ b/ui/app/templates/components/global-header.hbs @@ -7,6 +7,9 @@ {{partial "partials/nomad-logo"}} + {{#unless (media "isMobile")}} + + {{/unless}}