Skip to content
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

Merged
merged 24 commits into from
Jun 19, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
e919f00
Add inert search components
backspace Jun 15, 2020
73b6bb3
Add preliminary job search
backspace Jun 16, 2020
6b46679
Remove need to activate search with enter
backspace Jun 16, 2020
8fca80e
Add client search
backspace Jun 16, 2020
00ced6f
Add navigation upon search selection
backspace Jun 16, 2020
a7ded9b
Add indent to fix search icon overlap
backspace Jun 16, 2020
b24f73b
Remove currently-unused variable
backspace Jun 16, 2020
180f54a
Add / shortcut to open search
backspace Jun 16, 2020
e7cc5f5
Add test for navigating to nodes
backspace Jun 16, 2020
94f42d0
Expand scope of try to catch more errors
backspace Jun 17, 2020
de076d6
Change to use set for search properties
backspace Jun 17, 2020
db0ceac
Change interface for search classes
backspace Jun 17, 2020
a213176
Stop control from capturing / in input fields
backspace Jun 17, 2020
2fa2ef8
Add preliminary use of cached collections
backspace Jun 17, 2020
def4851
Remove manual vertical positioning of divider
backspace Jun 18, 2020
a9f97cb
Add time-based caching
backspace Jun 18, 2020
95904b7
Fix invocation of search component
backspace Jun 18, 2020
44c4f1c
Add media query to hide search on mobile
backspace Jun 18, 2020
93602c0
Change cache duration
backspace Jun 18, 2020
fd14f25
Fix assertion description
backspace Jun 18, 2020
379ad0c
Remove/update notes
backspace Jun 18, 2020
894de23
Remove/update notes
backspace Jun 18, 2020
5749ec1
Change non-slash-stealing test to use page objects
backspace Jun 18, 2020
e4e7c78
Add namespace to job navigation
backspace Jun 19, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 172 additions & 0 deletions ui/app/components/global-search/control.js
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;
}
Copy link
Contributor

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 ✨

33 changes: 33 additions & 0 deletions ui/app/services/data-caches.js
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 = {
Copy link
Contributor Author

Choose a reason for hiding this comment

The 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.

Copy link
Contributor

Choose a reason for hiding this comment

The 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);
}
}
}
2 changes: 2 additions & 0 deletions ui/app/styles/components.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
37 changes: 37 additions & 0 deletions ui/app/styles/components/global-search-control.scss
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%;
}
}
45 changes: 45 additions & 0 deletions ui/app/styles/components/global-search-dropdown.scss
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;
}
}
7 changes: 4 additions & 3 deletions ui/app/styles/core/navbar.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -35,7 +37,6 @@
display: block;
position: absolute;
left: 0px;
top: 1.25em;
}
}
}
Expand All @@ -44,7 +45,7 @@
display: flex;
align-items: stretch;
justify-content: flex-end;
margin-left: auto;
margin-left: inherit;
}

.navbar-end > a.navbar-item {
Expand Down Expand Up @@ -100,7 +101,7 @@
display: flex;
align-items: center;
justify-content: flex-end;
margin-left: auto;
margin-left: inherit;
}

.navbar-end > a.navbar-item {
Expand Down
3 changes: 3 additions & 0 deletions ui/app/templates/components/global-header.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
{{partial "partials/nomad-logo"}}
</LinkTo>
</div>
{{#unless (media "isMobile")}}
<GlobalSearch::Control />
{{/unless}}
<div class="navbar-end">
<a href="https://nomadproject.io/docs" class="navbar-item">Documentation</a>
<LinkTo @route="settings.tokens" class="navbar-item">ACL Tokens</LinkTo>
Expand Down
16 changes: 16 additions & 0 deletions ui/app/templates/components/global-search/control.hbs
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>
Empty file.
4 changes: 4 additions & 0 deletions ui/app/templates/components/global-search/trigger.hbs
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}}
4 changes: 2 additions & 2 deletions ui/tests/acceptance/jobs-list-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ module('Acceptance | jobs list', function(hooks) {

await JobsList.visit();

await JobsList.search('dog');
await JobsList.search.fillIn('dog');
assert.ok(JobsList.isEmpty, 'The empty message is shown');
assert.equal(JobsList.emptyState.headline, 'No Matches', 'The message is appropriate');
});
Expand All @@ -168,7 +168,7 @@ module('Acceptance | jobs list', function(hooks) {

assert.equal(currentURL(), '/jobs?page=2', 'Page query param captures page=2');

await JobsList.search('foobar');
await JobsList.search.fillIn('foobar');

assert.equal(currentURL(), '/jobs?search=foobar', 'No page query param');
});
Expand Down
Loading