Commit cc98f239 authored by Filipa Lacerda's avatar Filipa Lacerda

Merge branch...

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

Resolve "Move project search bar into modal dialog on Operations Dashboard page"

Closes #9262

See merge request gitlab-org/gitlab-ee!9260
parents 4ef02f4f 9dc72acc
<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 { ...@@ -36,43 +39,13 @@ export default {
}, },
computed: { computed: {
hasAvatar() { 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() { truncatedNamespace() {
if (!this.namespace) { return truncateNamespace(this.namespace);
return null; },
} highlightedItemName() {
const namespaceArr = this.namespace.split(' / '); return highlight(this.itemName, this.matcher);
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 { ...@@ -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>
......
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('');
}
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 => ...@@ -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;
};
<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>
<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>
.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;
}
}
}
<script> <script>
import _ from 'underscore';
import { mapState, mapActions } from 'vuex'; import { mapState, mapActions } from 'vuex';
import { GlLoadingIcon, GlDashboardSkeleton } from '@gitlab/ui'; import { GlLoadingIcon, GlModal, GlModalDirective, GlButton } from '@gitlab/ui';
import ProjectSearch from 'ee/vue_shared/dashboards/components/project_search.vue'; import ProjectSelector from '~/vue_shared/components/project_selector/project_selector.vue';
import DashboardProject from './project.vue'; import DashboardProject from './project.vue';
export default { export default {
components: { components: {
DashboardProject, DashboardProject,
ProjectSearch, GlModal,
GlLoadingIcon, GlLoadingIcon,
GlDashboardSkeleton, GlButton,
ProjectSelector,
},
directives: {
'gl-modal': GlModalDirective,
}, },
props: { props: {
addPath: { addPath: {
...@@ -29,10 +34,23 @@ export default { ...@@ -29,10 +34,23 @@ export default {
required: true, required: true,
}, },
}, },
modalId: 'add-projects-modal',
computed: { computed: {
...mapState(['projects', 'projectTokens', 'isLoadingProjects']), ...mapState([
addIsDisabled() { 'projects',
return !this.projectTokens.length; 'projectTokens',
'isLoadingProjects',
'selectedProjects',
'projectSearchResults',
'searchCount',
'searchQuery',
'messages',
]),
isSearchingProjects() {
return this.searchCount > 0;
},
okDisabled() {
return _.isEmpty(this.selectedProjects);
}, },
}, },
created() { created() {
...@@ -43,11 +61,33 @@ export default { ...@@ -43,11 +61,33 @@ export default {
this.fetchProjects(); this.fetchProjects();
}, },
methods: { methods: {
...mapActions(['addProjectsToDashboard', 'fetchProjects', 'setProjectEndpoints']), ...mapActions([
'fetchSearchResults',
'addProjectsToDashboard',
'fetchProjects',
'setProjectEndpoints',
'clearSearchResults',
'toggleSelectedProject',
'setSearchQuery',
]),
addProjects() { addProjects() {
if (!this.addIsDisabled) { this.addProjectsToDashboard();
this.addProjectsToDashboard(); },
} onModalShown() {
this.$refs.projectSelector.focusSearchInput();
},
onModalHidden() {
this.clearSearchResults();
},
onOk() {
this.addProjectsToDashboard();
},
searched(query) {
this.setSearchQuery(query);
this.fetchSearchResults();
},
projectClicked(project) {
this.toggleSelectedProject(project);
}, },
}, },
}; };
...@@ -55,23 +95,40 @@ export default { ...@@ -55,23 +95,40 @@ export default {
<template> <template>
<div class="operations-dashboard"> <div class="operations-dashboard">
<div <gl-modal
class="page-title-holder flex-fill d-flex flex-column flex-md-row align-items-md-end align-items-stretch" :modal-id="$options.modalId"
:title="s__('OperationsDashboard|Add projects')"
:ok-title="s__('OperationsDashboard|Add projects')"
:ok-disabled="okDisabled"
ok-variant="success"
@shown="onModalShown"
@hidden="onModalHidden"
@ok="onOk"
> >
<div class="flex-fill append-right-20"> <project-selector
<h1 class="js-dashboard-title page-title text-nowrap">{{ __('Operations Dashboard') }}</h1> ref="projectSelector"
</div> :project-search-results="projectSearchResults"
<div class="d-flex flex-fill align-items-end append-bottom-default"> :selected-projects="selectedProjects"
<project-search class="flex-grow-1" /> :show-no-results-message="messages.noResults"
<button :show-loading-indicator="isSearchingProjects"
:class="{ disabled: addIsDisabled }" :show-minimum-search-query-message="messages.minimumQuery"
type="button" :show-search-error-message="messages.searchError"
class="js-add-projects-button btn btn-success prepend-left-8" @searched="searched"
@click="addProjects" @projectClicked="projectClicked"
> />
{{ __('Add projects') }} </gl-modal>
</button>
</div> <div class="page-title-holder flex-fill d-flex align-items-center">
<h1 class="js-dashboard-title page-title text-nowrap flex-fill">
{{ s__('OperationsDashboard|Operations Dashboard') }}
</h1>
<gl-button
v-if="projects.length"
v-gl-modal="$options.modalId"
class="js-add-projects-button btn btn-success"
>
{{ s__('OperationsDashboard|Add projects') }}
</gl-button>
</div> </div>
<div class="prepend-top-default"> <div class="prepend-top-default">
<div v-if="projects.length" class="row prepend-top-default dashboard-cards"> <div v-if="projects.length" class="row prepend-top-default dashboard-cards">
...@@ -92,15 +149,18 @@ export default { ...@@ -92,15 +149,18 @@ export default {
s__(`OperationsDashboard|The operations dashboard provides a summary of each project's s__(`OperationsDashboard|The operations dashboard provides a summary of each project's
operational health, including pipeline and alert statuses.`) operational health, including pipeline and alert statuses.`)
}} }}
<a :href="emptyDashboardHelpPath" class="js-documentation-link">
{{ s__('OperationsDashboard|More information') }}
</a>
</span> </span>
</div> </div>
<div class="col-12"> <div class="col-12">
<a <gl-button
:href="emptyDashboardHelpPath" v-gl-modal="$options.modalId"
class="js-documentation-link btn btn-primary prepend-top-default append-bottom-default" class="js-add-projects-button btn btn-success prepend-top-default append-bottom-default"
> >
{{ __('View documentation') }} {{ s__('OperationsDashboard|Add projects') }}
</a> </gl-button>
</div> </div>
</div> </div>
<gl-dashboard-skeleton v-else /> <gl-dashboard-skeleton v-else />
......
...@@ -4,8 +4,11 @@ import axios from '~/lib/utils/axios_utils'; ...@@ -4,8 +4,11 @@ import axios from '~/lib/utils/axios_utils';
import Poll from '~/lib/utils/poll'; import Poll from '~/lib/utils/poll';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { __, s__, n__, sprintf } from '~/locale'; import { __, s__, n__, sprintf } from '~/locale';
import _ from 'underscore';
import * as types from './mutation_types'; import * as types from './mutation_types';
const API_MINIMUM_QUERY_LENGTH = 3;
let eTagPoll; let eTagPoll;
export const clearProjectsEtagPoll = () => { export const clearProjectsEtagPoll = () => {
...@@ -24,32 +27,29 @@ export const forceProjectsRequest = () => { ...@@ -24,32 +27,29 @@ export const forceProjectsRequest = () => {
export const addProjectsToDashboard = ({ state, dispatch }) => { export const addProjectsToDashboard = ({ state, dispatch }) => {
axios axios
.post(state.projectEndpoints.add, { .post(state.projectEndpoints.add, {
project_ids: state.projectTokens.map(project => project.id), project_ids: state.selectedProjects.map(p => p.id),
}) })
.then(response => dispatch('requestAddProjectsToDashboardSuccess', response.data)) .then(response => dispatch('receiveAddProjectsToDashboardSuccess', response.data))
.catch(() => dispatch('requestAddProjectsToDashboardError')); .catch(() => dispatch('receiveAddProjectsToDashboardError'));
};
export const clearInputValue = ({ commit }) => {
commit(types.SET_INPUT_VALUE, '');
}; };
export const clearProjectTokens = ({ commit }) => { export const toggleSelectedProject = ({ commit, state }, project) => {
commit(types.SET_PROJECT_TOKENS, []); if (!_.findWhere(state.selectedProjects, { id: project.id })) {
commit(types.ADD_SELECTED_PROJECT, project);
} else {
commit(types.REMOVE_SELECTED_PROJECT, project);
}
}; };
export const filterProjectTokensById = ({ commit, state }, ids) => { export const clearSearchResults = ({ commit }) => {
const tokens = state.projectTokens.filter(token => ids.includes(token.id)); commit(types.CLEAR_SEARCH_RESULTS);
commit(types.SET_PROJECT_TOKENS, tokens);
}; };
export const requestAddProjectsToDashboardSuccess = ({ dispatch, state }, data) => { export const receiveAddProjectsToDashboardSuccess = ({ dispatch, state }, data) => {
const { added, invalid } = data; const { added, invalid } = data;
dispatch('clearInputValue');
if (invalid.length) { if (invalid.length) {
const projectNames = state.projectTokens.reduce((accumulator, project) => { const projectNames = state.selectedProjects.reduce((accumulator, project) => {
if (invalid.includes(project.id)) { if (invalid.includes(project.id)) {
accumulator.push(project.name); accumulator.push(project.name);
} }
...@@ -73,9 +73,6 @@ export const requestAddProjectsToDashboardSuccess = ({ dispatch, state }, data) ...@@ -73,9 +73,6 @@ export const requestAddProjectsToDashboardSuccess = ({ dispatch, state }, data)
}, },
), ),
); );
dispatch('filterProjectTokensById', invalid);
} else {
dispatch('clearProjectTokens');
} }
if (added.length) { if (added.length) {
...@@ -83,23 +80,14 @@ export const requestAddProjectsToDashboardSuccess = ({ dispatch, state }, data) ...@@ -83,23 +80,14 @@ export const requestAddProjectsToDashboardSuccess = ({ dispatch, state }, data)
} }
}; };
export const requestAddProjectsToDashboardError = ({ state }) => { export const receiveAddProjectsToDashboardError = ({ state }) => {
createFlash( createFlash(
sprintf(__('Something went wrong, unable to add %{project} to dashboard'), { sprintf(__('Something went wrong, unable to add %{project} to dashboard'), {
project: n__('project', 'projects', state.projectTokens.length), project: n__('project', 'projects', state.selectedProjects.length),
}), }),
); );
}; };
export const addProjectToken = ({ commit }, project) => {
commit(types.ADD_PROJECT_TOKEN, project);
commit(types.SET_INPUT_VALUE, '');
};
export const clearProjectSearchResults = ({ commit }) => {
commit(types.SET_PROJECT_SEARCH_RESULTS, []);
};
export const fetchProjects = ({ state, dispatch }) => { export const fetchProjects = ({ state, dispatch }) => {
if (eTagPoll) return; if (eTagPoll) return;
...@@ -132,48 +120,51 @@ export const requestProjects = ({ commit }) => { ...@@ -132,48 +120,51 @@ export const requestProjects = ({ commit }) => {
}; };
export const receiveProjectsSuccess = ({ commit }, data) => { export const receiveProjectsSuccess = ({ commit }, data) => {
commit(types.SET_PROJECTS, data.projects); commit(types.RECEIVE_PROJECTS_SUCCESS, data.projects);
}; };
export const receiveProjectsError = ({ commit }) => { export const receiveProjectsError = ({ commit }) => {
commit(types.SET_PROJECTS, null); commit(types.RECEIVE_PROJECTS_ERROR);
createFlash(__('Something went wrong, unable to get operations projects')); createFlash(__('Something went wrong, unable to get operations projects'));
}; };
export const removeProject = ({ dispatch }, removePath) => { export const removeProject = ({ dispatch }, removePath) => {
axios axios
.delete(removePath) .delete(removePath)
.then(() => dispatch('requestRemoveProjectSuccess')) .then(() => dispatch('receiveRemoveProjectSuccess'))
.catch(() => dispatch('requestRemoveProjectError')); .catch(() => dispatch('receiveRemoveProjectError'));
}; };
export const requestRemoveProjectSuccess = ({ dispatch }) => { export const receiveRemoveProjectSuccess = ({ dispatch }) => dispatch('fetchProjects');
dispatch('forceProjectsRequest');
};
export const requestRemoveProjectError = () => { export const receiveRemoveProjectError = () => {
createFlash(__('Something went wrong, unable to remove project')); createFlash(__('Something went wrong, unable to remove project'));
}; };
export const removeProjectTokenAt = ({ commit }, index) => { export const setSearchQuery = ({ commit }, query) => commit(types.SET_SEARCH_QUERY, query);
commit(types.REMOVE_PROJECT_TOKEN_AT, index);
export const fetchSearchResults = ({ state, dispatch }) => {
dispatch('requestSearchResults');
if (!state.searchQuery) {
dispatch('receiveSearchResultsError');
} else if (state.searchQuery.lengh < API_MINIMUM_QUERY_LENGTH) {
dispatch('receiveSearchResultsError', 'minimumQuery');
} else {
Api.projects(state.searchQuery, {})
.then(results => dispatch('receiveSearchResultsSuccess', results))
.catch(() => dispatch('receiveSearchResultsError'));
}
}; };
export const searchProjects = ({ commit }, query) => { export const requestSearchResults = ({ commit }) => commit(types.REQUEST_SEARCH_RESULTS);
commit(types.INCREMENT_PROJECT_SEARCH_COUNT, 1);
Api.projects(query, {}) export const receiveSearchResultsSuccess = ({ commit }, results) => {
.then(data => data) commit(types.RECEIVE_SEARCH_RESULTS_SUCCESS, results);
.catch(() => [])
.then(results => {
commit(types.SET_PROJECT_SEARCH_RESULTS, results);
commit(types.DECREMENT_PROJECT_SEARCH_COUNT, 1);
})
.catch(() => {});
}; };
export const setInputValue = ({ commit }, value) => { export const receiveSearchResultsError = ({ commit }) => {
commit(types.SET_INPUT_VALUE, value); commit(types.RECEIVE_SEARCH_RESULTS_ERROR);
}; };
export const setProjectEndpoints = ({ commit }, endpoints) => { export const setProjectEndpoints = ({ commit }, endpoints) => {
......
export const ADD_PROJECT_TOKEN = 'ADD_PROJECT_TOKEN';
export const INCREMENT_PROJECT_SEARCH_COUNT = 'INCREMENT_PROJECT_SEARCH_COUNT';
export const DECREMENT_PROJECT_SEARCH_COUNT = 'DECREMENT_PROJECT_SEARCH_COUNT';
export const SET_INPUT_VALUE = 'SET_INPUT_VALUE';
export const SET_PROJECT_ENDPOINT_LIST = 'SET_PROJECT_ENDPOINT_LIST'; export const SET_PROJECT_ENDPOINT_LIST = 'SET_PROJECT_ENDPOINT_LIST';
export const SET_PROJECT_ENDPOINT_ADD = 'SET_PROJECT_ENDPOINT_ADD'; export const SET_PROJECT_ENDPOINT_ADD = 'SET_PROJECT_ENDPOINT_ADD';
export const SET_PROJECT_SEARCH_RESULTS = 'SET_PROJECT_SEARCH_RESULTS';
export const SET_PROJECTS = 'SET_PROJECTS'; export const SET_PROJECTS = 'SET_PROJECTS';
export const SET_PROJECT_TOKENS = 'SET_PROJECT_TOKENS'; export const SET_SEARCH_QUERY = 'SET_SEARCH_QUERY';
export const REMOVE_PROJECT_TOKEN_AT = 'REMOVE_PROJECT_TOKEN_AT'; export const REMOVE_PROJECT_TOKEN_AT = 'REMOVE_PROJECT_TOKEN_AT';
export const SET_MESSAGE_MINIMUM_QUERY = 'SET_MESSAGE_MINIMUM_QUERY';
export const ADD_SELECTED_PROJECT = 'ADD_SELECTED_PROJECT';
export const REMOVE_SELECTED_PROJECT = 'REMOVE_SELECTED_PROJECT';
export const REQUEST_PROJECTS = 'REQUEST_PROJECTS'; export const REQUEST_PROJECTS = 'REQUEST_PROJECTS';
export const RECEIVE_PROJECTS_SUCCESS = 'RECEIVE_PROJECTS_SUCCESS';
export const RECEIVE_PROJECTS_ERROR = 'RECEIVE_PROJECTS_ERROR';
export const CLEAR_SEARCH_RESULTS = 'CLEAR_SEARCH_RESULTS';
export const REQUEST_SEARCH_RESULTS = 'REQUEST_SEARCH_RESULTS';
export const RECEIVE_SEARCH_RESULTS_SUCCESS = 'RECEIVE_SEARCH_RESULTS_SUCCESS';
export const RECEIVE_SEARCH_RESULTS_ERROR = 'RECEIVE_SEARCH_RESULTS_ERROR';
import Vue from 'vue';
import * as types from './mutation_types'; import * as types from './mutation_types';
export default { export default {
[types.ADD_PROJECT_TOKEN](state, project) {
const projectsWithMatchingId = state.projectTokens.filter(token => token.id === project.id);
if (!projectsWithMatchingId.length) {
state.projectTokens.push(project);
}
},
[types.DECREMENT_PROJECT_SEARCH_COUNT](state, value) {
state.searchCount -= value;
},
[types.INCREMENT_PROJECT_SEARCH_COUNT](state, value) {
state.searchCount += value;
},
[types.SET_INPUT_VALUE](state, value) {
state.inputValue = value;
},
[types.SET_PROJECT_ENDPOINT_LIST](state, url) { [types.SET_PROJECT_ENDPOINT_LIST](state, url) {
state.projectEndpoints.list = url; state.projectEndpoints.list = url;
}, },
[types.SET_PROJECT_ENDPOINT_ADD](state, url) { [types.SET_PROJECT_ENDPOINT_ADD](state, url) {
state.projectEndpoints.add = url; state.projectEndpoints.add = url;
}, },
[types.SET_PROJECT_SEARCH_RESULTS](state, results) {
state.projectSearchResults = results;
},
[types.SET_PROJECTS](state, projects) { [types.SET_PROJECTS](state, projects) {
state.projects = projects || []; state.projects = projects || [];
state.isLoadingProjects = false; state.isLoadingProjects = false;
}, },
[types.SET_PROJECT_TOKENS](state, tokens) { [types.SET_SEARCH_QUERY](state, query) {
state.projectTokens = tokens; state.searchQuery = query;
}, },
[types.REMOVE_PROJECT_TOKEN_AT](state, index) {
state.projectTokens.splice(index, 1); [types.SET_MESSAGE_MINIMUM_QUERY](state, bool) {
state.messages.minimumQuery = bool;
},
[types.ADD_SELECTED_PROJECT](state, project) {
if (!state.selectedProjects.some(p => p.id === project.id)) {
state.selectedProjects.push(project);
}
},
[types.REMOVE_SELECTED_PROJECT](state, project) {
state.selectedProjects = state.selectedProjects.filter(p => p.id !== project.id);
}, },
[types.REQUEST_PROJECTS](state) { [types.REQUEST_PROJECTS](state) {
state.isLoadingProjects = true; state.isLoadingProjects = true;
}, },
[types.RECEIVE_PROJECTS_SUCCESS](state, projects) {
state.projects = projects;
state.isLoadingProjects = false;
},
[types.RECEIVE_PROJECTS_ERROR](state) {
state.projects = null;
state.isLoadingProjects = false;
},
[types.CLEAR_SEARCH_RESULTS](state) {
state.projectSearchResults = [];
state.selectedProjects = [];
},
[types.REQUEST_SEARCH_RESULTS](state) {
// Flipping this property separately to allows the UI
// to hide the "minimum query" message
// before the seach results arrive from the API
Vue.set(state.messages, 'minimumQuery', false);
state.searchCount += 1;
},
[types.RECEIVE_SEARCH_RESULTS_SUCCESS](state, results) {
state.projectSearchResults = results;
Vue.set(state.messages, 'noResults', state.projectSearchResults.length === 0);
Vue.set(state.messages, 'searchError', false);
Vue.set(state.messages, 'minimumQuery', false);
state.searchCount -= 1;
},
[types.RECEIVE_SEARCH_RESULTS_ERROR](state, message) {
state.projectSearchResults = [];
Vue.set(state.messages, 'noResults', false);
Vue.set(state.messages, 'searchError', true);
Vue.set(state.messages, 'minimumQuery', message === 'minimumQuery');
state.searchCount -= 1;
},
}; };
...@@ -5,8 +5,14 @@ export default () => ({ ...@@ -5,8 +5,14 @@ export default () => ({
list: null, list: null,
add: null, add: null,
}, },
searchQuery: '',
projects: [], projects: [],
projectTokens: [],
projectSearchResults: [], projectSearchResults: [],
selectedProjects: [],
messages: {
noResults: false,
searchError: false,
minimumQuery: false,
},
searchCount: 0, searchCount: 0,
}); });
<script>
import { mapState, mapActions } from 'vuex';
import Icon from '~/vue_shared/components/icon.vue';
import inputFocus from '../mixins';
export default {
components: {
Icon,
},
mixins: [inputFocus],
computed: {
...mapState(['inputValue', 'projectTokens']),
localInputValue: {
get() {
return this.inputValue;
},
set(newValue) {
this.setInputValue(newValue);
},
},
},
methods: {
...mapActions(['setInputValue', 'removeProjectTokenAt']),
focusInput() {
this.$refs.input.focus();
},
},
};
</script>
<template>
<div
:class="{ focus: isInputFocused }"
class="form-control tokenized-input-wrapper d-flex flex-wrap align-items-center"
@click="focusInput"
>
<div v-for="(token, index) in projectTokens" :key="token.id" class="d-flex" @click.stop>
<div class="js-input-token input-token text-secondary py-0 pl-2 pr-1 rounded-left">
{{ token.name_with_namespace }}
</div>
<div
class="js-token-remove tokenized-input-token-remove d-flex align-items-center text-secondary py-0 px-1 rounded-right"
@click="removeProjectTokenAt(index)"
>
<icon name="close" />
</div>
</div>
<div class="d-flex align-items-center flex-grow-1">
<input
ref="input"
v-model="localInputValue"
:placeholder="__('Search your projects')"
class="tokenized-input flex-grow-1"
type="text"
@focus="onFocus"
@blur="onBlur"
/>
<icon name="search" class="text-secondary" />
</div>
</div>
</template>
<script>
import { mapState, mapActions } from 'vuex';
import _ from 'underscore';
import { __, sprintf } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
import ProjectAvatar from '~/vue_shared/components/project_avatar/default.vue';
import { GlLoadingIcon } from '@gitlab/ui';
import TokenizedInput from './input.vue';
import inputFocus from '../mixins';
const inputSearchDelay = 300;
export default {
components: {
Icon,
ProjectAvatar,
TokenizedInput,
GlLoadingIcon,
},
mixins: [inputFocus],
data() {
return {
hasSearchedInput: false,
};
},
computed: {
...mapState(['inputValue', 'projectTokens', 'projectSearchResults', 'searchCount']),
isSearchingProjects() {
return this.searchCount > 0;
},
searchDescription() {
return sprintf(__('"%{query}" in projects'), { query: this.inputValue });
},
shouldShowSearch() {
return this.inputValue.length && this.isInputFocused;
},
foundNoResults() {
return !this.projectSearchResults.length && this.hasSearchedInput;
},
},
watch: {
inputValue() {
this.queryInputInProjects();
},
},
methods: {
...mapActions(['addProjectToken', 'searchProjects', 'clearProjectSearchResults']),
queryInputInProjects: _.debounce(function search() {
this.searchProjects(this.inputValue);
this.hasSearchedInput = true;
}, inputSearchDelay),
},
};
</script>
<template>
<div :class="{ show: shouldShowSearch }" class="dropdown">
<tokenized-input @focus="onFocus" @blur="onBlur" />
<div class="js-search-results dropdown-menu w-100 mw-100" @mousedown.prevent>
<div class="py-2 px-4 text-tertiary"><icon name="search" /> {{ searchDescription }}</div>
<div class="dropdown-divider"></div>
<gl-loading-icon v-if="isSearchingProjects" :size="2" class="py-2 px-4" />
<div v-else-if="foundNoResults" class="py-2 px-4 text-tertiary">
{{ __('Sorry, no projects matched your search') }}
</div>
<button
v-for="project in projectSearchResults"
:key="project.id"
type="button"
class="js-search-result dropdown-item btn-link d-flex align-items-center cgray py-2 px-4"
@mousedown="addProjectToken(project)"
>
<project-avatar :project="project" :size="20" class="flex-shrink-0 mr-3" />
<div class="flex-grow-1">
<div class="js-name-with-namespace bold ws-initial">
{{ project.name_with_namespace }}
</div>
</div>
</button>
</div>
</div>
</template>
---
title: Move project search bar into modal dialog on Operations Dashboard page
merge_request: 9260
author:
type: changed
This source diff could not be displayed because it is too large. You can view the blob instead.
import Vue from 'vue'; import Vue from 'vue';
import store from 'ee/operations/store/index'; import store from 'ee/operations/store/index';
import Dashboard from 'ee/operations/components/dashboard/dashboard.vue'; import Dashboard from 'ee/operations/components/dashboard/dashboard.vue';
import ProjectSearch from 'ee/vue_shared/dashboards/components/project_search.vue';
import DashboardProject from 'ee/operations/components/dashboard/project.vue'; import DashboardProject from 'ee/operations/components/dashboard/project.vue';
import { trimText } from 'spec/helpers/vue_component_helper';
import { getChildInstances, clearState } from '../../helpers'; import { getChildInstances, clearState } from '../../helpers';
import { mockProjectData, mockText } from '../../mock_data'; import { mockProjectData, mockText } from '../../mock_data';
describe('dashboard component', () => { describe('dashboard component', () => {
const DashboardComponent = Vue.extend(Dashboard); const DashboardComponent = Vue.extend(Dashboard);
const ProjectSearchComponent = Vue.extend(ProjectSearch);
const DashboardProjectComponent = Vue.extend(DashboardProject); const DashboardProjectComponent = Vue.extend(DashboardProject);
const projectTokens = mockProjectData(1);
const mount = () => const mount = () =>
new DashboardComponent({ new DashboardComponent({
store, store,
...@@ -52,29 +50,14 @@ describe('dashboard component', () => { ...@@ -52,29 +50,14 @@ describe('dashboard component', () => {
expect(button.innerText.trim()).toBe(mockText.ADD_PROJECTS); expect(button.innerText.trim()).toBe(mockText.ADD_PROJECTS);
}); });
it('calls action to add projects on click if projectTokens have been added', () => { it('renders the projects modal', () => {
const spy = spyOn(vm, 'addProjectsToDashboard').and.stub();
vm.$store.state.projectTokens = projectTokens;
button.click(); button.click();
expect(spy).toHaveBeenCalled(); expect(vm.$el.querySelector('.add-projects-modal')).toBeDefined();
});
it('does not call action to add projects on click when projectTokens is empty', () => {
const spy = spyOn(vm, 'addProjectsToDashboard').and.stub();
button.click();
expect(spy).not.toHaveBeenCalled();
}); });
}); });
describe('wrapped components', () => { describe('wrapped components', () => {
describe('project search component', () => {
it('renders project search component', () => {
expect(getChildInstances(vm, ProjectSearchComponent).length).toBe(1);
});
});
describe('dashboard project component', () => { describe('dashboard project component', () => {
const projectCount = 1; const projectCount = 1;
const projects = mockProjectData(projectCount); const projects = mockProjectData(projectCount);
...@@ -113,7 +96,7 @@ describe('dashboard component', () => { ...@@ -113,7 +96,7 @@ describe('dashboard component', () => {
}); });
it('renders sub-title', () => { it('renders sub-title', () => {
expect(vm.$el.querySelector('.js-sub-title').innerText.trim()).toBe( expect(trimText(vm.$el.querySelector('.js-sub-title').innerText)).toBe(
mockText.EMPTY_SUBTITLE, mockText.EMPTY_SUBTITLE,
); );
}); });
...@@ -121,7 +104,7 @@ describe('dashboard component', () => { ...@@ -121,7 +104,7 @@ describe('dashboard component', () => {
it('renders link to documentation', () => { it('renders link to documentation', () => {
const link = vm.$el.querySelector('.js-documentation-link'); const link = vm.$el.querySelector('.js-documentation-link');
expect(link.innerText.trim()).toBe('View documentation'); expect(link.innerText.trim()).toBe('More information');
}); });
it('links to documentation', () => { it('links to documentation', () => {
......
import Vue from 'vue';
import store from 'ee/operations/store/index';
import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import ProjectSearch from 'ee/vue_shared/dashboards/components/project_search.vue';
import TokenizedInput from 'ee/vue_shared/dashboards/components/input.vue';
import { mockText, mockProjectData } from '../../mock_data';
import { getChildInstances, mouseEvent, clearState } from '../../helpers';
describe('project search component', () => {
const ProjectSearchComponent = Vue.extend(ProjectSearch);
const TokenizedInputComponent = Vue.extend(TokenizedInput);
const mockProjects = mockProjectData(1);
const [mockOneProject] = mockProjects;
const mockInputValue = 'mock-inputValue';
const mount = () =>
mountComponentWithStore(ProjectSearchComponent, {
store,
});
let vm;
beforeEach(() => {
vm = mount();
});
afterEach(() => {
vm.$destroy();
clearState(store);
});
describe('dropdown menu', () => {
it('renders dropdown menu when input gains focus', done => {
vm.$store.dispatch('setInputValue', mockInputValue);
vm.isInputFocused = true;
vm.$nextTick(() => {
expect(vm.$el.classList.contains('show')).toBe(true);
expect(vm.$el.querySelector('.js-search-results')).not.toBeNull();
done();
});
});
it('does not render when input is not focused', () => {
vm.$store.dispatch('setInputValue', mockInputValue);
expect(vm.$el.classList.contains('show')).toBe(false);
});
it('does not render when input value is empty', () => {
vm.isInputFocused = true;
expect(vm.$el.classList.contains('show')).toBe(false);
});
it('renders search icon', () => {
expect(vm.$el.querySelector('.ic-search')).not.toBe(null);
});
it('renders search description', () => {
store.state.inputValue = mockInputValue;
vm = mountComponentWithStore(ProjectSearchComponent, {
store,
});
expect(vm.$el.querySelector('.js-search-results').innerText.trim()).toBe(
`"${mockInputValue}" ${mockText.SEARCH_DESCRIPTION_SUFFIX}`,
);
});
it('renders no search results after searching input with no matches', done => {
vm.hasSearchedInput = true;
vm.$nextTick(() => {
expect(
vm.$el
.querySelector('.js-search-results')
.innerText.trim()
.slice(-mockText.NO_SEARCH_RESULTS.length),
).toBe(mockText.NO_SEARCH_RESULTS);
done();
});
});
it('renders loading icon when searching', () => {
store.state.searchCount = 1;
vm = mount();
expect(vm.$el).toContainElement('.loading-container');
});
it('renders search results', () => {
store.state.projectSearchResults = mockProjects;
vm = mount();
expect(vm.$el.getElementsByClassName('js-search-result').length).toBe(mockProjects.length);
});
});
it('searches projects when input value changes', done => {
const spy = spyOn(vm, 'queryInputInProjects');
vm.$store.dispatch('setInputValue', mockInputValue);
vm.$nextTick(() => {
expect(spy).toHaveBeenCalled();
done();
});
});
describe('project search item', () => {
let item;
beforeEach(() => {
store.state.projectSearchResults = mockProjects;
vm = mount();
item = vm.$el.querySelector('.js-search-result');
});
it('renders project name with namespace', () => {
expect(item.querySelector('.js-name-with-namespace').innerText.trim()).toBe(
mockOneProject.name_with_namespace,
);
});
it('calls action to add project token on mousedown', done => {
const spy = spyOn(vm.$store, 'dispatch');
mouseEvent(item, 'mousedown');
vm.$nextTick(() => {
expect(spy).toHaveBeenCalledWith('addProjectToken', mockOneProject);
done();
});
});
});
describe('wrapped components', () => {
describe('tokenized input', () => {
const getInput = parent => getChildInstances(parent, TokenizedInputComponent)[0];
it('renders', () => {
expect(getChildInstances(vm, TokenizedInputComponent).length).toBe(1);
});
it('handles focus', () => {
getInput(vm).$emit('focus');
expect(vm.isInputFocused).toBe(true);
});
it('handles blur', () => {
getInput(vm).$emit('blur');
expect(vm.isInputFocused).toBe(false);
});
});
describe('project avatar', () => {
let avatars;
beforeEach(() => {
store.state.projectSearchResults = mockProjects;
vm = mount();
avatars = vm.$el.querySelectorAll('.project-avatar');
});
it('renders project avatar component', () => {
expect(avatars.length).toBe(1);
});
it('binds project to project', () => {
const [avatar] = avatars;
const identicon = avatar.querySelector('.identicon');
const [identiconLetter] = mockOneProject.name;
expect(identicon.textContent.trim()).toEqual(identiconLetter.toUpperCase());
});
});
});
});
import Vue from 'vue';
import store from 'ee/operations/store/index';
import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import TokenizedInput from 'ee/vue_shared/dashboards/components/input.vue';
import { clearState } from '../../helpers';
import { mockProjectData } from '../../mock_data';
describe('tokenized input component', () => {
const TokenizedInputComponent = Vue.extend(TokenizedInput);
const mockProjects = mockProjectData(1);
const [mockOneProject] = mockProjects;
const mockInputValue = 'mock-inputValue';
let vm;
const getInput = () => vm.$refs.input;
beforeEach(() => {
store.state.projectTokens = mockProjects;
vm = mountComponentWithStore(TokenizedInputComponent, { store });
});
afterEach(() => {
vm.$destroy();
clearState(store);
});
it('focuses input on click', () => {
const spy = spyOn(getInput(), 'focus');
vm.$el.click();
expect(spy).toHaveBeenCalled();
});
it('renders input token', () => {
expect(vm.$el.querySelector('.js-input-token').innerText.trim()).toBe(
mockOneProject.name_with_namespace,
);
});
it('removes input tokens on click', () => {
const spy = spyOn(vm.$store, 'dispatch');
vm.$el.querySelector('.js-token-remove').click();
expect(spy).toHaveBeenCalledWith('removeProjectTokenAt', mockOneProject.id);
});
describe('input', () => {
it('updates input value when local value changes', done => {
vm.localInputValue = mockInputValue;
vm.$nextTick(() => {
expect(getInput().value).toBe(mockInputValue);
done();
});
});
it('handles focus', () => {
const spy = spyOn(vm, '$emit');
vm.onFocus();
expect(spy).toHaveBeenCalledWith('focus');
});
it('handles blur', () => {
const spy = spyOn(vm, '$emit');
vm.onBlur();
expect(spy).toHaveBeenCalledWith('blur');
});
});
describe('wrapped components', () => {
describe('icon', () => {
it('should render close for input tokens', () => {
expect(vm.$el.querySelectorAll('.ic-close').length).toBe(mockProjects.length);
});
it('should render search', () => {
expect(vm.$el.querySelector('.ic-search')).not.toBe(null);
});
});
});
});
...@@ -7,7 +7,7 @@ export const mockText = { ...@@ -7,7 +7,7 @@ export const mockText = {
DASHBOARD_TITLE: 'Operations Dashboard', DASHBOARD_TITLE: 'Operations Dashboard',
EMPTY_TITLE: 'Add a project to the dashboard', EMPTY_TITLE: 'Add a project to the dashboard',
EMPTY_SUBTITLE: EMPTY_SUBTITLE:
"The operations dashboard provides a summary of each project's operational health, including pipeline and alert statuses.", "The operations dashboard provides a summary of each project's operational health, including pipeline and alert statuses. More information",
EMPTY_SVG_SOURCE: '/assets/illustrations/operations-dashboard_empty.svg', EMPTY_SVG_SOURCE: '/assets/illustrations/operations-dashboard_empty.svg',
NO_SEARCH_RESULTS: 'Sorry, no projects matched your search', NO_SEARCH_RESULTS: 'Sorry, no projects matched your search',
RECEIVE_PROJECTS_ERROR: 'Something went wrong, unable to get operations projects', RECEIVE_PROJECTS_ERROR: 'Something went wrong, unable to get operations projects',
......
...@@ -25,9 +25,9 @@ describe('actions', () => { ...@@ -25,9 +25,9 @@ describe('actions', () => {
}); });
describe('addProjectsToDashboard', () => { describe('addProjectsToDashboard', () => {
it('posts project token ids to project add endpoint', done => { it('posts selected project ids to project add endpoint', done => {
store.state.projectEndpoints.add = mockAddEndpoint; store.state.projectEndpoints.add = mockAddEndpoint;
store.state.projectTokens = mockProjects; store.state.selectedProjects = mockProjects;
mockAxios.onPost(mockAddEndpoint).replyOnce(200, mockResponse); mockAxios.onPost(mockAddEndpoint).replyOnce(200, mockResponse);
...@@ -38,7 +38,7 @@ describe('actions', () => { ...@@ -38,7 +38,7 @@ describe('actions', () => {
[], [],
[ [
{ {
type: 'requestAddProjectsToDashboardSuccess', type: 'receiveAddProjectsToDashboardSuccess',
payload: mockResponse, payload: mockResponse,
}, },
], ],
...@@ -54,61 +54,40 @@ describe('actions', () => { ...@@ -54,61 +54,40 @@ describe('actions', () => {
null, null,
store.state, store.state,
[], [],
[{ type: 'requestAddProjectsToDashboardError' }], [{ type: 'receiveAddProjectsToDashboardError' }],
done, done,
); );
}); });
}); });
describe('clearInputValue', () => { describe('toggleSelectedProject', () => {
it('sets inputValue to empty string', done => { it(`adds a project to selectedProjects if it doesn't already exist in the list`, done => {
testAction( testAction(
actions.clearInputValue, actions.toggleSelectedProject,
null, mockOneProject,
store.state,
[
{
type: types.SET_INPUT_VALUE,
payload: '',
},
],
[],
done,
);
});
});
describe('clearProjectTokens', () => {
it('sets project tokens to an empty array', done => {
testAction(
actions.clearProjectTokens,
null,
store.state, store.state,
[ [
{ {
type: types.SET_PROJECT_TOKENS, type: types.ADD_SELECTED_PROJECT,
payload: [], payload: mockOneProject,
}, },
], ],
[], [],
done, done,
); );
}); });
});
describe('filterProjectTokensById', () => { it(`removes a project from selectedProjects if it already exist in the list`, done => {
it('removes all project tokens except those with specified ids', done => { store.state.selectedProjects = mockProjects;
store.state.projectTokens = mockProjects;
const ids = mockProjects.map(project => project.id);
testAction( testAction(
actions.filterProjectTokensById, actions.toggleSelectedProject,
ids, mockOneProject,
store.state, store.state,
[ [
{ {
type: types.SET_PROJECT_TOKENS, type: types.REMOVE_SELECTED_PROJECT,
payload: mockProjects, payload: mockOneProject,
}, },
], ],
[], [],
...@@ -117,10 +96,10 @@ describe('actions', () => { ...@@ -117,10 +96,10 @@ describe('actions', () => {
}); });
}); });
describe('requestAddProjectsToDashboardSuccess', () => { describe('receiveAddProjectsToDashboardSuccess', () => {
it('fetches projects when new projects are added to the dashboard', done => { it('fetches projects when new projects are added to the dashboard', done => {
testAction( testAction(
actions.requestAddProjectsToDashboardSuccess, actions.receiveAddProjectsToDashboardSuccess,
{ {
added: [1], added: [1],
invalid: [], invalid: [],
...@@ -129,12 +108,6 @@ describe('actions', () => { ...@@ -129,12 +108,6 @@ describe('actions', () => {
store.state, store.state,
[], [],
[ [
{
type: 'clearInputValue',
},
{
type: 'clearProjectTokens',
},
{ {
type: 'fetchProjects', type: 'fetchProjects',
}, },
...@@ -143,63 +116,18 @@ describe('actions', () => { ...@@ -143,63 +116,18 @@ describe('actions', () => {
); );
}); });
it('removes projectTokens when user tries to add duplicates to dashboard', done => {
testAction(
actions.requestAddProjectsToDashboardSuccess,
{
added: [],
invalid: [],
duplicate: [1],
},
store.state,
[],
[
{
type: 'clearInputValue',
},
{
type: 'clearProjectTokens',
},
],
done,
);
});
it('does not remove projectTokens when user adds invalid projects to dashbaord', done => {
testAction(
actions.requestAddProjectsToDashboardSuccess,
{
added: [],
invalid: [1],
duplicate: [],
},
store.state,
[],
[
{
type: 'clearInputValue',
},
{
type: 'filterProjectTokensById',
payload: [1],
},
],
done,
);
});
const errorMessage = const errorMessage =
'The Operations Dashboard is available for public projects, and private projects in groups with a Gold plan.'; 'The Operations Dashboard is available for public projects, and private projects in groups with a Gold plan.';
const addTokens = count => { const selectProjects = count => {
for (let i = 0; i < count; i += 1) { for (let i = 0; i < count; i += 1) {
store.dispatch('addProjectToken', { store.dispatch('toggleSelectedProject', {
id: i, id: i,
name: 'mock-name', name: 'mock-name',
}); });
} }
}; };
const addInvalidProjects = invalid => const addInvalidProjects = invalid =>
store.dispatch('requestAddProjectsToDashboardSuccess', { store.dispatch('receiveAddProjectsToDashboardSuccess', {
added: [], added: [],
invalid, invalid,
duplicate: [], duplicate: [],
...@@ -207,7 +135,7 @@ describe('actions', () => { ...@@ -207,7 +135,7 @@ describe('actions', () => {
it('displays an error when user tries to add one invalid project to dashboard', () => { it('displays an error when user tries to add one invalid project to dashboard', () => {
const spy = spyOnDependency(defaultActions, 'createFlash'); const spy = spyOnDependency(defaultActions, 'createFlash');
addTokens(1); selectProjects(1);
addInvalidProjects([0]); addInvalidProjects([0]);
expect(spy).toHaveBeenCalledWith(`Unable to add mock-name. ${errorMessage}`); expect(spy).toHaveBeenCalledWith(`Unable to add mock-name. ${errorMessage}`);
...@@ -215,7 +143,7 @@ describe('actions', () => { ...@@ -215,7 +143,7 @@ describe('actions', () => {
it('displays an error when user tries to add two invalid projects to dashboard', () => { it('displays an error when user tries to add two invalid projects to dashboard', () => {
const spy = spyOnDependency(defaultActions, 'createFlash'); const spy = spyOnDependency(defaultActions, 'createFlash');
addTokens(2); selectProjects(2);
addInvalidProjects([0, 1]); addInvalidProjects([0, 1]);
expect(spy).toHaveBeenCalledWith(`Unable to add mock-name and mock-name. ${errorMessage}`); expect(spy).toHaveBeenCalledWith(`Unable to add mock-name and mock-name. ${errorMessage}`);
...@@ -223,7 +151,7 @@ describe('actions', () => { ...@@ -223,7 +151,7 @@ describe('actions', () => {
it('displays an error when user tries to add more than two invalid projects to dashboard', () => { it('displays an error when user tries to add more than two invalid projects to dashboard', () => {
const spy = spyOnDependency(defaultActions, 'createFlash'); const spy = spyOnDependency(defaultActions, 'createFlash');
addTokens(3); selectProjects(3);
addInvalidProjects([0, 1, 2]); addInvalidProjects([0, 1, 2]);
expect(spy).toHaveBeenCalledWith( expect(spy).toHaveBeenCalledWith(
...@@ -232,49 +160,26 @@ describe('actions', () => { ...@@ -232,49 +160,26 @@ describe('actions', () => {
}); });
}); });
describe('requestAddProjectsToDashboardError', () => { describe('receiveAddProjectsToDashboardError', () => {
it('shows error message', () => { it('shows error message', () => {
const spy = spyOnDependency(defaultActions, 'createFlash'); const spy = spyOnDependency(defaultActions, 'createFlash');
store.dispatch('requestAddProjectsToDashboardError'); store.dispatch('receiveAddProjectsToDashboardError');
expect(spy).toHaveBeenCalledWith(mockText.ADD_PROJECTS_ERROR); expect(spy).toHaveBeenCalledWith(mockText.ADD_PROJECTS_ERROR);
}); });
}); });
describe('addProjectToken', () => { describe('clearSearchResults', () => {
it('adds project token to state', done => {
testAction(
actions.addProjectToken,
mockOneProject,
null,
[
{
type: types.ADD_PROJECT_TOKEN,
payload: mockOneProject,
},
{
type: types.SET_INPUT_VALUE,
payload: '',
},
],
[],
done,
);
});
});
describe('clearProjectSearchResults', () => {
it('clears all project search results', done => { it('clears all project search results', done => {
store.state.projectSearchResults = mockProjects; store.state.projectSearchResults = mockProjects;
testAction( testAction(
actions.clearProjectSearchResults, actions.clearSearchResults,
null, null,
store.state, store.state,
[ [
{ {
type: types.SET_PROJECT_SEARCH_RESULTS, type: types.CLEAR_SEARCH_RESULTS,
payload: [],
}, },
], ],
[], [],
...@@ -338,7 +243,7 @@ describe('actions', () => { ...@@ -338,7 +243,7 @@ describe('actions', () => {
store.state, store.state,
[ [
{ {
type: types.SET_PROJECTS, type: types.RECEIVE_PROJECTS_SUCCESS,
payload: mockProjects, payload: mockProjects,
}, },
], ],
...@@ -359,8 +264,7 @@ describe('actions', () => { ...@@ -359,8 +264,7 @@ describe('actions', () => {
store.state, store.state,
[ [
{ {
type: types.SET_PROJECTS, type: types.RECEIVE_PROJECTS_ERROR,
payload: null,
}, },
], ],
[], [],
...@@ -382,7 +286,7 @@ describe('actions', () => { ...@@ -382,7 +286,7 @@ describe('actions', () => {
mockRemovePath, mockRemovePath,
null, null,
[], [],
[{ type: 'requestRemoveProjectSuccess' }], [{ type: 'receiveRemoveProjectSuccess' }],
done, done,
); );
}); });
...@@ -395,104 +299,108 @@ describe('actions', () => { ...@@ -395,104 +299,108 @@ describe('actions', () => {
mockRemovePath, mockRemovePath,
null, null,
[], [],
[{ type: 'requestRemoveProjectError' }], [{ type: 'receiveRemoveProjectError' }],
done, done,
); );
}); });
}); });
describe('requestRemoveProjectSuccess', () => { describe('receiveRemoveProjectSuccess', () => {
it('fetches operations dashboard projects', done => { it('fetches operations dashboard projects', done => {
testAction( testAction(
actions.requestRemoveProjectSuccess, actions.receiveRemoveProjectSuccess,
null, null,
null, null,
[], [],
[{ type: 'forceProjectsRequest' }], [{ type: 'fetchProjects' }],
done, done,
); );
}); });
}); });
describe('requestRemoveProjectError', () => { describe('receiveRemoveProjectError', () => {
it('displays project removal error', done => { it('displays project removal error', done => {
const spy = spyOnDependency(defaultActions, 'createFlash'); const spy = spyOnDependency(defaultActions, 'createFlash');
testAction(actions.requestRemoveProjectError, null, null, [], [], done); testAction(actions.receiveRemoveProjectError, null, null, [], [], done);
expect(spy).toHaveBeenCalledWith(mockText.REMOVE_PROJECT_ERROR); expect(spy).toHaveBeenCalledWith(mockText.REMOVE_PROJECT_ERROR);
}); });
}); });
describe('removeProjectToken', () => { describe('fetchSearchResults', () => {
it('removes project token', done => { it('dispatches an error if the search query was empty', done => {
store.state.projectTokens = mockProjects; store.state.searchQuery = '';
const [{ id }] = store.state.projectTokens;
testAction( testAction(
actions.removeProjectTokenAt, actions.fetchSearchResults,
id, null,
store.state, store.state,
[],
[ [
{ {
type: types.REMOVE_PROJECT_TOKEN_AT, type: 'requestSearchResults',
payload: 0, },
{
type: 'receiveSearchResultsError',
}, },
], ],
[],
done, done,
); );
}); });
});
describe('searchProjects', () => {
const mockQuery = 'mock-query';
it('sets project search results', done => { it(`dispatches an error if the search query wasn't long enough`, done => {
mockAxios.onAny().replyOnce(200, mockProjects); store.state.searchQuery = 'a';
testAction( testAction(
actions.searchProjects, actions.fetchSearchResults,
mockQuery, null,
store.state, store.state,
[],
[ [
{ {
type: types.INCREMENT_PROJECT_SEARCH_COUNT, type: 'requestSearchResults',
payload: 1,
}, },
{ {
type: types.SET_PROJECT_SEARCH_RESULTS, type: 'receiveSearchResultsError',
payload: mockProjects,
},
{
type: types.DECREMENT_PROJECT_SEARCH_COUNT,
payload: 1,
}, },
], ],
[],
done, done,
); );
}); });
it('clears project search results on error', done => { it(`dispatches the correct actions when the query is valid`, done => {
mockAxios.onAny().replyOnce(500); mockAxios.onAny().replyOnce(200, mockProjects);
store.state.searchQuery = 'mock-query';
testAction( testAction(
actions.searchProjects, actions.fetchSearchResults,
mockQuery, null,
store.state, store.state,
[],
[ [
{ {
type: types.INCREMENT_PROJECT_SEARCH_COUNT, type: 'requestSearchResults',
payload: 1,
}, },
{ {
type: types.SET_PROJECT_SEARCH_RESULTS, type: 'receiveSearchResultsSuccess',
payload: [], payload: mockProjects,
}, },
],
done,
);
});
});
describe('requestSearchResults', () => {
it(`commits the REQUEST_SEARCH_RESULTS mutation`, done => {
testAction(
actions.requestSearchResults,
null,
store.state,
[
{ {
type: types.DECREMENT_PROJECT_SEARCH_COUNT, type: types.REQUEST_SEARCH_RESULTS,
payload: 1,
}, },
], ],
[], [],
...@@ -501,18 +409,33 @@ describe('actions', () => { ...@@ -501,18 +409,33 @@ describe('actions', () => {
}); });
}); });
describe('setInputValue', () => { describe('receiveSearchResultsSuccess', () => {
it('sets input value', done => { it('commits the RECEIVE_SEARCH_RESULTS_SUCCESS mutation', done => {
const mockValue = 'mock-value'; testAction(
actions.receiveSearchResultsSuccess,
mockProjects,
store.state,
[
{
type: types.RECEIVE_SEARCH_RESULTS_SUCCESS,
payload: mockProjects,
},
],
[],
done,
);
});
});
describe('receiveSearchResultsError', () => {
it('commits the RECEIVE_SEARCH_RESULTS_ERROR mutation', done => {
testAction( testAction(
actions.setInputValue, actions.receiveSearchResultsError,
mockValue, ['error'],
null, store.state,
[ [
{ {
type: types.SET_INPUT_VALUE, type: types.RECEIVE_SEARCH_RESULTS_ERROR,
payload: mockValue,
}, },
], ],
[], [],
......
...@@ -4,84 +4,183 @@ import * as types from 'ee/operations/store/mutation_types'; ...@@ -4,84 +4,183 @@ import * as types from 'ee/operations/store/mutation_types';
import { mockProjectData } from '../mock_data'; import { mockProjectData } from '../mock_data';
describe('mutations', () => { describe('mutations', () => {
const projects = mockProjectData(1); const projects = mockProjectData(3);
const [oneProject] = projects;
const mockEndpoint = 'https://mock-endpoint'; const mockEndpoint = 'https://mock-endpoint';
const mockSearches = new Array(5).fill(null);
let localState; let localState;
beforeEach(() => { beforeEach(() => {
localState = state(); localState = state();
}); });
describe('ADD_PROJECT_TOKEN', () => { describe('SET_PROJECT_ENDPOINT_LIST', () => {
it('adds project token to projectTokens', () => { it('sets project list endpoint', () => {
mutations[types.ADD_PROJECT_TOKEN](localState, oneProject); mutations[types.SET_PROJECT_ENDPOINT_LIST](localState, mockEndpoint);
expect(localState.projectTokens[0]).toEqual(oneProject); expect(localState.projectEndpoints.list).toBe(mockEndpoint);
}); });
}); });
describe('INCREMENT_PROJECT_SEARCH_COUNT', () => { describe('SET_PROJECT_ENDPOINT_ADD', () => {
it('adds search to searchCount', () => { it('sets project add endpoint', () => {
mockSearches.forEach(() => { mutations[types.SET_PROJECT_ENDPOINT_ADD](localState, mockEndpoint);
mutations[types.INCREMENT_PROJECT_SEARCH_COUNT](localState, 1);
});
expect(localState.searchCount).toBe(mockSearches.length); expect(localState.projectEndpoints.add).toBe(mockEndpoint);
}); });
}); });
describe('DECREMENT_PROJECT_SEARCH_COUNT', () => { describe('SET_PROJECTS', () => {
it('removes search from searchCount', () => { it('sets projects', () => {
localState.searchCount = mockSearches.length + 2; mutations[types.SET_PROJECTS](localState, projects);
mockSearches.forEach(() => {
mutations[types.DECREMENT_PROJECT_SEARCH_COUNT](localState, 1);
});
expect(localState.searchCount).toBe(2); expect(localState.projects).toEqual(projects);
expect(localState.isLoadingProjects).toEqual(false);
}); });
}); });
describe('SET_PROJECT_ENDPOINT_LIST', () => { describe('SET_MESSAGE_MINIMUM_QUERY', () => {
it('sets project list endpoint', () => { it('sets the messages.minimumQuery boolean', () => {
mutations[types.SET_PROJECT_ENDPOINT_LIST](localState, mockEndpoint); mutations[types.SET_MESSAGE_MINIMUM_QUERY](localState, true);
expect(localState.projectEndpoints.list).toBe(mockEndpoint); expect(localState.messages.minimumQuery).toEqual(true);
mutations[types.SET_MESSAGE_MINIMUM_QUERY](localState, false);
}); });
}); });
describe('SET_PROJECT_ENDPOINT_ADD', () => { describe('SET_SEARCH_QUERY', () => {
it('sets project add endpoint', () => { it('sets the search query', () => {
mutations[types.SET_PROJECT_ENDPOINT_ADD](localState, mockEndpoint); const mockQuery = 'mock-query';
mutations[types.SET_SEARCH_QUERY](localState, mockQuery);
expect(localState.projectEndpoints.add).toBe(mockEndpoint); expect(localState.searchQuery).toBe(mockQuery);
}); });
}); });
describe('SET_PROJECT_SEARCH_RESULTS', () => { describe('ADD_SELECTED_PROJECT', () => {
it('sets project search results', () => { it('adds a project to the list of selected projects', () => {
mutations[types.SET_PROJECT_SEARCH_RESULTS](localState, projects); mutations[types.ADD_SELECTED_PROJECT](localState, projects[0]);
expect(localState.projectSearchResults).toEqual(projects); expect(localState.selectedProjects).toEqual([projects[0]]);
}); });
}); });
describe('SET_PROJECTS', () => { describe('REMOVE_SELECTED_PROJECT', () => {
it('sets projects', () => { it('removes a project from the list of selected projects', () => {
mutations[types.SET_PROJECTS](localState, projects); mutations[types.ADD_SELECTED_PROJECT](localState, projects[0]);
mutations[types.ADD_SELECTED_PROJECT](localState, projects[1]);
mutations[types.REMOVE_SELECTED_PROJECT](localState, projects[0]);
expect(localState.selectedProjects).toEqual([projects[1]]);
});
it('removes a project from the list of selected projects, including duplicates', () => {
mutations[types.ADD_SELECTED_PROJECT](localState, projects[0]);
mutations[types.ADD_SELECTED_PROJECT](localState, projects[0]);
mutations[types.ADD_SELECTED_PROJECT](localState, projects[1]);
mutations[types.REMOVE_SELECTED_PROJECT](localState, projects[0]);
expect(localState.selectedProjects).toEqual([projects[1]]);
});
});
describe('RECEIVE_PROJECTS_SUCCESS', () => {
it('sets the project list and clears the loading status', () => {
mutations[types.RECEIVE_PROJECTS_SUCCESS](localState, projects);
expect(localState.projects).toEqual(projects); expect(localState.projects).toEqual(projects);
expect(localState.isLoadingProjects).toEqual(false);
expect(localState.isLoadingProjects).toBe(false);
});
});
describe('RECEIVE_PROJECTS_ERROR', () => {
it('clears project list and the loading status', () => {
mutations[types.RECEIVE_PROJECTS_ERROR](localState);
expect(localState.projects).toEqual(null);
expect(localState.isLoadingProjects).toBe(false);
});
});
describe('CLEAR_SEARCH_RESULTS', () => {
it('empties both the search results and the list of selected projects', () => {
localState.selectedProjects = [{ id: 1 }];
localState.projectSearchResults = [{ id: 1 }];
mutations[types.CLEAR_SEARCH_RESULTS](localState);
expect(localState.projectSearchResults).toEqual([]);
expect(localState.selectedProjects).toEqual([]);
}); });
}); });
describe('REMOVE_PROJECT_TOKEN_AT', () => { describe('REQUEST_SEARCH_RESULTS', () => {
it('removes project token', () => { it('turns off the minimum length warning and increments the search count', () => {
localState.projectTokens = projects; mutations[types.REQUEST_SEARCH_RESULTS](localState);
mutations[types.REMOVE_PROJECT_TOKEN_AT](localState, oneProject.id);
expect(localState.messages.minimumQuery).toBe(false);
expect(localState.searchCount).toEqual(1);
});
});
describe('RECEIVE_SEARCH_RESULTS_SUCCESS', () => {
it('resets all messages and sets state.projectSearchResults to the results from the API', () => {
localState.projectSearchResults = [];
localState.messages = {
noResults: true,
searchError: true,
minimumQuery: true,
};
const searchResults = [{ id: 1 }];
mutations[types.RECEIVE_SEARCH_RESULTS_SUCCESS](localState, searchResults);
expect(localState.projectSearchResults).toEqual(searchResults);
expect(localState.messages.noResults).toBe(false);
expect(localState.messages.searchError).toBe(false);
expect(localState.messages.minimumQuery).toBe(false);
});
it('resets all messages and sets state.projectSearchResults to an empty array if no results', () => {
localState.projectSearchResults = [];
localState.messages = {
noResults: false,
searchError: true,
minimumQuery: true,
};
const searchResults = [];
mutations[types.RECEIVE_SEARCH_RESULTS_SUCCESS](localState, searchResults);
expect(localState.projectSearchResults).toEqual(searchResults);
expect(localState.messages.noResults).toBe(true);
expect(localState.messages.searchError).toBe(false);
expect(localState.messages.minimumQuery).toBe(false);
});
});
describe('RECEIVE_SEARCH_RESULTS_ERROR', () => {
it('clears the search results', () => {
mutations[types.RECEIVE_SEARCH_RESULTS_ERROR](localState);
expect(localState.projectSearchResults).toEqual([]);
expect(localState.messages.noResults).toBe(false);
expect(localState.messages.searchError).toBe(true);
expect(localState.projectTokens.length).toBe(0); expect(localState.messages.minimumQuery).toBe(false);
}); });
}); });
......
...@@ -47,9 +47,6 @@ msgstr "" ...@@ -47,9 +47,6 @@ msgstr ""
msgid " or <#issue id>" msgid " or <#issue id>"
msgstr "" msgstr ""
msgid "\"%{query}\" in projects"
msgstr ""
msgid "%d comment" msgid "%d comment"
msgid_plural "%d comments" msgid_plural "%d comments"
msgstr[0] "" msgstr[0] ""
...@@ -593,9 +590,6 @@ msgstr "" ...@@ -593,9 +590,6 @@ msgstr ""
msgid "Add new directory" msgid "Add new directory"
msgstr "" msgstr ""
msgid "Add projects"
msgstr ""
msgid "Add reaction" msgid "Add reaction"
msgstr "" msgstr ""
...@@ -3874,6 +3868,9 @@ msgstr "" ...@@ -3874,6 +3868,9 @@ msgstr ""
msgid "Enforced SSO" msgid "Enforced SSO"
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 ""
...@@ -7449,6 +7446,15 @@ msgstr "" ...@@ -7449,6 +7446,15 @@ msgstr ""
msgid "OperationsDashboard|Add a project to the dashboard" msgid "OperationsDashboard|Add a project to the dashboard"
msgstr "" msgstr ""
msgid "OperationsDashboard|Add projects"
msgstr ""
msgid "OperationsDashboard|More information"
msgstr ""
msgid "OperationsDashboard|Operations Dashboard"
msgstr ""
msgid "OperationsDashboard|The operations dashboard provides a summary of each project's operational health, including pipeline and alert statuses." msgid "OperationsDashboard|The operations dashboard provides a summary of each project's operational health, including pipeline and alert statuses."
msgstr "" msgstr ""
...@@ -9829,6 +9835,9 @@ msgstr "" ...@@ -9829,6 +9835,9 @@ msgstr ""
msgid "Something went wrong, unable to remove project" msgid "Something went wrong, unable to remove project"
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 ""
...@@ -11758,9 +11767,6 @@ msgstr "" ...@@ -11758,9 +11767,6 @@ msgstr ""
msgid "View details: %{details_url}" msgid "View details: %{details_url}"
msgstr "" msgstr ""
msgid "View documentation"
msgstr ""
msgid "View eligible approvers" msgid "View eligible approvers"
msgstr "" msgstr ""
......
...@@ -149,4 +149,31 @@ describe('text_utility', () => { ...@@ -149,4 +149,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);
});
});
});
}); });
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, {
itemId: mockProject.id, propsData: {
itemName: mockProject.name, itemId: mockProject.id,
namespace: mockProject.namespace, itemName: mockProject.name,
webUrl: mockProject.webUrl, namespace: mockProject.namespace,
avatarUrl: mockProject.avatarUrl, webUrl: mockProject.webUrl,
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', () => { ...@@ -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', () => { ...@@ -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);
}); });
}); });
}); });
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', () => { ...@@ -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', () => { ...@@ -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', () => { ...@@ -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(); );
}); });
}); });
}); });
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);
});
});
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();
});
});
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);
});
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment