Merge pull request #1425 from igbopie/search

Replace aupac-typeahead with power-select
This commit is contained in:
Ignacio Bona Piedrabuena 2018-10-02 15:32:27 -07:00 committed by GitHub
commit a8cd76f8bc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
42 changed files with 713 additions and 2155 deletions

View File

@ -1,138 +0,0 @@
import Component from '@ember/component';
import { computed, set, get } from '@ember/object';
import { inject as service } from '@ember/service';
import { debounce } from '@ember/runloop';
import { Keyboard } from 'wherehows-web/constants/keyboard';
/**
* Number of milliseconds to wait before triggering a request for keywords
* @type {Number}
*/
const keyPressDelay = 180;
export default Component.extend({
/**
* Service to retrieve type ahead keywords for a dataset
* @type {Ember.Service}
*/
keywords: service('search-keywords'),
/**
* Service to bind the search bar form to a global hotkey, allowing us to instantly jump to the
* search whenever the user presses the '/' key
* @type {Ember.Service}
*/
hotKeys: service(),
// Keywords and search Category filter
currentFilter: 'datasets',
tagName: 'form',
elementId: 'global-search-form',
search: '',
/**
* Based on the currentFilter returns placeholder text
*/
placeholder: computed('currentFilter', function() {
return `Search ${get(this, 'currentFilter')} by keywords... e.g. pagekey`;
}),
/**
* Creates an instance function that can be referenced by key. Acts as a proxy to the queryResolver
* function which is a new function on each invocation. This allows the debouncing function to retain
* a reference to the same function for multiple invocations.
* @return {*}
*/
debouncedResolver() {
const queryResolver = get(this, 'keywords.apiResultsFor')(get(this, 'currentFilter'));
return queryResolver(...arguments);
},
/**
* Sets to the focus to the input in the search bar
*/
setSearchBarFocus() {
const searchBar = document.getElementsByClassName('nacho-global-search__text-input')[1];
searchBar && searchBar.focus();
},
/**
* Sets to the blur to the input in the search bar
*/
setSearchBarBlur() {
const searchBar = document.getElementsByClassName('nacho-global-search__text-input')[1];
searchBar && searchBar.blur();
},
didInsertElement() {
this._super(...arguments);
// Registering this hotkey allows us to jump to focus the search bar when pressing '/'
get(this, 'hotKeys').registerKeyMapping(Keyboard.Slash, this.setSearchBarFocus.bind(this));
},
willDestroyElement() {
this._super(...arguments);
get(this, 'hotKeys').unregisterKeyMapping(Keyboard.Slash);
},
actions: {
/**
* When a search action is performed, invoke the parent search action with
* the user entered search value as keyword and the currentFilter
* as category. Triggered by clicking the search button
*/
search() {
get(this, 'didSearch')({
keyword: get(this, 'search'),
category: get(this, 'currentFilter')
});
this.setSearchBarBlur();
},
/**
* Triggers a search action by the user pressing enter on a typeahead suggestion
* @param {string} suggestion - suggestion text passed in from aupac-typeahead
*/
onSelectedSuggestion(suggestion, evtName) {
set(this, 'search', suggestion);
// Annoying issue where you focus out and it triggers search.
if (evtName !== 'focusout') {
this.actions.search.call(this);
}
},
/**
* Handles the text input action for potential typeahead matches
*/
onInput() {
// Delay invocation until after the given number of ms after each
// text input
debounce(this, this.debouncedResolver, [...arguments], keyPressDelay);
},
/**
* The current dynamic-link implementation sends an action without the option
* to pass arguments or using closure actions, hence the need to create
* ugly separate individual actions for each filter
* A PR will be created to fix this and then the implementation below
* refactored to correct this.
* TODO: DSS-6760 Create PR to handle action as closure action in dynamic-link
* component
*/
filterDatasets() {
set(this, 'currentFilter', 'datasets');
},
filterMetrics() {
set(this, 'currentFilter', 'metrics');
},
filterFlows() {
set(this, 'currentFilter', 'flows');
}
}
});

View File

@ -0,0 +1,76 @@
import Component from '@ember/component';
import { readSuggestions } from 'wherehows-web/utils/api/search/suggestions';
import { RouterService } from 'ember';
import { service } from '@ember-decorators/service';
import { task, timeout } from 'ember-concurrency';
import SearchService from 'wherehows-web/services/search';
import { ISuggestionsResponse } from 'wherehows-web/typings/app/search/suggestions';
import { isEmpty } from '@ember/utils';
import { alias } from '@ember-decorators/object/computed';
/**
* Runtime cache of recently seen typeahead results
* @type {Object.<Object>} a hash of urls to results
*/
const keywordResultsCache: Record<string, Array<string>> = {};
/**
* Search box container that handle all the data part for
* the search box
*/
export default class SearchBoxContainer extends Component {
@service
router: RouterService;
@service
search: SearchService;
/**
* Placeholder for input
*/
placeholder: string = 'Search datasets by keywords... e.g. pagekey';
/**
* Search route will update the service with current keywords
* since url will contain keyword
*/
@alias('search.keyword')
keyword: string;
/**
* Suggestions handle. Will debounce, cache suggestions requests
* @param {string} text suggestion for this text
*/
onTypeahead = task<Array<string> | Promise<ISuggestionsResponse | void>, string>(function*(text: string) {
if (text.length > 2) {
const cachedKeywords = keywordResultsCache[String(text)];
if (!isEmpty(cachedKeywords)) {
return cachedKeywords;
}
// debounce: see https://ember-power-select.com/cookbook/debounce-searches/
yield timeout(200);
const response: ISuggestionsResponse = yield readSuggestions({ input: text });
keywordResultsCache[String(text)] = response.source;
return response.source;
} else {
return [];
}
}).restartable();
/**
* When search actually happens, then we transition to a new route.
* @param text search term
*/
onSearch(text: string): void {
this.router.transitionTo('search', {
queryParams: {
keyword: text,
category: 'datasets',
page: 1,
facets: ''
}
});
}
}

View File

@ -0,0 +1,154 @@
import Component from '@ember/component';
import { set, setProperties } from '@ember/object';
import { IPowerSelectAPI } from 'wherehows-web/typings/modules/power-select';
import { PromiseOrTask, isTask } from 'wherehows-web/utils/helpers/ember-concurrency';
/**
* Presentation component that renders a search box
*/
export default class SearchBox extends Component {
/**
* HBS Expected Parameter
* The value of the input
* note: recommend (readonly) helper.
*/
text!: string;
/**
* HBS Expected Parameter
* Action when the user actually wants to search
*/
onSearch!: (q: string) => void;
/**
* HBS Expected Parameter
* Action when the user types into the input, so we can show suggestions
*/
onTypeahead!: (text: string) => PromiseOrTask<Array<string>>;
/**
* internal field to save temporal inputs in the text input
*/
inputText: string;
/**
* suggestions array that we will use to empty the current suggestions
*/
suggestions: Array<string>;
/**
* when suggestions box is open, we will save a reference to power-select
* se we can close it.
*/
powerSelectApi?: IPowerSelectAPI<string>;
/**
* When a search task is on going, we save it so we can cancel it when a new one comes.
*/
typeaheadTask?: PromiseOrTask<Array<string>>;
/**
* When new attrs, update inputText with latest text
*/
didReceiveAttrs(): void {
set(this, 'inputText', this.text);
}
/**
* Will cancel typeaheadTask if available
*/
cancelTypeaheadTask(): void {
if (isTask(this.typeaheadTask)) {
this.typeaheadTask.cancel();
}
set(this, 'typeaheadTask', undefined);
}
/**
* When the input transitioned from focus->blur
* Reset suggestions, save text and cancel previous search.
*/
onBlur(): void {
this.cancelTypeaheadTask();
set(this, 'text', this.inputText);
}
/**
* When the input transitioned from blur->focus
* Restore inputText value from text, open suggestions, and search latest term
*/
onFocus(pws: IPowerSelectAPI<string>): void {
setProperties(this, {
inputText: this.text,
powerSelectApi: pws
});
if (this.text) {
pws.actions.search(this.text);
pws.actions.open();
}
}
/**
* Before we call onTypeahead, we cancel the last search task if available
* and save the new one
* @param text user typed text
*/
typeahead(text: string): PromiseOrTask<Array<string>> {
this.cancelTypeaheadTask();
const typeaheadTask = this.onTypeahead(text);
set(this, 'typeaheadTask', typeaheadTask);
return typeaheadTask;
}
/**
* Power select forces us to return undefined to prevent to select
* the first item on the list.
*/
defaultHighlighted(): undefined {
return;
}
/**
* When user types text we save it
* @param text user typed text
*/
onInput(text: string): void {
set(this, 'inputText', text);
}
/**
* When user selects an item from the list
*/
onChange(selected: string): void {
if (selected && selected.trim().length > 0) {
setProperties(this, {
text: selected,
inputText: selected
});
this.onSearch(selected);
}
}
/**
* When user intents to perform a search
*/
onSubmit(): void {
if (this.inputText && this.inputText.trim().length > 0) {
// this will prevent search text from jitter,
// since inputText may differ from text, PWS will try to restore
// text since there was not a selection, but we will set text from the route
// at a later point. That will cause the search box to show for example:
// => car // press enter
// => somethingelse //after onSubmit
// => car // route sets car
set(this, 'text', this.inputText);
this.onSearch(this.inputText);
if (this.powerSelectApi) {
this.powerSelectApi.actions.close();
}
}
}
}

View File

@ -1,10 +1,8 @@
import Controller from '@ember/controller';
import Session from 'ember-simple-auth/services/session';
import Search from 'wherehows-web/services/search';
import UserLookup from 'wherehows-web/services/user-lookup';
import Notifications from 'wherehows-web/services/notifications';
import BannerService from 'wherehows-web/services/banners';
import { action } from '@ember-decorators/object';
import { service } from '@ember-decorators/service';
export default class Application extends Controller {
@ -15,13 +13,6 @@ export default class Application extends Controller {
@service
session: Session;
/**
* Injected global search service
* @type {Search}
*/
@service
search: Search;
/**
* Looks up user names and properties from the partyEntities api
* @type {UserLookup}
@ -44,25 +35,9 @@ export default class Application extends Controller {
@service('banners')
banners: BannerService;
/**
* Keyword of the current search to pass it down to the search bar
*/
keyword: string;
constructor() {
super(...arguments);
this.ldapUsers.fetchUserNames();
}
/**
* Invokes the search service api to transition to the
* search results page with the search parameters
* @param {String} [keyword] the search string to search for
* @param {String} [category] restrict search to results found here
*/
@action
didSearch({ keyword, category }: { keyword: string; category: string }) {
this.search.showSearchResults({ keyword, category });
}
}

View File

@ -1,7 +1,7 @@
import Controller from '@ember/controller';
import { computed } from '@ember-decorators/object';
import { debounce } from '@ember-decorators/runloop';
import { facetToParamUrl, facetFromParamUrl, facetToDynamicCounts } from 'wherehows-web/utils/api/search';
import { facetToParamUrl, facetFromParamUrl, facetToDynamicCounts } from 'wherehows-web/utils/api/search/search';
import { IFacetsSelectionsMap, IFacetsCounts } from 'wherehows-web/typings/app/search/facets';
import { set, setProperties, get } from '@ember/object';

View File

@ -179,30 +179,5 @@ export default Route.extend(ApplicationRouteMixin, {
tables: true,
renderer: markedRendererOverride
});
},
/**
* To make keyword accesible to the search bar
* we need to read it from the search query parameters
* and pass it down to the controller.
* @param {*} controller
* @param {*} model
*/
setupController(controller, model) {
const keyword = this.paramsFor('search').keyword;
controller.set('keyword', keyword);
controller.set('model', model);
},
actions: {
/**
* Make sure we keep the keywork updated, so if we return
* home, the search term clears
*/
willTransition() {
const controller = this.controllerFor('application');
const keyword = this.paramsFor('search').keyword;
controller.set('keyword', keyword);
}
}
});

View File

@ -2,7 +2,7 @@ import Route from '@ember/routing/route';
import AuthenticatedRouteMixin from 'ember-simple-auth/mixins/authenticated-route-mixin';
export default class Browse extends Route.extend(AuthenticatedRouteMixin) {
afterModel(_model: any, transition: import('ember').Ember.Transition) {
afterModel(_model: any, transition: EmberTransition) {
// Extract the entity being viewed from the transition state
const {
params: { 'browse.entity': { entity = 'datasets' } = {} }

View File

@ -72,7 +72,7 @@ export default class DatasetRoute extends Route {
throw new TypeError(`Could not parse identifier ${dataset_id}. Please ensure format is valid.`);
}
afterModel(resolvedModel: object, transition: import('ember').Ember.Transition): void {
afterModel(resolvedModel: object, transition: EmberTransition): void {
const { dataset_id } = transition.params['datasets.dataset'];
// Check is dataset_id is a number, and replace with urn

View File

@ -22,10 +22,10 @@ export default class IndexRoute extends Route.extend(AuthenticatedRouteMixin) {
/**
* Perform post model operations
* @param {import('ember').Ember.Transition} transition
* @param {EmberTransition} transition
* @return {Promise}
*/
async beforeModel(transition: import('ember').Ember.Transition) {
async beforeModel(transition: EmberTransition) {
super.beforeModel(transition);
await this._trackCurrentUser();

View File

@ -2,10 +2,16 @@ import Route from '@ember/routing/route';
import AuthenticatedRouteMixin from 'ember-simple-auth/mixins/authenticated-route-mixin';
import createSearchEntries from 'wherehows-web/utils/datasets/create-search-entries';
import { refreshModelForQueryParams } from 'wherehows-web/utils/helpers/routes';
import { readSearch } from 'wherehows-web/utils/api/search';
import { readSearch } from 'wherehows-web/utils/api/search/search';
import { action } from '@ember-decorators/object';
import { service } from '@ember-decorators/service';
import SearchService from 'wherehows-web/services/search';
import { set } from '@ember/object';
export default class SearchRoute extends Route.extend(AuthenticatedRouteMixin) {
@service
search: SearchService;
// Set `refreshModel` for each queryParam to true
// so each url state change results in a full transition
queryParams = refreshModelForQueryParams(['category', 'page', 'facets', 'keyword']);
@ -16,7 +22,9 @@ export default class SearchRoute extends Route.extend(AuthenticatedRouteMixin) {
async model(apiParams: any): Promise<{ keywords: string; data: Array<any> } | object> {
const { result } = await readSearch(apiParams);
const { keywords, data } = result || { keywords: '', data: [] };
createSearchEntries(data, keywords);
set(this.search, 'keyword', keywords);
return result || {};
}
@ -24,11 +32,23 @@ export default class SearchRoute extends Route.extend(AuthenticatedRouteMixin) {
* Add spinner when model is loading
*/
@action
loading(transition: import('ember').Ember.Transition): void {
loading(transition: EmberTransition): void {
let controller = this.controllerFor('search');
controller.set('searchLoading', true);
set(controller, 'searchLoading', true);
transition.promise!.finally(function() {
controller.set('searchLoading', false);
set(controller, 'searchLoading', false);
});
}
/**
* In order to keep the service up date with the state. The router pass
* the keyword from the queryParams to the service.
* @param transition Ember transition
*/
@action
willTransition(transition: EmberTransition & { targetName: string }) {
if (transition.targetName !== 'search') {
set(this.search, 'keyword', '');
}
}
}

View File

@ -1,79 +0,0 @@
import Ember from 'ember';
import buildUrl from 'wherehows-web/utils/build-url';
import { isEmpty } from '@ember/utils';
import Service from '@ember/service';
const {
$: { getJSON }
} = Ember;
/**
* Runtime cache of recently seen typeahead results
* @type {Object.<Object>} a hash of urls to results
*/
const keywordResultsCache = {};
/**
* a reference to to most recent typeahead query
* @type {String}
*/
let lastSeenQuery = '';
/**
* Map of routeNames to routes
* @param {String} routeName name of the route to return
* @return {String} route url of the keyword route
*/
const keywordRoutes = routeName =>
({
datasets: '/api/v1/autocomplete/datasets',
metrics: '/api/v1/autocomplete/metrics',
flows: '/api/v1/autocomplete/flows'
}[routeName] || '/api/v1/autocomplete/search');
/**
* Retrieves the keywords for a given url
* @param {String} url the url path to the fetch typeahead keywords from
* @return {Promise.<Object|void>}
*/
const getKeywordsFor = url =>
new Promise(resolve => {
// If we've seen the url for this request in the given session
// there is no need to make a new request. For example when
// a user is backspacing in the search bar
const cachedKeywords = keywordResultsCache[String(url)];
if (!isEmpty(cachedKeywords)) {
return resolve(cachedKeywords);
}
getJSON(url).then(response => {
const { status } = response;
status === 'ok' && resolve((keywordResultsCache[String(url)] = response));
});
});
/**
* Curried function that takes a source to filter on and then the queryResolver
* function
* @param {('datasets'|'metrics'|'flows')} source filters the keywords results
* on the provided source
* @inner {function([String, Function, Function])} a queryResolver
* function
*/
const apiResultsFor = (source = 'datasets') => ([query, , asyncResults]) => {
const autoCompleteBaseUrl = keywordRoutes(source);
const url = buildUrl(autoCompleteBaseUrl, 'input', query);
lastSeenQuery = query;
getKeywordsFor(url)
.then(({ source = [], input }) => {
if (input === lastSeenQuery) {
return source;
}
})
.then(asyncResults);
};
export default Service.extend({
apiResultsFor
});

View File

@ -1,27 +1,10 @@
import Service from '@ember/service';
import { getOwner } from '@ember/application';
import { isBlank } from '@ember/utils';
import { encode } from 'wherehows-web/utils/encode-decode-uri-component-with-space';
/**
* Search service is used to maintain the same
* state on different parts of the app. Right now the only thing
* we need to persist is the keywords.
*/
export default class Search extends Service {
/**
* Transition to the search route including search keyword as query parameter
* @param {Object} args = {} a map of query parameters to values, including keyword
* @prop {String|*} args.keyword the string to search for
* @returns {void|Transition}
*/
showSearchResults(args: { keyword: string; category: string }) {
let { keyword, category } = args;
// Transition to search route only if value is not null or void
if (!isBlank(keyword)) {
// Lookup application Route on ApplicationInstance
const applicationRoute = getOwner(this).lookup('route:application');
keyword = encode(keyword);
return applicationRoute.transitionTo('search', {
queryParams: { keyword, category, page: 1, facets: '' }
});
}
}
keyword: string;
}

View File

@ -2,6 +2,7 @@
@import 'bootstrap';
@import 'ember-power-select';
@import 'ember-power-select-typeahead';
@import 'ember-power-calendar';
@import 'abstracts/all';

View File

@ -6,17 +6,30 @@ $app-search-bar-height: 40px;
&__text-input {
height: $app-search-bar-height;
background: transparent;
// trying to be more specific than other rule
&.form-control:not(:first-child):not(:last-child),
& {
border-radius: 2px 0 0 2px;
}
input {
border: 0;
transition-duration: 334ms;
transition-property: border-color, box-shadow;
box-sizing: border-box;
border-radius: 2px 0 0 2px;
&:focus {
border: 0;
box-shadow: 0px 0px 2px 1px get-color(blue7) inset;
}
}
}
/**
* Rules for the search bar filter component
*/
&__filter {
width: 75px;
display: inline-flex;
white-space: nowrap;
overflow: hidden;
justify-content: flex-end;
.input-group-btn:last-child > .btn {
margin-left: 0px;
}
.search-button {
@ -24,4 +37,26 @@ $app-search-bar-height: 40px;
background-color: $secondary-color;
color: $text-color;
}
&__trigger {
border-radius: 2px 0 0 2px;
}
&__dropdown {
&.ember-power-select-dropdown.ember-basic-dropdown-content--in-place {
top: $app-search-bar-height;
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175);
border: 0px;
}
.ember-power-select-options {
max-height: 300px;
border: 1px solid get-color(gray3);
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175);
&:empty {
border: 0;
}
}
}
}

View File

@ -1,4 +1,3 @@
@import 'ember-power-select';
@import 'ember-radio-button';
@import 'ember-tooltip';
@import 'ember-aupac-typeahead';

View File

@ -1,8 +0,0 @@
.nacho-global-search__text-input {
// Thank you twitter for letting us override your styles...
background: white !important;
&.tt-hint {
visibility: hidden;
}
}

View File

@ -10,7 +10,13 @@
Search for datasets
</header>
<section class="nacho-hero__content">
{{search-bar-form selection=keyword didSearch=(action "didSearch")}}
{{#search/containers/search-box as |keyword placeholder onTypeahead onSearch|}}
{{search/search-box
placeholder=placeholder
text=(readonly keyword)
onTypeahead=(action onTypeahead)
onSearch=(action onSearch)}}
{{/search/containers/search-box}}
</section>
{{/hero-container}}

View File

@ -1,23 +0,0 @@
<div class="form-group nacho-global-search">
<div class="input-group">
<label for="search-input" class="sr-only">Search</label>
{{aupac-typeahead
selection=(readonly selection)
action=(action "onSelectedSuggestion")
autoFocus=false
allowFreeInput=true
source=(action "onInput")
async=true
limit=20
minLength=3
placeholder=placeholder
class="form-control nacho-global-search__text-input"}}
<span class="input-group-btn">
<button class="btn btn-default nacho-button--large search-button" type="submit" {{action "search"}} id="global-search-button">
<span class="glyphicon glyphicon-search" aria-hidden="true"></span>
</button>
</span>
</div>
</div>

View File

@ -0,0 +1 @@
{{yield keyword placeholder (perform onTypeahead) (action onSearch)}}

View File

@ -0,0 +1,28 @@
<form class="form-group nacho-global-search" {{action onSubmit on="submit"}}>
<div class="input-group">
<label for="search-input" class="sr-only">Search</label>
{{#power-select-typeahead
selected=text
placeholder=placeholder
defaultHighlighted=(action defaultHighlighted)
loadingMessage=''
noMatchesMessage=''
renderInPlace=true
search=(action typeahead)
oninput=(action onInput)
onchange=(action onChange)
onblur=(action onBlur)
onfocus=(action onFocus)
dropdownClass="nacho-global-search__dropdown"
triggerClass="form-control nacho-global-search__text-input" as |suggestion|}}
{{suggestion}}
{{/power-select-typeahead}}
<span class="input-group-btn">
<button class="btn btn-default nacho-button--large search-button" type="submit" id="global-search-button">
<span class="glyphicon glyphicon-search" aria-hidden="true"></span>
</button>
</span>
</div>
</form>

View File

@ -1,10 +0,0 @@
{{aupac-typeahead
source=userNamesResolver
action=(action "findUser")
autoFocus=true
async=true
limit=10
minLength=2
placeholder="Add an Owner"
title="username"
class="user-lookup__input"}}

View File

@ -0,0 +1,7 @@
export interface ISuggestionsApi {
input: string;
}
export interface ISuggestionsResponse {
input: string;
source: Array<string>;
}

View File

@ -2,17 +2,17 @@ declare module '@ember/service' {
import Session from 'ember-simple-auth/services/session';
import CurrentUser from 'wherehows-web/services/current-user';
import Metrics from 'ember-metrics';
import Search from 'wherehows-web/services/search';
import BannerService from 'wherehows-web/services/banners';
import Notifications from 'wherehows-web/services/notifications';
import UserLookup from 'wherehows-web/services/user-lookup';
import HotKeys from 'wherehows-web/services/hot-keys';
import Search from 'wherehows-web/services/search';
// eslint-disable-next-line typescript/interface-name-prefix
interface Registry {
search: Search;
session: Session;
metrics: Metrics;
search: Search;
banners: BannerService;
notifications: Notifications;
'current-user': CurrentUser;

View File

@ -4,4 +4,5 @@ import Ember from 'ember';
declare global {
// eslint-disable-next-line typescript/no-empty-interface, typescript/interface-name-prefix
interface Array<T> extends Ember.ArrayPrototypeExtensions<T> {}
type EmberTransition = Ember.Transition;
}

View File

@ -14,4 +14,15 @@ export interface IPowerSelectAPI<T> {
searchText: string; // Contains the text of the current search
selected: Array<T>; // Contains the resolved selected option (or options in multiple selects)
uniqueId: string; // Contains the unique of this instance of EmberPowerSelect. It's of the form `ember1234`.
actions: {
choose(option: T): void; // Chooses the given options if it's not disabled (slight different than `select`)
close(): void; // Closes the select
highlight(option: T): void; // Highlights the given option (if it's not disabled)
open(): void; // Opens the select
reposition(): void; // Repositions the dropdown (noop if renderInPlace=true)
scrollTo(option: T): void; // Scrolls the given option into the viewport
search(term: string): void; // Performs a search
select(option: T): void; // Selects the given option (if it's not disabled)
toggle(): void;
};
}

View File

@ -0,0 +1,21 @@
import { getJSON } from 'wherehows-web/utils/api/fetcher';
import { getApiRoot } from 'wherehows-web/utils/api/shared';
import buildUrl from 'wherehows-web/utils/build-url';
import { ISuggestionsApi, ISuggestionsResponse } from 'wherehows-web/typings/app/search/suggestions';
/**
* Build suggestions url
* @param {ISuggestionApi} params api contract
* @return {string} return a url with get paramenters attached
*/
export const suggestionsUrl = (params: ISuggestionsApi): string => {
return buildUrl(`${getApiRoot()}/autocomplete/datasets`, params);
};
/**
* Fetch suggestions from API
* @param {ISuggestionApi} params api contract
* @return {Promise<ISuggestionsResponse>} returns a promise of the suggestions api response
*/
export const readSuggestions = (params: ISuggestionsApi): Promise<ISuggestionsResponse> =>
getJSON<ISuggestionsResponse>({ url: suggestionsUrl(params) });

View File

@ -0,0 +1,16 @@
import { TaskInstance } from 'ember-concurrency';
/**
* A task can be used instead of a promise in some cases, but a task
* has the advantage of being cancellable. See ember-concurrency.
*/
export type PromiseOrTask<T> = PromiseLike<T> | TaskInstance<T> | undefined;
/**
* Will check if the type is a promise or a task. The difference is that
* a task is cancellable where as a promise not (for now).
* @param obj the object to check
*/
export function isTask<T>(obj: PromiseOrTask<T>): obj is TaskInstance<T> {
return typeof obj !== 'undefined' && (<TaskInstance<T>>obj).cancel !== undefined;
}

View File

@ -112,7 +112,6 @@ module.exports = function (defaults) {
app.import('node_modules/jquery-treegrid/js/jquery.treegrid.js');
app.import('node_modules/json-human/src/json.human.js');
app.import('node_modules/jquery-jsonview/dist/jquery.jsonview.js');
app.import('vendor/typeahead.jquery.js');
app.import('node_modules/marked/marked.min.js');
app.import('node_modules/scrollmonitor/scrollMonitor.js');
app.import('vendor/shims/scrollmonitor.js');

View File

@ -31,6 +31,7 @@ import { getDatasetDownstreams } from 'wherehows-web/mirage/helpers/dataset-down
import { getBrowsePlatforms } from 'wherehows-web/mirage/helpers/browse-platforms';
import { getSearchResults } from 'wherehows-web/mirage/helpers/search';
import { getDatasetExportPolicy } from 'wherehows-web/mirage/helpers/export-policy';
import { getAutocompleteDatasets } from 'wherehows-web/mirage/helpers/autocomplete-datasets';
export default function(this: IMirageServer) {
this.get('/config', getConfig);
@ -253,6 +254,8 @@ export default function(this: IMirageServer) {
*/
this.post('/acl', aclAuth);
this.get('/search', getSearchResults);
this.get('/autocomplete/datasets', getAutocompleteDatasets);
}
export function testConfig(this: IMirageServer) {}

View File

@ -0,0 +1,10 @@
import { faker } from 'ember-cli-mirage';
import { dasherize } from '@ember/string';
export const getAutocompleteDatasets = function(_: any, request: any) {
return {
status: 'ok',
input: request.queryParams.input,
source: [...Array(10)].map(() => dasherize(faker.lorem.words().toLowerCase()))
};
};

View File

@ -101,7 +101,6 @@
"dependencies": {
"bootstrap": "3.3.7",
"dynamic-link": "^0.2.3",
"ember-aupac-typeahead": "3.1.0",
"ember-cli-mirage": "^0.4.9",
"ember-cli-string-helpers": "^1.4.0",
"ember-lodash": "^4.18.0",

View File

@ -50,6 +50,6 @@ module('Acceptance | search', function(hooks) {
await click(getCheckboxSelector('ei'));
assert.equal(currentURL(), '/search?facets=(fabric%3AList(prod%2Ccorp%2Cei))&keyword=car&page=1');
assert.equal(currentURL(), '/search?facets=(fabric%3AList(prod%2Ccorp%2Cei))&keyword=car');
});
});

View File

@ -8,12 +8,34 @@ export const getTextNoSpaces = (test: TestContext) => {
return getText(test).replace(/\s/gi, '');
};
export const querySelector = <E extends Element>(test: TestContext, selector: string): E | null => {
export const getElement = (test: TestContext) => {
const element = test.element;
if (!element) {
return null;
throw new Error(`Base element not found`);
}
return element.querySelector<E>(selector);
return element;
};
export const querySelector = <E extends Element>(test: TestContext, selector: string): E => {
const element = getElement(test);
const selectedElement = element.querySelector<E>(selector);
if (!selectedElement) {
throw new Error(`Element ${selector} not found`);
}
return selectedElement;
};
export const querySelectorAll = <E extends Element>(test: TestContext, selector: string): NodeListOf<E> => {
const element = getElement(test);
const selectedElements = element.querySelectorAll<E>(selector);
if (!selectedElements) {
throw new Error(`Elements ${selector} not found`);
}
return selectedElements;
};

View File

@ -1,7 +1,7 @@
/**
* Page constant for typeahead input on the search bar
*/
export const searchBarSelector = 'input.nacho-global-search__text-input.tt-input';
export const searchBarSelector = '.nacho-global-search__text-input input';
/**
* Page constant for search button

View File

@ -1,81 +0,0 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, click } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';
import { querySelector } from 'wherehows-web/tests/helpers/dom-helpers';
import {
searchBarSelector,
searchButton,
searchSuggestions
} from 'wherehows-web/tests/helpers/search/global-search-constants';
import { TestContext } from 'ember-test-helpers';
const createSearchTest = () => {
let searchPerformed = false;
const testObj = {
keyword: 'car',
didSearch: () => {
searchPerformed = true;
},
wasSearchPerformed: () => {
return searchPerformed;
}
};
return testObj;
};
/**
* Will setup search test: create mock data, render and query dom
* @param test current test
*/
const setupSearchTest = async (test: TestContext) => {
const testObj = createSearchTest();
test.setProperties(testObj);
await render(hbs`{{search-bar-form selection=keyword didSearch=(action didSearch)}}`);
const input = querySelector<HTMLInputElement>(test, searchBarSelector);
const suggestions = querySelector(test, searchSuggestions);
if (!input) {
throw new Error('input should not be null');
}
if (!suggestions) {
throw new Error('suggestions should not be null');
}
return { input, suggestions, testObj };
};
module('Integration | Component | search-bar-form', function(hooks) {
setupRenderingTest(hooks);
test('it renders', async function(assert) {
const { input, testObj } = await setupSearchTest(this);
assert.notEqual(input, null, 'Input is not null');
assert.equal(input.value, testObj.keyword);
});
test('blur does not trigger search', async function(assert) {
const { input, testObj } = await setupSearchTest(this);
await input.focus();
await input.blur();
assert.notOk(testObj.wasSearchPerformed(), 'Search should not be triggered');
});
test('suggestions box hides after search', async function(assert) {
const { input, testObj, suggestions } = await setupSearchTest(this);
await input.focus();
assert.ok((suggestions.classList.contains('tt-open'), 'Suggestions must be open'));
await click(searchButton);
assert.ok(testObj.wasSearchPerformed(), 'Search was performed');
assert.notOk(suggestions.classList.contains('tt-open'), 'Suggestion box should not be open after search');
});
});

View File

@ -0,0 +1,78 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import { getText } from 'wherehows-web/tests/helpers/dom-helpers';
import hbs from 'htmlbars-inline-precompile';
import Component from '@ember/component';
import { TaskInstance } from 'ember-concurrency';
import { TestContext } from 'ember-test-helpers';
interface ITestWithMirageContext extends TestContext {
server: any;
}
const getMirageHandle = (test: ITestWithMirageContext, api: string, verb: string) => {
return test.server.pretender.hosts.forURL(api)[verb.toLocaleUpperCase()].recognize(api)[0].handler;
};
const containerComponentTest = (test: TestContext, testFn: (me: Component) => void) => {
test.owner.register(
'component:container-stub',
class ContainerStub extends Component {
didInsertElement() {
testFn(this);
}
}
);
};
interface IContainerStub extends Component {
onTypeahead: (word: string) => TaskInstance<Array<string>>;
}
module('Integration | Component | search/containers/search-box', function(hooks) {
setupRenderingTest(hooks);
test('it renders', async function(assert) {
await render(hbs`
{{#search/containers/search-box as |keyword placeholder onTypeahead onSearch|}}
template block text
{{/search/containers/search-box}}
`);
assert.equal(getText(this).trim(), 'template block text');
});
test('onTypeahead', async function(this: ITestWithMirageContext, assert) {
const apiHandler = getMirageHandle(this, '/api/v1/autocomplete/datasets', 'get');
assert.expect(6);
containerComponentTest(this, async (component: IContainerStub) => {
// dont return anything with less than 3
const results1 = await component.onTypeahead('h');
assert.equal(results1.length, 0);
// return list
const results2 = await component.onTypeahead('hol');
assert.ok(results2.length > 0);
// cache return
const results3 = await component.onTypeahead('hol');
assert.ok(results3.length > 0);
assert.equal(apiHandler.numberOfCalls, 1, 'cached return');
// debounce
component.onTypeahead('hola');
component.onTypeahead('hola ');
const results4 = await component.onTypeahead('hola nacho');
assert.ok(results4.length > 0);
assert.equal(apiHandler.numberOfCalls, 2, 'App debounces calls');
});
await render(hbs`
{{#search/containers/search-box as |keyword placeholder onTypeahead onSearch|}}
{{container-stub onTypeahead=(action onTypeahead)}}
{{/search/containers/search-box}}
`);
});
});

View File

@ -0,0 +1,187 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, click, triggerEvent } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';
import { setProperties } from '@ember/object';
import { TestContext } from 'ember-test-helpers';
import { getText, querySelector, querySelectorAll } from 'wherehows-web/tests/helpers/dom-helpers';
import { typeInSearch } from 'ember-power-select/test-support/helpers';
interface ISearchBoxContract {
placeholder: string;
text?: string;
onTypeahead: () => PromiseLike<Array<string>>;
onSearch: (text: string) => void;
}
interface ISearchBoxTestContext extends TestContext, ISearchBoxContract {}
const inputSelector = '.ember-power-select-search-input';
const optionSelector = '.ember-power-select-option';
const searchButtonSelector = '.search-button';
const getInput = (test: ISearchBoxTestContext): HTMLInputElement => {
return querySelector<HTMLInputElement>(test, inputSelector);
};
const focusInput = async () => {
await click(inputSelector); // Focus
await triggerEvent(inputSelector, 'focus');
};
const blurInput = async () => {
await click('button'); // Blur
await triggerEvent(inputSelector, 'blur');
};
const getBaseTest = async (test: ISearchBoxTestContext, override: ISearchBoxContract | {} = {}) => {
const props: ISearchBoxContract = {
placeholder: 'this is my placeholder',
text: undefined,
onTypeahead: async () => [],
onSearch: () => {},
...override
};
setProperties(test, props);
await render(hbs`
{{#search/search-box
placeholder=(readonly placeholder)
text=(readonly text)
onTypeahead=(action onTypeahead)
onSearch=(action onSearch) as |suggestion|}}
{{suggestion}}
{{/search/search-box}}
`);
return props;
};
module('Integration | Component | search/search-box', function(hooks) {
setupRenderingTest(hooks);
test('it renders', async function(this: ISearchBoxTestContext, assert) {
const props = await getBaseTest(this);
assert.equal(getText(this), 'Search');
assert.equal(getInput(this).placeholder, props.placeholder);
assert.equal(getInput(this).value, '');
});
test('input shows text', async function(this: ISearchBoxTestContext, assert) {
const props = await getBaseTest(this, { text: 'search this' });
assert.equal(getText(this), 'Search');
assert.equal(getInput(this).placeholder, props.placeholder);
assert.equal(getInput(this).value, props.text);
});
test('input shows text, and it does not lose state', async function(this: ISearchBoxTestContext, assert) {
const word = 'Hola carambola';
await getBaseTest(this);
const input = getInput(this);
await focusInput();
await typeInSearch(word);
assert.equal(input.value, word);
await blurInput();
assert.equal(input.value, word);
await focusInput();
assert.equal(input.value, word);
});
test('show list when text present and focus', async function(this: ISearchBoxTestContext, assert) {
const searchOptions = ['one', 'two'];
await getBaseTest(this, { text: 'search this', onTypeahead: async () => searchOptions });
await focusInput();
const domOptions = querySelectorAll(this, optionSelector);
assert.equal(domOptions.length, 2, 'options should show up');
searchOptions.forEach((searchOption: string, index: number) => {
const content = domOptions[index].textContent || '';
assert.equal(content.trim(), searchOption);
});
await blurInput();
const domOptionsGone = querySelectorAll(this, optionSelector);
assert.equal(domOptionsGone.length, 0, 'options should hide');
});
test('search button triggers search with actual text', async function(this: ISearchBoxTestContext, assert) {
const expectedSearch = 'expected search';
let searchedWord = '';
const onSearch = (word: string) => {
searchedWord = word;
};
await getBaseTest(this, { text: expectedSearch, onSearch });
await click(searchButtonSelector);
assert.equal(searchedWord, expectedSearch);
});
test('request not completed, will be cancelled on blur', async function(this: ISearchBoxTestContext, assert) {
let cancelled = false;
// Does not ever resolve
const onTypeahead = () => {
return {
cancel: () => {
cancelled = true;
}
};
};
await getBaseTest(this, { onTypeahead });
await typeInSearch('something');
await blurInput();
assert.ok(cancelled);
});
test('when suggestions opens, no suggestion is selected', async function(this: ISearchBoxTestContext, assert) {
const searchOptions = ['one', 'two'];
await getBaseTest(this, { text: 'search this', onTypeahead: async () => searchOptions });
await focusInput();
assert.equal(querySelectorAll(this, '.ember-power-select-option[aria-current=true]').length, 0);
});
test('click on suggestion should work', async function(this: ISearchBoxTestContext, assert) {
const expectedSearch = 'expected search';
let searchedWord = '';
const onSearch = (word: string) => {
searchedWord = word;
};
const searchOptions = ['one', 'two'];
await getBaseTest(this, { text: expectedSearch, onTypeahead: async () => searchOptions, onSearch });
await focusInput();
await click('.ember-power-select-option:first-child');
assert.equal(searchedWord, searchOptions[0]);
});
test('pressing enter should trigger search and suggestion box should go away', async function(
this: ISearchBoxTestContext,
assert
) {
const searchOptions = ['one', 'two'];
const expectedSearch = 'expected search';
let searchedWord = '';
const onSearch = (word: string) => {
searchedWord = word;
};
await getBaseTest(this, { text: expectedSearch, onSearch, onTypeahead: async () => searchOptions });
await focusInput();
await triggerEvent('form', 'submit');
await assert.equal(searchedWord, expectedSearch);
assert.equal(querySelectorAll(this, '.ember-power-select-option').length, 0);
});
});

View File

@ -1,28 +0,0 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, triggerEvent } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';
module('Integration | Component | user lookup', function(hooks) {
setupRenderingTest(hooks);
const userLookupTypeahead = '.user-lookup__input';
test('it renders', async function(assert) {
await render(hbs`{{user-lookup}}`);
assert.equal(document.querySelector(userLookupTypeahead).tagName, 'INPUT');
});
test('it triggers the findUser action', async function(assert) {
let findUserActionCallCount = 0;
this.set('findUser', () => {
findUserActionCallCount++;
assert.equal(findUserActionCallCount, 1, 'findUser action is invoked when triggered');
});
await render(hbs`{{user-lookup didFindUser=findUser}}`);
assert.equal(findUserActionCallCount, 0, 'findUser action is not invoked on instantiation');
triggerEvent(userLookupTypeahead, 'input');
});
});

View File

@ -0,0 +1,3 @@
declare module 'ember-power-select/test-support/helpers' {
export const typeInSearch: (text: string) => Promise<any>;
}

File diff suppressed because it is too large Load Diff

View File

@ -2369,12 +2369,6 @@ core-util-is@1.0.2, core-util-is@~1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"
corejs-typeahead@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/corejs-typeahead/-/corejs-typeahead-1.2.1.tgz#345a8afe664cc494075b59b64777807f0b3f132b"
dependencies:
jquery ">=1.11"
cosmiconfig@^5.0.2:
version "5.0.6"
resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-5.0.6.tgz#dca6cf680a0bd03589aff684700858c81abeeb39"
@ -2691,25 +2685,6 @@ ember-ast-helpers@0.3.5:
"@glimmer/compiler" "^0.27.0"
"@glimmer/syntax" "^0.27.0"
ember-aupac-control@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/ember-aupac-control/-/ember-aupac-control-1.2.0.tgz#9ae49d5c8d0d9a6e2888ebec3c5354b0547829bb"
dependencies:
bootstrap "3.3.7"
ember-cli-babel "^6.6.0"
ember-cli-htmlbars "^2.0.1"
ember-aupac-typeahead@3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/ember-aupac-typeahead/-/ember-aupac-typeahead-3.1.0.tgz#4b2c741fd0b65ea7e1befcf8823fc793e41a21ad"
dependencies:
broccoli-funnel "^2.0.1"
broccoli-merge-trees "^2.0.0"
corejs-typeahead "^1.2.1"
ember-aupac-control "^1.2.0"
ember-cli-babel "^6.6.0"
ember-cli-htmlbars "^2.0.1"
ember-basic-dropdown@^1.0.0:
version "1.0.3"
resolved "https://registry.yarnpkg.com/ember-basic-dropdown/-/ember-basic-dropdown-1.0.3.tgz#bd785c84ea2b366951e0630f173c84677ed53b6c"
@ -5639,7 +5614,7 @@ jquery-treegrid@^0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/jquery-treegrid/-/jquery-treegrid-0.3.0.tgz#e445d4e789cbd85ec045ed467192d70b2ab189d3"
jquery@>=1.11, jquery@^3.3.0, jquery@^3.3.1:
jquery@^3.3.0, jquery@^3.3.1:
version "3.3.1"
resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.3.1.tgz#958ce29e81c9790f31be7792df5d4d95fc57fbca"