summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue57
-rw-r--r--app/assets/javascripts/lib/utils/highlight.js44
-rw-r--r--app/assets/javascripts/lib/utils/text_utility.js32
-rw-r--r--app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue74
-rw-r--r--app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue103
-rw-r--r--app/assets/stylesheets/components/project_list_item.scss27
-rw-r--r--locale/gitlab.pot12
-rw-r--r--spec/frontend/lib/utils/text_utility_spec.js27
-rw-r--r--spec/javascripts/frequent_items/components/frequent_items_list_item_spec.js49
-rw-r--r--spec/javascripts/lib/utils/higlight_spec.js43
-rw-r--r--spec/javascripts/vue_shared/components/project_selector/project_list_item_spec.js104
-rw-r--r--spec/javascripts/vue_shared/components/project_selector/project_selector_spec.js152
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">/&nbsp;</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);
+ });
+});