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>
/* eslint-disable vue/require-default-prop */
import Identicon from '../../vue_shared/components/identicon.vue';
import _ from 'underscore';
import Identicon from '~/vue_shared/components/identicon.vue';
import highlight from '~/lib/utils/highlight';
import { truncateNamespace } from '~/lib/utils/text_utility';
export default {
components: {
......@@ -36,43 +39,13 @@ export default {
},
computed: {
hasAvatar() {
return this.avatarUrl !== null;
return _.isString(this.avatarUrl) && !_.isEmpty(this.avatarUrl);
},
highlightedItemName() {
if (this.matcher) {
const matcherRegEx = new RegExp(this.matcher, 'gi');
const matches = this.itemName.match(matcherRegEx);
if (matches && matches.length > 0) {
return this.itemName.replace(matches[0], `<b>${matches[0]}</b>`);
}
}
return this.itemName;
},
/**
* Smartly truncates item namespace by doing two things;
* 1. Only include Group names in path by removing item name
* 2. Only include first and last group names in the path
* when namespace has more than 2 groups present
*
* First part (removal of item name from namespace) can be
* done from backend but doing so involves migration of
* existing item namespaces which is not wise thing to do.
*/
truncatedNamespace() {
if (!this.namespace) {
return null;
}
const namespaceArr = this.namespace.split(' / ');
namespaceArr.splice(-1, 1);
let namespace = namespaceArr.join(' / ');
if (namespaceArr.length > 2) {
namespace = `${namespaceArr[0]} / ... / ${namespaceArr.pop()}`;
}
return namespace;
return truncateNamespace(this.namespace);
},
highlightedItemName() {
return highlight(this.itemName, this.matcher);
},
},
};
......@@ -92,8 +65,16 @@ export default {
/>
</div>
<div class="frequent-items-item-metadata-container">
<div :title="itemName" class="frequent-items-item-title" v-html="highlightedItemName"></div>
<div v-if="truncatedNamespace" :title="namespace" class="frequent-items-item-namespace">
<div
:title="itemName"
class="frequent-items-item-title js-frequent-items-item-title"
v-html="highlightedItemName"
></div>
<div
v-if="namespace"
:title="namespace"
class="frequent-items-item-namespace js-frequent-items-item-namespace"
>
{{ truncatedNamespace }}
</div>
</div>
......
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.
*
......@@ -160,3 +162,33 @@ export const splitCamelCase = string =>
.replace(/([A-Z]+)([A-Z][a-z])/g, ' $1 $2')
.replace(/([a-z\d])([A-Z])/g, '$1 $2')
.trim();
/**
* Intelligently truncates an item's namespace by doing two things:
* 1. Only include group names in path by removing the item name
* 2. Only include the first and last group names in the path
* when the namespace includes more than 2 groups
*
* @param {String} string A string namespace,
* i.e. "My Group / My Subgroup / My Project"
*/
export const truncateNamespace = (string = '') => {
if (_.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>
import _ from 'underscore';
import { mapState, mapActions } from 'vuex';
import { GlLoadingIcon, GlDashboardSkeleton } from '@gitlab/ui';
import ProjectSearch from 'ee/vue_shared/dashboards/components/project_search.vue';
import { GlLoadingIcon, GlModal, GlModalDirective, GlButton } from '@gitlab/ui';
import ProjectSelector from '~/vue_shared/components/project_selector/project_selector.vue';
import DashboardProject from './project.vue';
export default {
components: {
DashboardProject,
ProjectSearch,
GlModal,
GlLoadingIcon,
GlDashboardSkeleton,
GlButton,
ProjectSelector,
},
directives: {
'gl-modal': GlModalDirective,
},
props: {
addPath: {
......@@ -29,10 +34,23 @@ export default {
required: true,
},
},
modalId: 'add-projects-modal',
computed: {
...mapState(['projects', 'projectTokens', 'isLoadingProjects']),
addIsDisabled() {
return !this.projectTokens.length;
...mapState([
'projects',
'projectTokens',
'isLoadingProjects',
'selectedProjects',
'projectSearchResults',
'searchCount',
'searchQuery',
'messages',
]),
isSearchingProjects() {
return this.searchCount > 0;
},
okDisabled() {
return _.isEmpty(this.selectedProjects);
},
},
created() {
......@@ -43,11 +61,33 @@ export default {
this.fetchProjects();
},
methods: {
...mapActions(['addProjectsToDashboard', 'fetchProjects', 'setProjectEndpoints']),
...mapActions([
'fetchSearchResults',
'addProjectsToDashboard',
'fetchProjects',
'setProjectEndpoints',
'clearSearchResults',
'toggleSelectedProject',
'setSearchQuery',
]),
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 {
<template>
<div class="operations-dashboard">
<div
class="page-title-holder flex-fill d-flex flex-column flex-md-row align-items-md-end align-items-stretch"
<gl-modal
: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">
<h1 class="js-dashboard-title page-title text-nowrap">{{ __('Operations Dashboard') }}</h1>
</div>
<div class="d-flex flex-fill align-items-end append-bottom-default">
<project-search class="flex-grow-1" />
<button
:class="{ disabled: addIsDisabled }"
type="button"
class="js-add-projects-button btn btn-success prepend-left-8"
@click="addProjects"
>
{{ __('Add projects') }}
</button>
</div>
<project-selector
ref="projectSelector"
:project-search-results="projectSearchResults"
:selected-projects="selectedProjects"
:show-no-results-message="messages.noResults"
:show-loading-indicator="isSearchingProjects"
:show-minimum-search-query-message="messages.minimumQuery"
:show-search-error-message="messages.searchError"
@searched="searched"
@projectClicked="projectClicked"
/>
</gl-modal>
<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 class="prepend-top-default">
<div v-if="projects.length" class="row prepend-top-default dashboard-cards">
......@@ -92,15 +149,18 @@ export default {
s__(`OperationsDashboard|The operations dashboard provides a summary of each project's
operational health, including pipeline and alert statuses.`)
}}
<a :href="emptyDashboardHelpPath" class="js-documentation-link">
{{ s__('OperationsDashboard|More information') }}
</a>
</span>
</div>
<div class="col-12">
<a
:href="emptyDashboardHelpPath"
class="js-documentation-link btn btn-primary prepend-top-default append-bottom-default"
<gl-button
v-gl-modal="$options.modalId"
class="js-add-projects-button btn btn-success prepend-top-default append-bottom-default"
>
{{ __('View documentation') }}
</a>
{{ s__('OperationsDashboard|Add projects') }}
</gl-button>
</div>
</div>
<gl-dashboard-skeleton v-else />
......
......@@ -4,8 +4,11 @@ import axios from '~/lib/utils/axios_utils';
import Poll from '~/lib/utils/poll';
import createFlash from '~/flash';
import { __, s__, n__, sprintf } from '~/locale';
import _ from 'underscore';
import * as types from './mutation_types';
const API_MINIMUM_QUERY_LENGTH = 3;
let eTagPoll;
export const clearProjectsEtagPoll = () => {
......@@ -24,32 +27,29 @@ export const forceProjectsRequest = () => {
export const addProjectsToDashboard = ({ state, dispatch }) => {
axios
.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))
.catch(() => dispatch('requestAddProjectsToDashboardError'));
};
export const clearInputValue = ({ commit }) => {
commit(types.SET_INPUT_VALUE, '');
.then(response => dispatch('receiveAddProjectsToDashboardSuccess', response.data))
.catch(() => dispatch('receiveAddProjectsToDashboardError'));
};
export const clearProjectTokens = ({ commit }) => {
commit(types.SET_PROJECT_TOKENS, []);
export const toggleSelectedProject = ({ commit, state }, project) => {
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) => {
const tokens = state.projectTokens.filter(token => ids.includes(token.id));
commit(types.SET_PROJECT_TOKENS, tokens);
export const clearSearchResults = ({ commit }) => {
commit(types.CLEAR_SEARCH_RESULTS);
};
export const requestAddProjectsToDashboardSuccess = ({ dispatch, state }, data) => {
export const receiveAddProjectsToDashboardSuccess = ({ dispatch, state }, data) => {
const { added, invalid } = data;
dispatch('clearInputValue');
if (invalid.length) {
const projectNames = state.projectTokens.reduce((accumulator, project) => {
const projectNames = state.selectedProjects.reduce((accumulator, project) => {
if (invalid.includes(project.id)) {
accumulator.push(project.name);
}
......@@ -73,9 +73,6 @@ export const requestAddProjectsToDashboardSuccess = ({ dispatch, state }, data)
},
),
);
dispatch('filterProjectTokensById', invalid);
} else {
dispatch('clearProjectTokens');
}
if (added.length) {
......@@ -83,23 +80,14 @@ export const requestAddProjectsToDashboardSuccess = ({ dispatch, state }, data)
}
};
export const requestAddProjectsToDashboardError = ({ state }) => {
export const receiveAddProjectsToDashboardError = ({ state }) => {
createFlash(
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 }) => {
if (eTagPoll) return;
......@@ -132,48 +120,51 @@ export const requestProjects = ({ commit }) => {
};
export const receiveProjectsSuccess = ({ commit }, data) => {
commit(types.SET_PROJECTS, data.projects);
commit(types.RECEIVE_PROJECTS_SUCCESS, data.projects);
};
export const receiveProjectsError = ({ commit }) => {
commit(types.SET_PROJECTS, null);
commit(types.RECEIVE_PROJECTS_ERROR);
createFlash(__('Something went wrong, unable to get operations projects'));
};
export const removeProject = ({ dispatch }, removePath) => {
axios
.delete(removePath)
.then(() => dispatch('requestRemoveProjectSuccess'))
.catch(() => dispatch('requestRemoveProjectError'));
.then(() => dispatch('receiveRemoveProjectSuccess'))
.catch(() => dispatch('receiveRemoveProjectError'));
};
export const requestRemoveProjectSuccess = ({ dispatch }) => {
dispatch('forceProjectsRequest');
};
export const receiveRemoveProjectSuccess = ({ dispatch }) => dispatch('fetchProjects');
export const requestRemoveProjectError = () => {
export const receiveRemoveProjectError = () => {
createFlash(__('Something went wrong, unable to remove project'));
};
export const removeProjectTokenAt = ({ commit }, index) => {
commit(types.REMOVE_PROJECT_TOKEN_AT, index);
export const setSearchQuery = ({ commit }, query) => commit(types.SET_SEARCH_QUERY, query);
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) => {
commit(types.INCREMENT_PROJECT_SEARCH_COUNT, 1);
export const requestSearchResults = ({ commit }) => commit(types.REQUEST_SEARCH_RESULTS);
Api.projects(query, {})
.then(data => data)
.catch(() => [])
.then(results => {
commit(types.SET_PROJECT_SEARCH_RESULTS, results);
commit(types.DECREMENT_PROJECT_SEARCH_COUNT, 1);
})
.catch(() => {});
export const receiveSearchResultsSuccess = ({ commit }, results) => {
commit(types.RECEIVE_SEARCH_RESULTS_SUCCESS, results);
};
export const setInputValue = ({ commit }, value) => {
commit(types.SET_INPUT_VALUE, value);
export const receiveSearchResultsError = ({ commit }) => {
commit(types.RECEIVE_SEARCH_RESULTS_ERROR);
};
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_ADD = 'SET_PROJECT_ENDPOINT_ADD';
export const SET_PROJECT_SEARCH_RESULTS = 'SET_PROJECT_SEARCH_RESULTS';
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 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 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';
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) {
state.projectEndpoints.list = url;
},
[types.SET_PROJECT_ENDPOINT_ADD](state, url) {
state.projectEndpoints.add = url;
},
[types.SET_PROJECT_SEARCH_RESULTS](state, results) {
state.projectSearchResults = results;
},
[types.SET_PROJECTS](state, projects) {
state.projects = projects || [];
state.isLoadingProjects = false;
},
[types.SET_PROJECT_TOKENS](state, tokens) {
state.projectTokens = tokens;
[types.SET_SEARCH_QUERY](state, query) {
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) {
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 () => ({
list: null,
add: null,
},
searchQuery: '',
projects: [],
projectTokens: [],
projectSearchResults: [],
selectedProjects: [],
messages: {
noResults: false,
searchError: false,
minimumQuery: false,
},
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 diff is collapsed.
import Vue from 'vue';
import store from 'ee/operations/store/index';
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 { trimText } from 'spec/helpers/vue_component_helper';
import { getChildInstances, clearState } from '../../helpers';
import { mockProjectData, mockText } from '../../mock_data';
describe('dashboard component', () => {
const DashboardComponent = Vue.extend(Dashboard);
const ProjectSearchComponent = Vue.extend(ProjectSearch);
const DashboardProjectComponent = Vue.extend(DashboardProject);
const projectTokens = mockProjectData(1);
const mount = () =>
new DashboardComponent({
store,
......@@ -52,29 +50,14 @@ describe('dashboard component', () => {
expect(button.innerText.trim()).toBe(mockText.ADD_PROJECTS);
});
it('calls action to add projects on click if projectTokens have been added', () => {
const spy = spyOn(vm, 'addProjectsToDashboard').and.stub();
vm.$store.state.projectTokens = projectTokens;
it('renders the projects modal', () => {
button.click();
expect(spy).toHaveBeenCalled();
});
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();
expect(vm.$el.querySelector('.add-projects-modal')).toBeDefined();
});
});
describe('wrapped components', () => {
describe('project search component', () => {
it('renders project search component', () => {
expect(getChildInstances(vm, ProjectSearchComponent).length).toBe(1);
});
});
describe('dashboard project component', () => {
const projectCount = 1;
const projects = mockProjectData(projectCount);
......@@ -113,7 +96,7 @@ describe('dashboard component', () => {
});
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,
);
});
......@@ -121,7 +104,7 @@ describe('dashboard component', () => {
it('renders link to documentation', () => {
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', () => {
......
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 = {
DASHBOARD_TITLE: 'Operations Dashboard',
EMPTY_TITLE: 'Add a project to the dashboard',
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',
NO_SEARCH_RESULTS: 'Sorry, no projects matched your search',
RECEIVE_PROJECTS_ERROR: 'Something went wrong, unable to get operations projects',
......
......@@ -4,84 +4,183 @@ import * as types from 'ee/operations/store/mutation_types';
import { mockProjectData } from '../mock_data';
describe('mutations', () => {
const projects = mockProjectData(1);
const [oneProject] = projects;
const projects = mockProjectData(3);
const mockEndpoint = 'https://mock-endpoint';
const mockSearches = new Array(5).fill(null);
let localState;
beforeEach(() => {
localState = state();
});
describe('ADD_PROJECT_TOKEN', () => {
it('adds project token to projectTokens', () => {
mutations[types.ADD_PROJECT_TOKEN](localState, oneProject);
describe('SET_PROJECT_ENDPOINT_LIST', () => {
it('sets project list endpoint', () => {
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', () => {
it('adds search to searchCount', () => {
mockSearches.forEach(() => {
mutations[types.INCREMENT_PROJECT_SEARCH_COUNT](localState, 1);
});
describe('SET_PROJECT_ENDPOINT_ADD', () => {
it('sets project add endpoint', () => {
mutations[types.SET_PROJECT_ENDPOINT_ADD](localState, mockEndpoint);
expect(localState.searchCount).toBe(mockSearches.length);
expect(localState.projectEndpoints.add).toBe(mockEndpoint);
});
});
describe('DECREMENT_PROJECT_SEARCH_COUNT', () => {
it('removes search from searchCount', () => {
localState.searchCount = mockSearches.length + 2;
mockSearches.forEach(() => {
mutations[types.DECREMENT_PROJECT_SEARCH_COUNT](localState, 1);
});
describe('SET_PROJECTS', () => {
it('sets projects', () => {
mutations[types.SET_PROJECTS](localState, projects);
expect(localState.searchCount).toBe(2);
expect(localState.projects).toEqual(projects);
expect(localState.isLoadingProjects).toEqual(false);
});
});
describe('SET_PROJECT_ENDPOINT_LIST', () => {
it('sets project list endpoint', () => {
mutations[types.SET_PROJECT_ENDPOINT_LIST](localState, mockEndpoint);
describe('SET_MESSAGE_MINIMUM_QUERY', () => {
it('sets the messages.minimumQuery boolean', () => {
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', () => {
it('sets project add endpoint', () => {
mutations[types.SET_PROJECT_ENDPOINT_ADD](localState, mockEndpoint);
describe('SET_SEARCH_QUERY', () => {
it('sets the search query', () => {
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', () => {
it('sets project search results', () => {
mutations[types.SET_PROJECT_SEARCH_RESULTS](localState, projects);
describe('ADD_SELECTED_PROJECT', () => {
it('adds a project to the list of selected projects', () => {
mutations[types.ADD_SELECTED_PROJECT](localState, projects[0]);
expect(localState.projectSearchResults).toEqual(projects);
expect(localState.selectedProjects).toEqual([projects[0]]);
});
});
describe('SET_PROJECTS', () => {
it('sets projects', () => {
mutations[types.SET_PROJECTS](localState, projects);
describe('REMOVE_SELECTED_PROJECT', () => {
it('removes a project from the list of selected 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.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', () => {
it('removes project token', () => {
localState.projectTokens = projects;
mutations[types.REMOVE_PROJECT_TOKEN_AT](localState, oneProject.id);
describe('REQUEST_SEARCH_RESULTS', () => {
it('turns off the minimum length warning and increments the search count', () => {
mutations[types.REQUEST_SEARCH_RESULTS](localState);
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 ""
msgid " or <#issue id>"
msgstr ""
msgid "\"%{query}\" in projects"
msgstr ""
msgid "%d comment"
msgid_plural "%d comments"
msgstr[0] ""
......@@ -593,9 +590,6 @@ msgstr ""
msgid "Add new directory"
msgstr ""
msgid "Add projects"
msgstr ""
msgid "Add reaction"
msgstr ""
......@@ -3874,6 +3868,9 @@ msgstr ""
msgid "Enforced SSO"
msgstr ""
msgid "Enter at least three characters to search"
msgstr ""
msgid "Enter in your Bitbucket Server URL and personal access token below"
msgstr ""
......@@ -7449,6 +7446,15 @@ msgstr ""
msgid "OperationsDashboard|Add a project to the dashboard"
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."
msgstr ""
......@@ -9829,6 +9835,9 @@ msgstr ""
msgid "Something went wrong, unable to remove project"
msgstr ""
msgid "Something went wrong, unable to search projects"
msgstr ""
msgid "Something went wrong. Please try again."
msgstr ""
......@@ -11758,9 +11767,6 @@ msgstr ""
msgid "View details: %{details_url}"
msgstr ""
msgid "View documentation"
msgstr ""
msgid "View eligible approvers"
msgstr ""
......
......@@ -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 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
const createComponent = () => {
const Component = Vue.extend(frequentItemsListItemComponent);
return mountComponent(Component, {
itemId: mockProject.id,
itemName: mockProject.name,
namespace: mockProject.namespace,
webUrl: mockProject.webUrl,
avatarUrl: mockProject.avatarUrl,
return shallowMount(Component, {
propsData: {
itemId: mockProject.id,
itemName: mockProject.name,
namespace: mockProject.namespace,
webUrl: mockProject.webUrl,
avatarUrl: mockProject.avatarUrl,
},
});
};
describe('FrequentItemsListItemComponent', () => {
let wrapper;
let vm;
beforeEach(() => {
vm = createComponent();
wrapper = createComponent();
({ vm } = wrapper);
});
afterEach(() => {
......@@ -29,11 +35,11 @@ describe('FrequentItemsListItemComponent', () => {
describe('computed', () => {
describe('hasAvatar', () => {
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);
vm.avatarUrl = null;
wrapper.setProps({ avatarUrl: null });
expect(vm.hasAvatar).toBe(false);
});
......@@ -41,41 +47,49 @@ describe('FrequentItemsListItemComponent', () => {
describe('highlightedItemName', () => {
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', () => {
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', () => {
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', () => {
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', () => {
it('should render component element', () => {
expect(vm.$el.classList.contains('frequent-items-list-item-container')).toBeTruthy();
expect(vm.$el.querySelectorAll('a').length).toBe(1);
expect(vm.$el.querySelectorAll('.frequent-items-item-avatar-container').length).toBe(1);
expect(vm.$el.querySelectorAll('.frequent-items-item-metadata-container').length).toBe(1);
expect(vm.$el.querySelectorAll('.frequent-items-item-title').length).toBe(1);
expect(vm.$el.querySelectorAll('.frequent-items-item-namespace').length).toBe(1);
expect(wrapper.classes()).toContain('frequent-items-list-item-container');
expect(wrapper.findAll('a').length).toBe(1);
expect(wrapper.findAll('.frequent-items-item-avatar-container').length).toBe(1);
expect(wrapper.findAll('.frequent-items-item-metadata-container').length).toBe(1);
expect(wrapper.findAll('.frequent-items-item-title').length).toBe(1);
expect(wrapper.findAll('.frequent-items-item-namespace').length).toBe(1);
});
});
});
import Vue from 'vue';
import searchComponent from '~/frequent_items/components/frequent_items_search_input.vue';
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 Component = Vue.extend(searchComponent);
return mountComponent(Component, { namespace });
return shallowMount(Component, { propsData: { namespace } });
};
describe('FrequentItemsSearchInputComponent', () => {
let wrapper;
let vm;
beforeEach(() => {
vm = createComponent();
wrapper = createComponent();
({ vm } = wrapper);
});
afterEach(() => {
......@@ -35,7 +38,7 @@ describe('FrequentItemsSearchInputComponent', () => {
describe('mounted', () => {
it('should listen `dropdownOpen` event', done => {
spyOn(eventHub, '$on');
const vmX = createComponent();
const vmX = createComponent().vm;
Vue.nextTick(() => {
expect(eventHub.$on).toHaveBeenCalledWith(
......@@ -49,7 +52,7 @@ describe('FrequentItemsSearchInputComponent', () => {
describe('beforeDestroy', () => {
it('should unbind event listeners on eventHub', done => {
const vmX = createComponent();
const vmX = createComponent().vm;
spyOn(eventHub, '$off');
vmX.$mount();
......@@ -67,12 +70,12 @@ describe('FrequentItemsSearchInputComponent', () => {
describe('template', () => {
it('should render component element', () => {
const inputEl = vm.$el.querySelector('input.form-control');
expect(vm.$el.classList.contains('search-input-container')).toBeTruthy();
expect(inputEl).not.toBe(null);
expect(inputEl.getAttribute('placeholder')).toBe('Search your projects');
expect(vm.$el.querySelector('.search-icon')).toBeDefined();
expect(wrapper.classes()).toContain('search-input-container');
expect(wrapper.contains('input.form-control')).toBe(true);
expect(wrapper.contains('.search-icon')).toBe(true);
expect(wrapper.find('input.form-control').attributes('placeholder')).toBe(
'Search your projects',
);
});
});
});
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