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 @@ + 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 @@ + + 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 & which matches with `matcher` prop', () => { + it('should enclose part of project name in & which matches with `matcher` prop', done => { vm.matcher = 'lab'; - expect(vm.highlightedItemName).toContain('Lab'); + vm.$nextTick() + .then(() => { + expect(vm.$el.querySelector('.js-frequent-items-item-title').innerHTML).toContain( + 'Lab', + ); + }) + .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 = 'gitlab'; + + 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 = '123456'; + + expect(highlight(123456, 45)).toBe(expected); + }); + + it(`should sanitize the input string before highlighting matches`, () => { + const expected = 'hello world'; + + expect(highlight('hello world', '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 = '123'; + + expect(highlight('123', '2', '', '')).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-project'; + + 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); + }); +});