diff options
12 files changed, 676 insertions, 48 deletions
diff --git a/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue b/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue index 42d14b65b3a..92c3bcb5012 100644 --- a/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue +++ b/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue @@ -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); }, - 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 truncateNamespace(this.namespace); + }, + highlightedItemName() { + 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> diff --git a/app/assets/javascripts/lib/utils/highlight.js b/app/assets/javascripts/lib/utils/highlight.js new file mode 100644 index 00000000000..4f7eff2cca1 --- /dev/null +++ b/app/assets/javascripts/lib/utils/highlight.js @@ -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(''); +} diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js index c49b1bb5a2f..386d69ea430 100644 --- a/app/assets/javascripts/lib/utils/text_utility.js +++ b/app/assets/javascripts/lib/utils/text_utility.js @@ -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 (_.isUndefined(string) || _.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; +}; diff --git a/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue new file mode 100644 index 00000000000..071bae7f665 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue @@ -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">/ </span> + </div> + <div + :title="project.name" + class="js-project-name text-truncate" + v-html="highlightedProjectName" + ></div> + </div> + </gl-button> +</template> diff --git a/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue b/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue new file mode 100644 index 00000000000..bb17a9b331e --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue @@ -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(e) { + this.$emit('searched', e.target.value); + }, 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> diff --git a/app/assets/stylesheets/components/project_list_item.scss b/app/assets/stylesheets/components/project_list_item.scss new file mode 100644 index 00000000000..6f9933e3d70 --- /dev/null +++ b/app/assets/stylesheets/components/project_list_item.scss @@ -0,0 +1,27 @@ +.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; + + // should be replaced by Bootstrap's + // .overflow-hidden utility class once + // we upgrade Bootstrap to at least 4.2.x + .project-namespace-name-container { + overflow: hidden; + } + } +} diff --git a/locale/gitlab.pot b/locale/gitlab.pot index fcbd34a05d5..5626e196d37 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -3166,6 +3166,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 "" @@ -7013,6 +7016,9 @@ msgstr "" msgid "Search users" msgstr "" +msgid "Search your projects" +msgstr "" + msgid "SearchAutocomplete|All GitLab" msgstr "" @@ -7405,9 +7411,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 "" diff --git a/spec/frontend/lib/utils/text_utility_spec.js b/spec/frontend/lib/utils/text_utility_spec.js index 0a266b19ea5..3f331055a32 100644 --- a/spec/frontend/lib/utils/text_utility_spec.js +++ b/spec/frontend/lib/utils/text_utility_spec.js @@ -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); + }); + }); + }); }); diff --git a/spec/javascripts/frequent_items/components/frequent_items_list_item_spec.js b/spec/javascripts/frequent_items/components/frequent_items_list_item_spec.js index 7deed985219..92554bd9a69 100644 --- a/spec/javascripts/frequent_items/components/frequent_items_list_item_spec.js +++ b/spec/javascripts/frequent_items/components/frequent_items_list_item_spec.js @@ -1,6 +1,7 @@ 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 { trimText } from 'spec/helpers/vue_component_helper'; import { mockProject } from '../mock_data'; // can also use 'mockGroup', but not useful to test here const createComponent = () => { @@ -40,30 +41,58 @@ describe('FrequentItemsListItemComponent', () => { }); describe('highlightedItemName', () => { - it('should enclose part of project name in <b> & </b> which matches with `matcher` prop', () => { + it('should enclose part of project name in <b> & </b> which matches with `matcher` prop', done => { vm.matcher = 'lab'; - expect(vm.highlightedItemName).toContain('<b>Lab</b>'); + vm.$nextTick() + .then(() => { + expect(vm.$el.querySelector('.js-frequent-items-item-title').innerHTML).toContain( + '<b>L</b><b>a</b><b>b</b>', + ); + }) + .then(done) + .catch(done.fail); }); - it('should return project name as it is if `matcher` is not available', () => { + it('should return project name as it is if `matcher` is not available', done => { vm.matcher = null; - expect(vm.highlightedItemName).toBe(mockProject.name); + vm.$nextTick() + .then(() => { + expect(vm.$el.querySelector('.js-frequent-items-item-title').innerHTML).toBe( + mockProject.name, + ); + }) + .then(done) + .catch(done.fail); }); }); describe('truncatedNamespace', () => { - it('should truncate project name from namespace string', () => { + it('should truncate project name from namespace string', done => { vm.namespace = 'platform / nokia-3310'; - expect(vm.truncatedNamespace).toBe('platform'); + vm.$nextTick() + .then(() => { + expect( + trimText(vm.$el.querySelector('.js-frequent-items-item-namespace').innerHTML), + ).toBe('platform'); + }) + .then(done) + .catch(done.fail); }); - it('should truncate namespace string from the middle if it includes more than two groups in path', () => { + it('should truncate namespace string from the middle if it includes more than two groups in path', done => { vm.namespace = 'platform / hardware / broadcom / Wifi Group / Mobile Chipset / nokia-3310'; - expect(vm.truncatedNamespace).toBe('platform / ... / Mobile Chipset'); + vm.$nextTick() + .then(() => { + expect( + trimText(vm.$el.querySelector('.js-frequent-items-item-namespace').innerHTML), + ).toBe('platform / ... / Mobile Chipset'); + }) + .then(done) + .catch(done.fail); }); }); }); @@ -74,8 +103,8 @@ describe('FrequentItemsListItemComponent', () => { 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(vm.$el.querySelectorAll('.js-frequent-items-item-title').length).toBe(1); + expect(vm.$el.querySelectorAll('.js-frequent-items-item-namespace').length).toBe(1); }); }); }); diff --git a/spec/javascripts/lib/utils/higlight_spec.js b/spec/javascripts/lib/utils/higlight_spec.js new file mode 100644 index 00000000000..638bbf65ae9 --- /dev/null +++ b/spec/javascripts/lib/utils/higlight_spec.js @@ -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); + }); +}); diff --git a/spec/javascripts/vue_shared/components/project_selector/project_list_item_spec.js b/spec/javascripts/vue_shared/components/project_selector/project_list_item_spec.js new file mode 100644 index 00000000000..8dbdfe97f8f --- /dev/null +++ b/spec/javascripts/vue_shared/components/project_selector/project_list_item_spec.js @@ -0,0 +1,104 @@ +import _ from 'underscore'; +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', () => { + let wrapper; + let vm; + loadJSONFixtures('projects.json'); + const project = getJSONFixture('projects.json')[0]; + + beforeEach(() => { + wrapper = shallowMount(localVue.extend(ProjectListItem), { + propsData: { + project, + selected: false, + }, + sync: false, + localVue, + }); + + ({ vm } = wrapper); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('does not render a check mark icon if selected === false', () => { + expect(vm.$el.querySelector('.js-selected-icon.js-unselected')).toBeTruthy(); + }); + + it('renders a check mark icon if selected === true', done => { + wrapper.setProps({ selected: true }); + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.js-selected-icon.js-selected')).toBeTruthy(); + done(); + }); + }); + + it(`emits a "clicked" event when clicked`, () => { + spyOn(vm, '$emit'); + vm.onClick(); + + expect(vm.$emit).toHaveBeenCalledWith('click'); + }); + + it(`renders the project avatar`, () => { + expect(vm.$el.querySelector('.js-project-avatar')).toBeTruthy(); + }); + + it(`renders a simple namespace name with a trailing slash`, done => { + project.name_with_namespace = 'a / b'; + wrapper.setProps({ project: _.clone(project) }); + + vm.$nextTick(() => { + const renderedNamespace = trimText(vm.$el.querySelector('.js-project-namespace').textContent); + + expect(renderedNamespace).toBe('a /'); + done(); + }); + }); + + it(`renders a properly truncated namespace with a trailing slash`, done => { + project.name_with_namespace = 'a / b / c / d / e / f'; + wrapper.setProps({ project: _.clone(project) }); + + vm.$nextTick(() => { + const renderedNamespace = trimText(vm.$el.querySelector('.js-project-namespace').textContent); + + expect(renderedNamespace).toBe('a / ... / e /'); + done(); + }); + }); + + it(`renders the project name`, done => { + project.name = 'my-test-project'; + wrapper.setProps({ project: _.clone(project) }); + + vm.$nextTick(() => { + const renderedName = trimText(vm.$el.querySelector('.js-project-name').innerHTML); + + expect(renderedName).toBe('my-test-project'); + done(); + }); + }); + + it(`renders the project name with highlighting in the case of a search query match`, done => { + project.name = 'my-test-project'; + wrapper.setProps({ project: _.clone(project), matcher: 'pro' }); + + vm.$nextTick(() => { + const renderedName = trimText(vm.$el.querySelector('.js-project-name').innerHTML); + + const expected = 'my-test-<b>p</b><b>r</b><b>o</b>ject'; + + expect(renderedName).toBe(expected); + done(); + }); + }); +}); diff --git a/spec/javascripts/vue_shared/components/project_selector/project_selector_spec.js b/spec/javascripts/vue_shared/components/project_selector/project_selector_spec.js new file mode 100644 index 00000000000..88c1dff76a1 --- /dev/null +++ b/spec/javascripts/vue_shared/components/project_selector/project_selector_spec.js @@ -0,0 +1,152 @@ +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(vm.$el.querySelectorAll('.js-project-list-item').length).toBe(5); + }); + + it(`triggers a (debounced) search when the search input value changes`, done => { + spyOn(vm, '$emit'); + const query = 'my test query!'; + const searchInput = vm.$el.querySelector('.js-project-selector-input'); + searchInput.value = query; + searchInput.dispatchEvent(new Event('input')); + + vm.$nextTick(() => { + expect(vm.$emit).not.toHaveBeenCalledWith(); + jasmine.clock().tick(501); + + expect(vm.$emit).toHaveBeenCalledWith('searched', query); + done(); + }); + }); + + it(`debounces the search input`, done => { + spyOn(vm, '$emit'); + const searchInput = vm.$el.querySelector('.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`); + done(); + } else { + searchInput.value = `search query #${count}`; + searchInput.dispatchEvent(new Event('input')); + + vm.$nextTick(() => { + jasmine.clock().tick(400); + updateSearchQuery(count + 1); + }); + } + }; + + updateSearchQuery(); + }); + + it(`includes a placeholder in the search box`, () => { + expect(vm.$el.querySelector('.js-project-selector-input').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`, done => { + wrapper.setProps({ showNoResultsMessage: true }); + + vm.$nextTick(() => { + const noResultsEl = vm.$el.querySelector('.js-no-results-message'); + + expect(noResultsEl).toBeTruthy(); + + expect(trimText(noResultsEl.textContent)).toEqual('Sorry, no projects matched your search'); + + done(); + }); + }); + + it(`shows a "minimum seach query" message if showMinimumSearchQueryMessage === true`, done => { + wrapper.setProps({ showMinimumSearchQueryMessage: true }); + + vm.$nextTick(() => { + const minimumSearchEl = vm.$el.querySelector('.js-minimum-search-query-message'); + + expect(minimumSearchEl).toBeTruthy(); + + expect(trimText(minimumSearchEl.textContent)).toEqual( + 'Enter at least three characters to search', + ); + + done(); + }); + }); + + it(`shows a error message if showSearchErrorMessage === true`, done => { + wrapper.setProps({ showSearchErrorMessage: true }); + + vm.$nextTick(() => { + const errorMessageEl = vm.$el.querySelector('.js-search-error-message'); + + expect(errorMessageEl).toBeTruthy(); + + expect(trimText(errorMessageEl.textContent)).toEqual( + 'Something went wrong, unable to search projects', + ); + + done(); + }); + }); + + it(`focuses the input element when the focusSearchInput() method is called`, () => { + const input = vm.$el.querySelector('.js-project-selector-input'); + + expect(document.activeElement).not.toBe(input); + vm.focusSearchInput(); + + expect(document.activeElement).toBe(input); + }); +}); |