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:
commit
941e00121c
|
@ -1,6 +1,9 @@
|
||||||
<script>
|
<script>
|
||||||
/* eslint-disable vue/require-default-prop */
|
/* 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 {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
|
@ -36,43 +39,13 @@ export default {
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
hasAvatar() {
|
hasAvatar() {
|
||||||
return this.avatarUrl !== null;
|
return _.isString(this.avatarUrl) && !_.isEmpty(this.avatarUrl);
|
||||||
|
},
|
||||||
|
truncatedNamespace() {
|
||||||
|
return truncateNamespace(this.namespace);
|
||||||
},
|
},
|
||||||
highlightedItemName() {
|
highlightedItemName() {
|
||||||
if (this.matcher) {
|
return highlight(this.itemName, 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;
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -92,8 +65,16 @@ export default {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="frequent-items-item-metadata-container">
|
<div class="frequent-items-item-metadata-container">
|
||||||
<div :title="itemName" class="frequent-items-item-title" v-html="highlightedItemName"></div>
|
<div
|
||||||
<div v-if="truncatedNamespace" :title="namespace" class="frequent-items-item-namespace">
|
: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 }}
|
{{ truncatedNamespace }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -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('');
|
||||||
|
}
|
|
@ -1,3 +1,5 @@
|
||||||
|
import _ from 'underscore';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds a , to a string composed by numbers, at every 3 chars.
|
* 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]+)([A-Z][a-z])/g, ' $1 $2')
|
||||||
.replace(/([a-z\d])([A-Z])/g, '$1 $2')
|
.replace(/([a-z\d])([A-Z])/g, '$1 $2')
|
||||||
.trim();
|
.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;
|
||||||
|
};
|
||||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -3178,6 +3178,9 @@ msgstr ""
|
||||||
msgid "Ends at (UTC)"
|
msgid "Ends at (UTC)"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Enter at least three characters to search"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Enter in your Bitbucket Server URL and personal access token below"
|
msgid "Enter in your Bitbucket Server URL and personal access token below"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
@ -7079,6 +7082,9 @@ msgstr ""
|
||||||
msgid "Search users"
|
msgid "Search users"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Search your projects"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "SearchAutocomplete|All GitLab"
|
msgid "SearchAutocomplete|All GitLab"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
@ -7474,9 +7480,15 @@ msgstr ""
|
||||||
msgid "Something went wrong while resolving this discussion. Please try again."
|
msgid "Something went wrong while resolving this discussion. Please try again."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Something went wrong, unable to search projects"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Something went wrong. Please try again."
|
msgid "Something went wrong. Please try again."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Sorry, no projects matched your search"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Sorry, your filter produced no results"
|
msgid "Sorry, your filter produced no results"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,25 +1,31 @@
|
||||||
import Vue from 'vue';
|
import Vue from 'vue';
|
||||||
import frequentItemsListItemComponent from '~/frequent_items/components/frequent_items_list_item.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
|
import { mockProject } from '../mock_data'; // can also use 'mockGroup', but not useful to test here
|
||||||
|
|
||||||
const createComponent = () => {
|
const createComponent = () => {
|
||||||
const Component = Vue.extend(frequentItemsListItemComponent);
|
const Component = Vue.extend(frequentItemsListItemComponent);
|
||||||
|
|
||||||
return mountComponent(Component, {
|
return shallowMount(Component, {
|
||||||
|
propsData: {
|
||||||
itemId: mockProject.id,
|
itemId: mockProject.id,
|
||||||
itemName: mockProject.name,
|
itemName: mockProject.name,
|
||||||
namespace: mockProject.namespace,
|
namespace: mockProject.namespace,
|
||||||
webUrl: mockProject.webUrl,
|
webUrl: mockProject.webUrl,
|
||||||
avatarUrl: mockProject.avatarUrl,
|
avatarUrl: mockProject.avatarUrl,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('FrequentItemsListItemComponent', () => {
|
describe('FrequentItemsListItemComponent', () => {
|
||||||
|
let wrapper;
|
||||||
let vm;
|
let vm;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vm = createComponent();
|
wrapper = createComponent();
|
||||||
|
|
||||||
|
({ vm } = wrapper);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
@ -29,11 +35,11 @@ describe('FrequentItemsListItemComponent', () => {
|
||||||
describe('computed', () => {
|
describe('computed', () => {
|
||||||
describe('hasAvatar', () => {
|
describe('hasAvatar', () => {
|
||||||
it('should return `true` or `false` if whether avatar is present or not', () => {
|
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);
|
expect(vm.hasAvatar).toBe(true);
|
||||||
|
|
||||||
vm.avatarUrl = null;
|
wrapper.setProps({ avatarUrl: null });
|
||||||
|
|
||||||
expect(vm.hasAvatar).toBe(false);
|
expect(vm.hasAvatar).toBe(false);
|
||||||
});
|
});
|
||||||
|
@ -41,41 +47,49 @@ describe('FrequentItemsListItemComponent', () => {
|
||||||
|
|
||||||
describe('highlightedItemName', () => {
|
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', () => {
|
||||||
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', () => {
|
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', () => {
|
describe('truncatedNamespace', () => {
|
||||||
it('should truncate project name from namespace string', () => {
|
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', () => {
|
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', () => {
|
describe('template', () => {
|
||||||
it('should render component element', () => {
|
it('should render component element', () => {
|
||||||
expect(vm.$el.classList.contains('frequent-items-list-item-container')).toBeTruthy();
|
expect(wrapper.classes()).toContain('frequent-items-list-item-container');
|
||||||
expect(vm.$el.querySelectorAll('a').length).toBe(1);
|
expect(wrapper.findAll('a').length).toBe(1);
|
||||||
expect(vm.$el.querySelectorAll('.frequent-items-item-avatar-container').length).toBe(1);
|
expect(wrapper.findAll('.frequent-items-item-avatar-container').length).toBe(1);
|
||||||
expect(vm.$el.querySelectorAll('.frequent-items-item-metadata-container').length).toBe(1);
|
expect(wrapper.findAll('.frequent-items-item-metadata-container').length).toBe(1);
|
||||||
expect(vm.$el.querySelectorAll('.frequent-items-item-title').length).toBe(1);
|
expect(wrapper.findAll('.frequent-items-item-title').length).toBe(1);
|
||||||
expect(vm.$el.querySelectorAll('.frequent-items-item-namespace').length).toBe(1);
|
expect(wrapper.findAll('.frequent-items-item-namespace').length).toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,19 +1,22 @@
|
||||||
import Vue from 'vue';
|
import Vue from 'vue';
|
||||||
import searchComponent from '~/frequent_items/components/frequent_items_search_input.vue';
|
import searchComponent from '~/frequent_items/components/frequent_items_search_input.vue';
|
||||||
import eventHub from '~/frequent_items/event_hub';
|
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 createComponent = (namespace = 'projects') => {
|
||||||
const Component = Vue.extend(searchComponent);
|
const Component = Vue.extend(searchComponent);
|
||||||
|
|
||||||
return mountComponent(Component, { namespace });
|
return shallowMount(Component, { propsData: { namespace } });
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('FrequentItemsSearchInputComponent', () => {
|
describe('FrequentItemsSearchInputComponent', () => {
|
||||||
|
let wrapper;
|
||||||
let vm;
|
let vm;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vm = createComponent();
|
wrapper = createComponent();
|
||||||
|
|
||||||
|
({ vm } = wrapper);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
@ -35,7 +38,7 @@ describe('FrequentItemsSearchInputComponent', () => {
|
||||||
describe('mounted', () => {
|
describe('mounted', () => {
|
||||||
it('should listen `dropdownOpen` event', done => {
|
it('should listen `dropdownOpen` event', done => {
|
||||||
spyOn(eventHub, '$on');
|
spyOn(eventHub, '$on');
|
||||||
const vmX = createComponent();
|
const vmX = createComponent().vm;
|
||||||
|
|
||||||
Vue.nextTick(() => {
|
Vue.nextTick(() => {
|
||||||
expect(eventHub.$on).toHaveBeenCalledWith(
|
expect(eventHub.$on).toHaveBeenCalledWith(
|
||||||
|
@ -49,7 +52,7 @@ describe('FrequentItemsSearchInputComponent', () => {
|
||||||
|
|
||||||
describe('beforeDestroy', () => {
|
describe('beforeDestroy', () => {
|
||||||
it('should unbind event listeners on eventHub', done => {
|
it('should unbind event listeners on eventHub', done => {
|
||||||
const vmX = createComponent();
|
const vmX = createComponent().vm;
|
||||||
spyOn(eventHub, '$off');
|
spyOn(eventHub, '$off');
|
||||||
|
|
||||||
vmX.$mount();
|
vmX.$mount();
|
||||||
|
@ -67,12 +70,12 @@ describe('FrequentItemsSearchInputComponent', () => {
|
||||||
|
|
||||||
describe('template', () => {
|
describe('template', () => {
|
||||||
it('should render component element', () => {
|
it('should render component element', () => {
|
||||||
const inputEl = vm.$el.querySelector('input.form-control');
|
expect(wrapper.classes()).toContain('search-input-container');
|
||||||
|
expect(wrapper.contains('input.form-control')).toBe(true);
|
||||||
expect(vm.$el.classList.contains('search-input-container')).toBeTruthy();
|
expect(wrapper.contains('.search-icon')).toBe(true);
|
||||||
expect(inputEl).not.toBe(null);
|
expect(wrapper.find('input.form-control').attributes('placeholder')).toBe(
|
||||||
expect(inputEl.getAttribute('placeholder')).toBe('Search your projects');
|
'Search your projects',
|
||||||
expect(vm.$el.querySelector('.search-icon')).toBeDefined();
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in New Issue