-
Notifications
You must be signed in to change notification settings - Fork 2k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
UI: Add phase 1 search #8175
UI: Add phase 1 search #8175
Changes from all commits
e919f00
73b6bb3
6b46679
8fca80e
00ced6f
a7ded9b
b24f73b
180f54a
e7cc5f5
94f42d0
de076d6
db0ceac
a213176
2fa2ef8
def4851
a9f97cb
95904b7
44c4f1c
93602c0
fd14f25
379ad0c
894de23
5749ec1
e4e7c78
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; | ||
} | ||
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 = { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe this could be dynamically calculated by examining routes with watchers but that seemed like overkill for this small use. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I agree. This quick little mapping does the job just fine. |
||
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); | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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%; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
<PowerSelect | ||
@tagName="div" | ||
class="global-search" | ||
data-test-search | ||
@searchEnabled={{true}} | ||
@search={{perform this.search}} | ||
@onChange={{action 'selectOption'}} | ||
@onFocus={{action 'openOnClickOrTab'}} | ||
@dropdownClass="global-search-dropdown" | ||
@calculatePosition={{this.calculatePosition}} | ||
@searchMessageComponent="global-search/message" | ||
@triggerComponent="global-search/trigger" | ||
@registerAPI={{action 'storeSelect'}} | ||
as |option|> | ||
{{option.name}} | ||
</PowerSelect> |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
{{x-icon "search" class="is-small"}} | ||
{{#unless select.isOpen}} | ||
<span class='placeholder'>Search</span> | ||
{{/unless}} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a nice concise way to separate out search strategies for different types ✨