Merge branch 'ce-9262-move-project-search-bar-into-modal-dialog-on-operations-dashboard-page' into 'master'

CE backport: Add reusable project_selector component

See merge request gitlab-org/gitlab-ce!25036
This commit is contained in:
Filipa Lacerda 2019-04-05 16:25:45 +00:00
commit 941e00121c
13 changed files with 672 additions and 73 deletions

View File

@ -1,6 +1,9 @@
<script>
/* eslint-disable vue/require-default-prop */
import Identicon from '../../vue_shared/components/identicon.vue';
import _ from 'underscore';
import Identicon from '~/vue_shared/components/identicon.vue';
import highlight from '~/lib/utils/highlight';
import { truncateNamespace } from '~/lib/utils/text_utility';
export default {
components: {
@ -36,43 +39,13 @@ export default {
},
computed: {
hasAvatar() {
return this.avatarUrl !== null;
return _.isString(this.avatarUrl) && !_.isEmpty(this.avatarUrl);
},
truncatedNamespace() {
return truncateNamespace(this.namespace);
},
highlightedItemName() {
if (this.matcher) {
const matcherRegEx = new RegExp(this.matcher, 'gi');
const matches = this.itemName.match(matcherRegEx);
if (matches && matches.length > 0) {
return this.itemName.replace(matches[0], `<b>${matches[0]}</b>`);
}
}
return this.itemName;
},
/**
* Smartly truncates item namespace by doing two things;
* 1. Only include Group names in path by removing item name
* 2. Only include first and last group names in the path
* when namespace has more than 2 groups present
*
* First part (removal of item name from namespace) can be
* done from backend but doing so involves migration of
* existing item namespaces which is not wise thing to do.
*/
truncatedNamespace() {
if (!this.namespace) {
return null;
}
const namespaceArr = this.namespace.split(' / ');
namespaceArr.splice(-1, 1);
let namespace = namespaceArr.join(' / ');
if (namespaceArr.length > 2) {
namespace = `${namespaceArr[0]} / ... / ${namespaceArr.pop()}`;
}
return namespace;
return highlight(this.itemName, this.matcher);
},
},
};
@ -92,8 +65,16 @@ export default {
/>
</div>
<div class="frequent-items-item-metadata-container">
<div :title="itemName" class="frequent-items-item-title" v-html="highlightedItemName"></div>
<div v-if="truncatedNamespace" :title="namespace" class="frequent-items-item-namespace">
<div
:title="itemName"
class="frequent-items-item-title js-frequent-items-item-title"
v-html="highlightedItemName"
></div>
<div
v-if="namespace"
:title="namespace"
class="frequent-items-item-namespace js-frequent-items-item-namespace"
>
{{ truncatedNamespace }}
</div>
</div>

View File

@ -0,0 +1,44 @@
import fuzzaldrinPlus from 'fuzzaldrin-plus';
import _ from 'underscore';
import sanitize from 'sanitize-html';
/**
* Wraps substring matches with HTML `<span>` elements.
* Inputs are sanitized before highlighting, so this
* filter is safe to use with `v-html` (as long as `matchPrefix`
* and `matchSuffix` are not being dynamically generated).
*
* Note that this function can't be used inside `v-html` as a filter
* (Vue filters cannot be used inside `v-html`).
*
* @param {String} string The string to highlight
* @param {String} match The substring match to highlight in the string
* @param {String} matchPrefix The string to insert at the beginning of a match
* @param {String} matchSuffix The string to insert at the end of a match
*/
export default function highlight(string, match = '', matchPrefix = '<b>', matchSuffix = '</b>') {
if (_.isUndefined(string) || _.isNull(string)) {
return '';
}
if (_.isUndefined(match) || _.isNull(match) || match === '') {
return string;
}
const sanitizedValue = sanitize(string.toString(), { allowedTags: [] });
// occurences is an array of character indices that should be
// highlighted in the original string, i.e. [3, 4, 5, 7]
const occurences = fuzzaldrinPlus.match(sanitizedValue, match.toString());
return sanitizedValue
.split('')
.map((character, i) => {
if (_.contains(occurences, i)) {
return `${matchPrefix}${character}${matchSuffix}`;
}
return character;
})
.join('');
}

View File

@ -1,3 +1,5 @@
import _ from 'underscore';
/**
* Adds a , to a string composed by numbers, at every 3 chars.
*
@ -160,3 +162,33 @@ export const splitCamelCase = string =>
.replace(/([A-Z]+)([A-Z][a-z])/g, ' $1 $2')
.replace(/([a-z\d])([A-Z])/g, '$1 $2')
.trim();
/**
* Intelligently truncates an item's namespace by doing two things:
* 1. Only include group names in path by removing the item name
* 2. Only include the first and last group names in the path
* when the namespace includes more than 2 groups
*
* @param {String} string A string namespace,
* i.e. "My Group / My Subgroup / My Project"
*/
export const truncateNamespace = (string = '') => {
if (_.isNull(string) || !_.isString(string)) {
return '';
}
const namespaceArray = string.split(' / ');
if (namespaceArray.length === 1) {
return string;
}
namespaceArray.splice(-1, 1);
let namespace = namespaceArray.join(' / ');
if (namespaceArray.length > 2) {
namespace = `${namespaceArray[0]} / ... / ${namespaceArray.pop()}`;
}
return namespace;
};

View File

@ -0,0 +1,74 @@
<script>
import { GlButton } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import ProjectAvatar from '~/vue_shared/components/project_avatar/default.vue';
import highlight from '~/lib/utils/highlight';
import { truncateNamespace } from '~/lib/utils/text_utility';
import _ from 'underscore';
export default {
name: 'ProjectListItem',
components: {
Icon,
ProjectAvatar,
GlButton,
},
props: {
project: {
type: Object,
required: true,
validator: p => _.isFinite(p.id) && _.isString(p.name) && _.isString(p.name_with_namespace),
},
selected: {
type: Boolean,
required: true,
},
matcher: {
type: String,
required: false,
default: '',
},
},
computed: {
truncatedNamespace() {
return truncateNamespace(this.project.name_with_namespace);
},
highlightedProjectName() {
return highlight(this.project.name, this.matcher);
},
},
methods: {
onClick() {
this.$emit('click');
},
},
};
</script>
<template>
<gl-button
class="d-flex align-items-center btn pt-1 pb-1 border-0 project-list-item"
@click="onClick"
>
<icon
class="prepend-left-10 append-right-10 flex-shrink-0 position-top-0 js-selected-icon"
:class="{ 'js-selected visible': selected, 'js-unselected invisible': !selected }"
name="mobile-issue-close"
/>
<project-avatar class="flex-shrink-0 js-project-avatar" :project="project" :size="32" />
<div class="d-flex flex-wrap project-namespace-name-container">
<div
v-if="truncatedNamespace"
:title="project.name_with_namespace"
class="text-secondary text-truncate js-project-namespace"
>
{{ truncatedNamespace }}
<span v-if="truncatedNamespace" class="text-secondary">/&nbsp;</span>
</div>
<div
:title="project.name"
class="js-project-name text-truncate"
v-html="highlightedProjectName"
></div>
</div>
</gl-button>
</template>

View File

@ -0,0 +1,103 @@
<script>
import _ from 'underscore';
import { GlLoadingIcon } from '@gitlab/ui';
import ProjectListItem from './project_list_item.vue';
const SEARCH_INPUT_TIMEOUT_MS = 500;
export default {
name: 'ProjectSelector',
components: {
GlLoadingIcon,
ProjectListItem,
},
props: {
projectSearchResults: {
type: Array,
required: true,
},
selectedProjects: {
type: Array,
required: true,
},
showNoResultsMessage: {
type: Boolean,
required: false,
default: false,
},
showMinimumSearchQueryMessage: {
type: Boolean,
required: false,
default: false,
},
showLoadingIndicator: {
type: Boolean,
required: false,
default: false,
},
showSearchErrorMessage: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
searchQuery: '',
};
},
methods: {
projectClicked(project) {
this.$emit('projectClicked', project);
},
isSelected(project) {
return Boolean(_.findWhere(this.selectedProjects, { id: project.id }));
},
focusSearchInput() {
this.$refs.searchInput.focus();
},
onInput: _.debounce(function debouncedOnInput() {
this.$emit('searched', this.searchQuery);
}, SEARCH_INPUT_TIMEOUT_MS),
},
};
</script>
<template>
<div>
<input
ref="searchInput"
v-model="searchQuery"
:placeholder="__('Search your projects')"
type="search"
class="form-control mb-3 js-project-selector-input"
autofocus
@input="onInput"
/>
<div class="d-flex flex-column">
<gl-loading-icon v-if="showLoadingIndicator" :size="2" class="py-2 px-4" />
<div v-if="!showLoadingIndicator" class="d-flex flex-column">
<project-list-item
v-for="project in projectSearchResults"
:key="project.id"
:selected="isSelected(project)"
:project="project"
:matcher="searchQuery"
class="js-project-list-item"
@click="projectClicked(project)"
/>
</div>
<div v-if="showNoResultsMessage" class="text-muted ml-2 js-no-results-message">
{{ __('Sorry, no projects matched your search') }}
</div>
<div
v-if="showMinimumSearchQueryMessage"
class="text-muted ml-2 js-minimum-search-query-message"
>
{{ __('Enter at least three characters to search') }}
</div>
<div v-if="showSearchErrorMessage" class="text-danger ml-2 js-search-error-message">
{{ __('Something went wrong, unable to search projects') }}
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,24 @@
.project-list-item {
&:not(:disabled):not(.disabled) {
&:focus,
&:active,
&:focus:active {
outline: none;
box-shadow: none;
}
}
}
// When housed inside a modal, the edge of each item
// should extend to the edge of the modal.
.modal-body {
.project-list-item {
border-radius: 0;
margin-left: -$gl-padding;
margin-right: -$gl-padding;
.project-namespace-name-container {
overflow: hidden;
}
}
}

View File

@ -3178,6 +3178,9 @@ msgstr ""
msgid "Ends at (UTC)"
msgstr ""
msgid "Enter at least three characters to search"
msgstr ""
msgid "Enter in your Bitbucket Server URL and personal access token below"
msgstr ""
@ -7079,6 +7082,9 @@ msgstr ""
msgid "Search users"
msgstr ""
msgid "Search your projects"
msgstr ""
msgid "SearchAutocomplete|All GitLab"
msgstr ""
@ -7474,9 +7480,15 @@ msgstr ""
msgid "Something went wrong while resolving this discussion. Please try again."
msgstr ""
msgid "Something went wrong, unable to search projects"
msgstr ""
msgid "Something went wrong. Please try again."
msgstr ""
msgid "Sorry, no projects matched your search"
msgstr ""
msgid "Sorry, your filter produced no results"
msgstr ""

View File

@ -151,4 +151,31 @@ describe('text_utility', () => {
);
});
});
describe('truncateNamespace', () => {
it(`should return the root namespace if the namespace only includes one level`, () => {
expect(textUtils.truncateNamespace('a / b')).toBe('a');
});
it(`should return the first 2 namespaces if the namespace inlcudes exactly 2 levels`, () => {
expect(textUtils.truncateNamespace('a / b / c')).toBe('a / b');
});
it(`should return the first and last namespaces, separated by "...", if the namespace inlcudes more than 2 levels`, () => {
expect(textUtils.truncateNamespace('a / b / c / d')).toBe('a / ... / c');
expect(textUtils.truncateNamespace('a / b / c / d / e / f / g / h / i')).toBe('a / ... / h');
});
it(`should return an empty string for invalid inputs`, () => {
[undefined, null, 4, {}, true, new Date()].forEach(input => {
expect(textUtils.truncateNamespace(input)).toBe('');
});
});
it(`should not alter strings that aren't formatted as namespaces`, () => {
['', ' ', '\t', 'a', 'a \\ b'].forEach(input => {
expect(textUtils.truncateNamespace(input)).toBe(input);
});
});
});
});

View File

@ -1,25 +1,31 @@
import Vue from 'vue';
import frequentItemsListItemComponent from '~/frequent_items/components/frequent_items_list_item.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { shallowMount } from '@vue/test-utils';
import { trimText } from 'spec/helpers/vue_component_helper';
import { mockProject } from '../mock_data'; // can also use 'mockGroup', but not useful to test here
const createComponent = () => {
const Component = Vue.extend(frequentItemsListItemComponent);
return mountComponent(Component, {
itemId: mockProject.id,
itemName: mockProject.name,
namespace: mockProject.namespace,
webUrl: mockProject.webUrl,
avatarUrl: mockProject.avatarUrl,
return shallowMount(Component, {
propsData: {
itemId: mockProject.id,
itemName: mockProject.name,
namespace: mockProject.namespace,
webUrl: mockProject.webUrl,
avatarUrl: mockProject.avatarUrl,
},
});
};
describe('FrequentItemsListItemComponent', () => {
let wrapper;
let vm;
beforeEach(() => {
vm = createComponent();
wrapper = createComponent();
({ vm } = wrapper);
});
afterEach(() => {
@ -29,11 +35,11 @@ describe('FrequentItemsListItemComponent', () => {
describe('computed', () => {
describe('hasAvatar', () => {
it('should return `true` or `false` if whether avatar is present or not', () => {
vm.avatarUrl = 'path/to/avatar.png';
wrapper.setProps({ avatarUrl: 'path/to/avatar.png' });
expect(vm.hasAvatar).toBe(true);
vm.avatarUrl = null;
wrapper.setProps({ avatarUrl: null });
expect(vm.hasAvatar).toBe(false);
});
@ -41,41 +47,49 @@ describe('FrequentItemsListItemComponent', () => {
describe('highlightedItemName', () => {
it('should enclose part of project name in <b> & </b> which matches with `matcher` prop', () => {
vm.matcher = 'lab';
wrapper.setProps({ matcher: 'lab' });
expect(vm.highlightedItemName).toContain('<b>Lab</b>');
expect(wrapper.find('.js-frequent-items-item-title').html()).toContain(
'<b>L</b><b>a</b><b>b</b>',
);
});
it('should return project name as it is if `matcher` is not available', () => {
vm.matcher = null;
wrapper.setProps({ matcher: null });
expect(vm.highlightedItemName).toBe(mockProject.name);
expect(trimText(wrapper.find('.js-frequent-items-item-title').text())).toBe(
mockProject.name,
);
});
});
describe('truncatedNamespace', () => {
it('should truncate project name from namespace string', () => {
vm.namespace = 'platform / nokia-3310';
wrapper.setProps({ namespace: 'platform / nokia-3310' });
expect(vm.truncatedNamespace).toBe('platform');
expect(trimText(wrapper.find('.js-frequent-items-item-namespace').text())).toBe('platform');
});
it('should truncate namespace string from the middle if it includes more than two groups in path', () => {
vm.namespace = 'platform / hardware / broadcom / Wifi Group / Mobile Chipset / nokia-3310';
wrapper.setProps({
namespace: 'platform / hardware / broadcom / Wifi Group / Mobile Chipset / nokia-3310',
});
expect(vm.truncatedNamespace).toBe('platform / ... / Mobile Chipset');
expect(trimText(wrapper.find('.js-frequent-items-item-namespace').text())).toBe(
'platform / ... / Mobile Chipset',
);
});
});
});
describe('template', () => {
it('should render component element', () => {
expect(vm.$el.classList.contains('frequent-items-list-item-container')).toBeTruthy();
expect(vm.$el.querySelectorAll('a').length).toBe(1);
expect(vm.$el.querySelectorAll('.frequent-items-item-avatar-container').length).toBe(1);
expect(vm.$el.querySelectorAll('.frequent-items-item-metadata-container').length).toBe(1);
expect(vm.$el.querySelectorAll('.frequent-items-item-title').length).toBe(1);
expect(vm.$el.querySelectorAll('.frequent-items-item-namespace').length).toBe(1);
expect(wrapper.classes()).toContain('frequent-items-list-item-container');
expect(wrapper.findAll('a').length).toBe(1);
expect(wrapper.findAll('.frequent-items-item-avatar-container').length).toBe(1);
expect(wrapper.findAll('.frequent-items-item-metadata-container').length).toBe(1);
expect(wrapper.findAll('.frequent-items-item-title').length).toBe(1);
expect(wrapper.findAll('.frequent-items-item-namespace').length).toBe(1);
});
});
});

View File

@ -1,19 +1,22 @@
import Vue from 'vue';
import searchComponent from '~/frequent_items/components/frequent_items_search_input.vue';
import eventHub from '~/frequent_items/event_hub';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { shallowMount } from '@vue/test-utils';
const createComponent = (namespace = 'projects') => {
const Component = Vue.extend(searchComponent);
return mountComponent(Component, { namespace });
return shallowMount(Component, { propsData: { namespace } });
};
describe('FrequentItemsSearchInputComponent', () => {
let wrapper;
let vm;
beforeEach(() => {
vm = createComponent();
wrapper = createComponent();
({ vm } = wrapper);
});
afterEach(() => {
@ -35,7 +38,7 @@ describe('FrequentItemsSearchInputComponent', () => {
describe('mounted', () => {
it('should listen `dropdownOpen` event', done => {
spyOn(eventHub, '$on');
const vmX = createComponent();
const vmX = createComponent().vm;
Vue.nextTick(() => {
expect(eventHub.$on).toHaveBeenCalledWith(
@ -49,7 +52,7 @@ describe('FrequentItemsSearchInputComponent', () => {
describe('beforeDestroy', () => {
it('should unbind event listeners on eventHub', done => {
const vmX = createComponent();
const vmX = createComponent().vm;
spyOn(eventHub, '$off');
vmX.$mount();
@ -67,12 +70,12 @@ describe('FrequentItemsSearchInputComponent', () => {
describe('template', () => {
it('should render component element', () => {
const inputEl = vm.$el.querySelector('input.form-control');
expect(vm.$el.classList.contains('search-input-container')).toBeTruthy();
expect(inputEl).not.toBe(null);
expect(inputEl.getAttribute('placeholder')).toBe('Search your projects');
expect(vm.$el.querySelector('.search-icon')).toBeDefined();
expect(wrapper.classes()).toContain('search-input-container');
expect(wrapper.contains('input.form-control')).toBe(true);
expect(wrapper.contains('.search-icon')).toBe(true);
expect(wrapper.find('input.form-control').attributes('placeholder')).toBe(
'Search your projects',
);
});
});
});

View File

@ -0,0 +1,43 @@
import highlight from '~/lib/utils/highlight';
describe('highlight', () => {
it(`should appropriately surround substring matches`, () => {
const expected = 'g<b>i</b><b>t</b>lab';
expect(highlight('gitlab', 'it')).toBe(expected);
});
it(`should return an empty string in the case of invalid inputs`, () => {
[null, undefined].forEach(input => {
expect(highlight(input, 'match')).toBe('');
});
});
it(`should return the original value if match is null, undefined, or ''`, () => {
[null, undefined].forEach(match => {
expect(highlight('gitlab', match)).toBe('gitlab');
});
});
it(`should highlight matches in non-string inputs`, () => {
const expected = '123<b>4</b><b>5</b>6';
expect(highlight(123456, 45)).toBe(expected);
});
it(`should sanitize the input string before highlighting matches`, () => {
const expected = 'hello <b>w</b>orld';
expect(highlight('hello <b>world</b>', 'w')).toBe(expected);
});
it(`should not highlight anything if no matches are found`, () => {
expect(highlight('gitlab', 'hello')).toBe('gitlab');
});
it(`should allow wrapping elements to be customized`, () => {
const expected = '1<hello>2</hello>3';
expect(highlight('123', '2', '<hello>', '</hello>')).toBe(expected);
});
});

View File

@ -0,0 +1,110 @@
import ProjectListItem from '~/vue_shared/components/project_selector/project_list_item.vue';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { trimText } from 'spec/helpers/vue_component_helper';
const localVue = createLocalVue();
describe('ProjectListItem component', () => {
const Component = localVue.extend(ProjectListItem);
let wrapper;
let vm;
let options;
loadJSONFixtures('projects.json');
const project = getJSONFixture('projects.json')[0];
beforeEach(() => {
options = {
propsData: {
project,
selected: false,
},
sync: false,
localVue,
};
});
afterEach(() => {
wrapper.vm.$destroy();
});
it('does not render a check mark icon if selected === false', () => {
wrapper = shallowMount(Component, options);
expect(wrapper.contains('.js-selected-icon.js-unselected')).toBe(true);
});
it('renders a check mark icon if selected === true', () => {
options.propsData.selected = true;
wrapper = shallowMount(Component, options);
expect(wrapper.contains('.js-selected-icon.js-selected')).toBe(true);
});
it(`emits a "clicked" event when clicked`, () => {
wrapper = shallowMount(Component, options);
({ vm } = wrapper);
spyOn(vm, '$emit');
wrapper.vm.onClick();
expect(wrapper.vm.$emit).toHaveBeenCalledWith('click');
});
it(`renders the project avatar`, () => {
wrapper = shallowMount(Component, options);
expect(wrapper.contains('.js-project-avatar')).toBe(true);
});
it(`renders a simple namespace name with a trailing slash`, () => {
options.propsData.project.name_with_namespace = 'a / b';
wrapper = shallowMount(Component, options);
const renderedNamespace = trimText(wrapper.find('.js-project-namespace').text());
expect(renderedNamespace).toBe('a /');
});
it(`renders a properly truncated namespace with a trailing slash`, () => {
options.propsData.project.name_with_namespace = 'a / b / c / d / e / f';
wrapper = shallowMount(Component, options);
const renderedNamespace = trimText(wrapper.find('.js-project-namespace').text());
expect(renderedNamespace).toBe('a / ... / e /');
});
it(`renders the project name`, () => {
options.propsData.project.name = 'my-test-project';
wrapper = shallowMount(Component, options);
const renderedName = trimText(wrapper.find('.js-project-name').text());
expect(renderedName).toBe('my-test-project');
});
it(`renders the project name with highlighting in the case of a search query match`, () => {
options.propsData.project.name = 'my-test-project';
options.propsData.matcher = 'pro';
wrapper = shallowMount(Component, options);
const renderedName = trimText(wrapper.find('.js-project-name').html());
const expected = 'my-test-<b>p</b><b>r</b><b>o</b>ject';
expect(renderedName).toContain(expected);
});
it('prevents search query and project name XSS', () => {
const alertSpy = spyOn(window, 'alert');
options.propsData.project.name = "my-xss-pro<script>alert('XSS');</script>ject";
options.propsData.matcher = "pro<script>alert('XSS');</script>";
wrapper = shallowMount(Component, options);
const renderedName = trimText(wrapper.find('.js-project-name').html());
const expected = 'my-xss-project';
expect(renderedName).toContain(expected);
expect(alertSpy).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,132 @@
import Vue from 'vue';
import _ from 'underscore';
import ProjectSelector from '~/vue_shared/components/project_selector/project_selector.vue';
import ProjectListItem from '~/vue_shared/components/project_selector/project_list_item.vue';
import { shallowMount } from '@vue/test-utils';
import { trimText } from 'spec/helpers/vue_component_helper';
describe('ProjectSelector component', () => {
let wrapper;
let vm;
loadJSONFixtures('projects.json');
const allProjects = getJSONFixture('projects.json');
const searchResults = allProjects.slice(0, 5);
let selected = [];
selected = selected.concat(allProjects.slice(0, 3)).concat(allProjects.slice(5, 8));
beforeEach(() => {
jasmine.clock().install();
wrapper = shallowMount(Vue.extend(ProjectSelector), {
propsData: {
projectSearchResults: searchResults,
selectedProjects: selected,
showNoResultsMessage: false,
showMinimumSearchQueryMessage: false,
showLoadingIndicator: false,
showSearchErrorMessage: false,
},
attachToDocument: true,
});
({ vm } = wrapper);
});
afterEach(() => {
jasmine.clock().uninstall();
vm.$destroy();
});
it('renders the search results', () => {
expect(wrapper.findAll('.js-project-list-item').length).toBe(5);
});
it(`triggers a (debounced) search when the search input value changes`, () => {
spyOn(vm, '$emit');
const query = 'my test query!';
const searchInput = wrapper.find('.js-project-selector-input');
searchInput.setValue(query);
searchInput.trigger('input');
expect(vm.$emit).not.toHaveBeenCalledWith();
jasmine.clock().tick(501);
expect(vm.$emit).toHaveBeenCalledWith('searched', query);
});
it(`debounces the search input`, () => {
spyOn(vm, '$emit');
const searchInput = wrapper.find('.js-project-selector-input');
const updateSearchQuery = (count = 0) => {
if (count === 10) {
jasmine.clock().tick(101);
expect(vm.$emit).toHaveBeenCalledTimes(1);
expect(vm.$emit).toHaveBeenCalledWith('searched', `search query #9`);
} else {
searchInput.setValue(`search query #${count}`);
searchInput.trigger('input');
jasmine.clock().tick(400);
updateSearchQuery(count + 1);
}
};
updateSearchQuery();
});
it(`includes a placeholder in the search box`, () => {
expect(wrapper.find('.js-project-selector-input').attributes('placeholder')).toBe(
'Search your projects',
);
});
it(`triggers a "projectClicked" event when a project is clicked`, () => {
spyOn(vm, '$emit');
wrapper.find(ProjectListItem).vm.$emit('click', _.first(searchResults));
expect(vm.$emit).toHaveBeenCalledWith('projectClicked', _.first(searchResults));
});
it(`shows a "no results" message if showNoResultsMessage === true`, () => {
wrapper.setProps({ showNoResultsMessage: true });
expect(wrapper.contains('.js-no-results-message')).toBe(true);
const noResultsEl = wrapper.find('.js-no-results-message');
expect(trimText(noResultsEl.text())).toEqual('Sorry, no projects matched your search');
});
it(`shows a "minimum seach query" message if showMinimumSearchQueryMessage === true`, () => {
wrapper.setProps({ showMinimumSearchQueryMessage: true });
expect(wrapper.contains('.js-minimum-search-query-message')).toBe(true);
const minimumSearchEl = wrapper.find('.js-minimum-search-query-message');
expect(trimText(minimumSearchEl.text())).toEqual('Enter at least three characters to search');
});
it(`shows a error message if showSearchErrorMessage === true`, () => {
wrapper.setProps({ showSearchErrorMessage: true });
expect(wrapper.contains('.js-search-error-message')).toBe(true);
const errorMessageEl = wrapper.find('.js-search-error-message');
expect(trimText(errorMessageEl.text())).toEqual(
'Something went wrong, unable to search projects',
);
});
it(`focuses the input element when the focusSearchInput() method is called`, () => {
const input = wrapper.find('.js-project-selector-input');
expect(document.activeElement).not.toBe(input.element);
vm.focusSearchInput();
expect(document.activeElement).toBe(input.element);
});
});