mirror of
https://github.com/datahub-project/datahub.git
synced 2025-09-08 00:28:37 +00:00
Merge pull request #1425 from igbopie/search
Replace aupac-typeahead with power-select
This commit is contained in:
commit
a8cd76f8bc
@ -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');
|
||||
}
|
||||
}
|
||||
});
|
76
wherehows-web/app/components/search/containers/search-box.ts
Normal file
76
wherehows-web/app/components/search/containers/search-box.ts
Normal 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: ''
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
154
wherehows-web/app/components/search/search-box.ts
Normal file
154
wherehows-web/app/components/search/search-box.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
@ -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';
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@ -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' } = {} }
|
||||
|
@ -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
|
||||
|
@ -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();
|
||||
|
@ -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', '');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
});
|
@ -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;
|
||||
}
|
||||
|
@ -2,6 +2,7 @@
|
||||
|
||||
@import 'bootstrap';
|
||||
@import 'ember-power-select';
|
||||
@import 'ember-power-select-typeahead';
|
||||
@import 'ember-power-calendar';
|
||||
|
||||
@import 'abstracts/all';
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,3 @@
|
||||
@import 'ember-power-select';
|
||||
@import 'ember-radio-button';
|
||||
@import 'ember-tooltip';
|
||||
@import 'ember-aupac-typeahead';
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -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}}
|
||||
|
||||
|
@ -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>
|
@ -0,0 +1 @@
|
||||
{{yield keyword placeholder (perform onTypeahead) (action onSearch)}}
|
28
wherehows-web/app/templates/components/search/search-box.hbs
Normal file
28
wherehows-web/app/templates/components/search/search-box.hbs
Normal 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>
|
@ -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"}}
|
7
wherehows-web/app/typings/app/search/suggestions.d.ts
vendored
Normal file
7
wherehows-web/app/typings/app/search/suggestions.d.ts
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
export interface ISuggestionsApi {
|
||||
input: string;
|
||||
}
|
||||
export interface ISuggestionsResponse {
|
||||
input: string;
|
||||
source: Array<string>;
|
||||
}
|
4
wherehows-web/app/typings/app/services.d.ts
vendored
4
wherehows-web/app/typings/app/services.d.ts
vendored
@ -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;
|
||||
|
1
wherehows-web/app/typings/global-plugin.d.ts
vendored
1
wherehows-web/app/typings/global-plugin.d.ts
vendored
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
21
wherehows-web/app/utils/api/search/suggestions.ts
Normal file
21
wherehows-web/app/utils/api/search/suggestions.ts
Normal 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) });
|
16
wherehows-web/app/utils/helpers/ember-concurrency.ts
Normal file
16
wherehows-web/app/utils/helpers/ember-concurrency.ts
Normal 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;
|
||||
}
|
@ -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');
|
||||
|
@ -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) {}
|
||||
|
10
wherehows-web/mirage/helpers/autocomplete-datasets.ts
Normal file
10
wherehows-web/mirage/helpers/autocomplete-datasets.ts
Normal 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()))
|
||||
};
|
||||
};
|
@ -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",
|
||||
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
@ -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;
|
||||
};
|
||||
|
@ -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
|
||||
|
@ -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');
|
||||
});
|
||||
});
|
@ -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}}
|
||||
`);
|
||||
});
|
||||
});
|
@ -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);
|
||||
});
|
||||
});
|
@ -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');
|
||||
});
|
||||
});
|
3
wherehows-web/tests/typings/power-select.d.ts
vendored
Normal file
3
wherehows-web/tests/typings/power-select.d.ts
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
declare module 'ember-power-select/test-support/helpers' {
|
||||
export const typeInSearch: (text: string) => Promise<any>;
|
||||
}
|
1660
wherehows-web/vendor/typeahead.jquery.js
vendored
1660
wherehows-web/vendor/typeahead.jquery.js
vendored
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user