Commit cf1e2084 authored by Nathan Friend's avatar Nathan Friend Committed by mfluharty

Move Operations Dashboard search bar into modal

This commit moves the existing search bar on the Operations Dashboard
page - which is currently embedded directly in the page - into a modal
dialog.

The new search feature uses the new "project_selector" CE shared
component to allow the user to select one or more projects to add.
parent 701096d2
<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([
'searchProjects',
'addProjectsToDashboard',
'fetchProjects',
'setProjectEndpoints',
'clearSearchResults',
'toggleSelectedProject',
'setSearchQuery',
]),
addProjects() {
if (!this.addIsDisabled) {
this.addProjectsToDashboard();
}
},
onModalShown() {
this.$refs.projectSelector.focusSearchInput();
},
onModalHidden() {
this.clearSearchResults();
},
onOk() {
this.addProjectsToDashboard();
},
searched(query) {
this.setSearchQuery(query);
this.searchProjects();
},
projectClicked(project) {
this.toggleSelectedProject(project);
},
},
};
......@@ -55,23 +95,41 @@ 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 }"
<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"
type="button"
class="js-add-projects-button btn btn-success prepend-left-8"
@click="addProjects"
class="js-add-projects-button btn btn-success"
>
{{ __('Add projects') }}
</button>
</div>
{{ 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 +150,19 @@ 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"
type="button"
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 />
......
......@@ -5,6 +5,9 @@ import Poll from '~/lib/utils/poll';
import createFlash from '~/flash';
import { __, s__, n__, sprintf } from '~/locale';
import * as types from './mutation_types';
import _ from 'underscore';
const API_MINIMUM_QUERY_LENGTH = 3;
let eTagPoll;
......@@ -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, '');
};
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) => {
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) {
......@@ -86,20 +83,11 @@ export const requestAddProjectsToDashboardSuccess = ({ dispatch, state }, data)
export const requestAddProjectsToDashboardError = ({ 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;
......@@ -155,25 +143,38 @@ export const requestRemoveProjectError = () => {
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 searchProjects = ({ commit }, query) => {
export const searchProjects = ({ commit, state }) => {
if (!state.searchQuery) {
commit(types.SEARCHED_WITH_NO_QUERY);
} else if (state.searchQuery.length < API_MINIMUM_QUERY_LENGTH) {
commit(types.SEARCHED_WITH_TOO_SHORT_QUERY);
} else {
commit(types.INCREMENT_PROJECT_SEARCH_COUNT, 1);
Api.projects(query, {})
.then(data => data)
.catch(() => [])
// Flipping this property separately to allows the UI
// to hide the "minimum query" message
// before the seach results arrive from the API
commit(types.SET_MESSAGE_MINIMUM_QUERY, false);
Api.projects(state.searchQuery, {})
.then(results => {
commit(types.SET_PROJECT_SEARCH_RESULTS, results);
if (results.length === 0) {
commit(types.SEARCHED_SUCCESSFULLY_NO_RESULTS);
} else {
commit(types.SEARCHED_SUCCESSFULLY_WITH_RESULTS, results);
}
commit(types.DECREMENT_PROJECT_SEARCH_COUNT, 1);
})
.catch(() => {});
};
export const setInputValue = ({ commit }, value) => {
commit(types.SET_INPUT_VALUE, value);
.catch(() => {
commit(types.SEARCHED_WITH_API_ERROR);
commit(types.DECREMENT_PROJECT_SEARCH_COUNT, 1);
});
}
};
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 REQUEST_PROJECTS = 'REQUEST_PROJECTS';
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 TOGGLE_IS_LOADING_PROJECTS = 'TOGGLE_IS_LOADING_PROJECTS';
export const CLEAR_SEARCH_RESULTS = 'CLEAR_SEARCH_RESULTS';
export const SEARCHED_WITH_NO_QUERY = 'SEARCHED_WITH_NO_QUERY';
export const SEARCHED_WITH_TOO_SHORT_QUERY = 'SEARCHED_WITH_TOO_SHORT_QUERY';
export const SEARCHED_WITH_API_ERROR = 'SEARCHED_WITH_API_ERROR';
export const SEARCHED_SUCCESSFULLY_WITH_RESULTS = 'SEARCHED_SUCCESSFULLY_WITH_RESULTS';
export const SEARCHED_SUCCESSFULLY_NO_RESULTS = 'SEARCHED_SUCCESSFULLY_NO_RESULTS';
import _ from 'underscore';
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.REQUEST_PROJECTS](state) {
state.isLoadingProjects = true;
},
[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 = _.without(
state.selectedProjects,
..._.where(state.selectedProjects, { id: project.id }),
);
},
[types.TOGGLE_IS_LOADING_PROJECTS](state) {
state.isLoadingProjects = !state.isLoadingProjects;
},
[types.CLEAR_SEARCH_RESULTS](state) {
state.projectSearchResults = [];
state.selectedProjects = [];
},
[types.SEARCHED_WITH_NO_QUERY](state) {
state.projectSearchResults = [];
state.messages.noResults = false;
state.messages.searchError = false;
state.messages.minimumQuery = false;
},
[types.SEARCHED_WITH_TOO_SHORT_QUERY](state) {
state.projectSearchResults = [];
state.messages.noResults = false;
state.messages.searchError = false;
state.messages.minimumQuery = true;
},
[types.SEARCHED_WITH_API_ERROR](state) {
state.projectSearchResults = [];
state.messages.noResults = false;
state.messages.searchError = true;
state.messages.minimumQuery = false;
},
[types.SEARCHED_SUCCESSFULLY_WITH_RESULTS](state, results) {
state.projectSearchResults = results;
state.messages.noResults = false;
state.messages.searchError = false;
state.messages.minimumQuery = false;
},
[types.SEARCHED_SUCCESSFULLY_NO_RESULTS](state) {
state.projectSearchResults = [];
state.messages.noResults = true;
state.messages.searchError = false;
state.messages.minimumQuery = false;
},
};
......@@ -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 source diff could not be displayed because it is too large. You can view the blob instead.
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('opens the Add 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',
......
......@@ -25,9 +25,9 @@ describe('actions', () => {
});
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.projectTokens = mockProjects;
store.state.selectedProjects = mockProjects;
mockAxios.onPost(mockAddEndpoint).replyOnce(200, mockResponse);
......@@ -60,55 +60,34 @@ describe('actions', () => {
});
});
describe('clearInputValue', () => {
it('sets inputValue to empty string', done => {
describe('toggleSelectedProject', () => {
it(`adds a project to selectedProjects if it doesn't already exist in the list`, done => {
testAction(
actions.clearInputValue,
null,
actions.toggleSelectedProject,
mockOneProject,
store.state,
[
{
type: types.SET_INPUT_VALUE,
payload: '',
type: types.ADD_SELECTED_PROJECT,
payload: mockOneProject,
},
],
[],
done,
);
});
});
describe('clearProjectTokens', () => {
it('sets project tokens to an empty array', done => {
testAction(
actions.clearProjectTokens,
null,
store.state,
[
{
type: types.SET_PROJECT_TOKENS,
payload: [],
},
],
[],
done,
);
});
});
describe('filterProjectTokensById', () => {
it('removes all project tokens except those with specified ids', done => {
store.state.projectTokens = mockProjects;
const ids = mockProjects.map(project => project.id);
it(`removes a project from selectedProjects if it already exist in the list`, done => {
store.state.selectedProjects = mockProjects;
testAction(
actions.filterProjectTokensById,
ids,
actions.toggleSelectedProject,
mockOneProject,
store.state,
[
{
type: types.SET_PROJECT_TOKENS,
payload: mockProjects,
type: types.REMOVE_SELECTED_PROJECT,
payload: mockOneProject,
},
],
[],
......@@ -129,12 +108,6 @@ describe('actions', () => {
store.state,
[],
[
{
type: 'clearInputValue',
},
{
type: 'clearProjectTokens',
},
{
type: 'fetchProjects',
},
......@@ -143,56 +116,11 @@ 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 =
'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) {
store.dispatch('addProjectToken', {
store.dispatch('toggleSelectedProject', {
id: i,
name: 'mock-name',
});
......@@ -207,7 +135,7 @@ describe('actions', () => {
it('displays an error when user tries to add one invalid project to dashboard', () => {
const spy = spyOnDependency(defaultActions, 'createFlash');
addTokens(1);
selectProjects(1);
addInvalidProjects([0]);
expect(spy).toHaveBeenCalledWith(`Unable to add mock-name. ${errorMessage}`);
......@@ -215,7 +143,7 @@ describe('actions', () => {
it('displays an error when user tries to add two invalid projects to dashboard', () => {
const spy = spyOnDependency(defaultActions, 'createFlash');
addTokens(2);
selectProjects(2);
addInvalidProjects([0, 1]);
expect(spy).toHaveBeenCalledWith(`Unable to add mock-name and mock-name. ${errorMessage}`);
......@@ -223,7 +151,7 @@ describe('actions', () => {
it('displays an error when user tries to add more than two invalid projects to dashboard', () => {
const spy = spyOnDependency(defaultActions, 'createFlash');
addTokens(3);
selectProjects(3);
addInvalidProjects([0, 1, 2]);
expect(spy).toHaveBeenCalledWith(
......@@ -241,40 +169,17 @@ describe('actions', () => {
});
});
describe('addProjectToken', () => {
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', () => {
describe('clearSearchResults', () => {
it('clears all project search results', done => {
store.state.projectSearchResults = mockProjects;
testAction(
actions.clearProjectSearchResults,
actions.clearSearchResults,
null,
store.state,
[
{
type: types.SET_PROJECT_SEARCH_RESULTS,
payload: [],
type: types.CLEAR_SEARCH_RESULTS,
},
],
[],
......@@ -424,32 +329,30 @@ describe('actions', () => {
});
});
describe('removeProjectToken', () => {
it('removes project token', done => {
store.state.projectTokens = mockProjects;
const [{ id }] = store.state.projectTokens;
describe('searchProjects', () => {
const mockQuery = 'mock-query';
it('commits the SEARCHED_WITH_NO_QUERY if the search query was empty', done => {
mockAxios.onAny().replyOnce(200, mockProjects);
store.state.searchQuery = '';
testAction(
actions.removeProjectTokenAt,
id,
actions.searchProjects,
mockQuery,
store.state,
[
{
type: types.REMOVE_PROJECT_TOKEN_AT,
payload: 0,
type: types.SEARCHED_WITH_NO_QUERY,
},
],
[],
done,
);
});
});
describe('searchProjects', () => {
const mockQuery = 'mock-query';
it('sets project search results', done => {
mockAxios.onAny().replyOnce(200, mockProjects);
store.state.searchQuery = mockQuery;
testAction(
actions.searchProjects,
......@@ -461,7 +364,11 @@ describe('actions', () => {
payload: 1,
},
{
type: types.SET_PROJECT_SEARCH_RESULTS,
type: types.SET_MESSAGE_MINIMUM_QUERY,
payload: false,
},
{
type: types.SEARCHED_SUCCESSFULLY_WITH_RESULTS,
payload: mockProjects,
},
{
......@@ -474,8 +381,27 @@ describe('actions', () => {
);
});
it('clears project search results on error', done => {
mockAxios.onAny().replyOnce(500);
it(`commits the SEARCHED_WITH_TOO_SHORT_QUERY type if the search query wasn't long enough`, done => {
mockAxios.onAny().replyOnce(200, []);
store.state.searchQuery = 'a';
testAction(
actions.searchProjects,
mockQuery,
store.state,
[
{
type: types.SEARCHED_WITH_TOO_SHORT_QUERY,
},
],
[],
done,
);
});
it('commits the SEARCHED_SUCCESSFULLY_NO_RESULTS type (among others) if the search returns with no results', done => {
mockAxios.onAny().replyOnce(200, []);
store.state.searchQuery = mockQuery;
testAction(
actions.searchProjects,
......@@ -487,8 +413,11 @@ describe('actions', () => {
payload: 1,
},
{
type: types.SET_PROJECT_SEARCH_RESULTS,
payload: [],
type: types.SET_MESSAGE_MINIMUM_QUERY,
payload: false,
},
{
type: types.SEARCHED_SUCCESSFULLY_NO_RESULTS,
},
{
type: types.DECREMENT_PROJECT_SEARCH_COUNT,
......@@ -499,20 +428,30 @@ describe('actions', () => {
done,
);
});
});
describe('setInputValue', () => {
it('sets input value', done => {
const mockValue = 'mock-value';
it('commits the SEARCHED_WITH_API_ERROR type (among others) if the search API returns an error code', done => {
store.state.searchQuery = mockQuery;
mockAxios.onAny().replyOnce(500);
testAction(
actions.setInputValue,
mockValue,
null,
actions.searchProjects,
mockQuery,
store.state,
[
{
type: types.SET_INPUT_VALUE,
payload: mockValue,
type: types.INCREMENT_PROJECT_SEARCH_COUNT,
payload: 1,
},
{
type: types.SET_MESSAGE_MINIMUM_QUERY,
payload: false,
},
{
type: types.SEARCHED_WITH_API_ERROR,
},
{
type: types.DECREMENT_PROJECT_SEARCH_COUNT,
payload: 1,
},
],
[],
......
......@@ -4,8 +4,7 @@ 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;
......@@ -14,11 +13,14 @@ describe('mutations', () => {
localState = state();
});
describe('ADD_PROJECT_TOKEN', () => {
it('adds project token to projectTokens', () => {
mutations[types.ADD_PROJECT_TOKEN](localState, oneProject);
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);
});
expect(localState.projectTokens[0]).toEqual(oneProject);
expect(localState.searchCount).toBe(2);
});
});
......@@ -32,17 +34,6 @@ describe('mutations', () => {
});
});
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);
});
expect(localState.searchCount).toBe(2);
});
});
describe('SET_PROJECT_ENDPOINT_LIST', () => {
it('sets project list endpoint', () => {
mutations[types.SET_PROJECT_ENDPOINT_LIST](localState, mockEndpoint);
......@@ -59,14 +50,6 @@ describe('mutations', () => {
});
});
describe('SET_PROJECT_SEARCH_RESULTS', () => {
it('sets project search results', () => {
mutations[types.SET_PROJECT_SEARCH_RESULTS](localState, projects);
expect(localState.projectSearchResults).toEqual(projects);
});
});
describe('SET_PROJECTS', () => {
it('sets projects', () => {
mutations[types.SET_PROJECTS](localState, projects);
......@@ -76,12 +59,174 @@ describe('mutations', () => {
});
});
describe('REMOVE_PROJECT_TOKEN_AT', () => {
it('removes project token', () => {
localState.projectTokens = projects;
mutations[types.REMOVE_PROJECT_TOKEN_AT](localState, oneProject.id);
describe('TOGGLE_IS_LOADING_PROJECTS', () => {
it('toggles the isLoadingProjects boolean', () => {
mutations[types.TOGGLE_IS_LOADING_PROJECTS](localState);
expect(localState.isLoadingProjects).toEqual(true);
mutations[types.TOGGLE_IS_LOADING_PROJECTS](localState);
expect(localState.isLoadingProjects).toEqual(false);
});
});
describe('SET_MESSAGE_MINIMUM_QUERY', () => {
it('sets the messages.minimumQuery boolean', () => {
mutations[types.SET_MESSAGE_MINIMUM_QUERY](localState, true);
expect(localState.messages.minimumQuery).toEqual(true);
mutations[types.SET_MESSAGE_MINIMUM_QUERY](localState, false);
expect(localState.messages.minimumQuery).toEqual(false);
});
});
describe('ADD_SELECTED_PROJECT', () => {
it('adds a project to the list of selected projects', () => {
mutations[types.ADD_SELECTED_PROJECT](localState, projects[0]);
expect(localState.selectedProjects).toEqual([projects[0]]);
});
});
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('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('SEARCHED_WITH_NO_QUERY', () => {
it(`resets all messages and sets state.projectSearchResults to an empty array`, () => {
localState.projectSearchResults = [{ id: 1 }];
localState.messages = {
noResults: true,
searchError: true,
minimumQuery: true,
};
mutations[types.SEARCHED_WITH_NO_QUERY](localState);
expect(localState.projectSearchResults).toEqual([]);
expect(localState.messages.noResults).toBe(false);
expect(localState.messages.searchError).toBe(false);
expect(localState.messages.minimumQuery).toBe(false);
});
});
describe('SEARCHED_WITH_TOO_SHORT_QUERY', () => {
it(`sets the appropriate messages and sets state.projectSearchResults to an empty array`, () => {
localState.projectSearchResults = [{ id: 1 }];
localState.messages = {
noResults: true,
searchError: true,
minimumQuery: false,
};
mutations[types.SEARCHED_WITH_TOO_SHORT_QUERY](localState);
expect(localState.projectSearchResults).toEqual([]);
expect(localState.messages.noResults).toBe(false);
expect(localState.messages.searchError).toBe(false);
expect(localState.messages.minimumQuery).toBe(true);
});
});
describe('SEARCHED_WITH_API_ERROR', () => {
it(`sets the appropriate messages and sets state.projectSearchResults to an empty array`, () => {
localState.projectSearchResults = [{ id: 1 }];
localState.messages = {
noResults: true,
searchError: false,
minimumQuery: true,
};
mutations[types.SEARCHED_WITH_API_ERROR](localState);
expect(localState.projectSearchResults).toEqual([]);
expect(localState.messages.noResults).toBe(false);
expect(localState.messages.searchError).toBe(true);
expect(localState.messages.minimumQuery).toBe(false);
});
});
describe('SEARCHED_SUCCESSFULLY_WITH_RESULTS', () => {
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.SEARCHED_SUCCESSFULLY_WITH_RESULTS](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);
});
});
describe('SEARCHED_SUCCESSFULLY_NO_RESULTS', () => {
it(`sets the appropriate messages and sets state.projectSearchResults to an empty array`, () => {
localState.projectSearchResults = [{ id: 1 }];
localState.messages = {
noResults: false,
searchError: true,
minimumQuery: true,
};
mutations[types.SEARCHED_SUCCESSFULLY_NO_RESULTS](localState);
expect(localState.projectSearchResults).toEqual([]);
expect(localState.messages.noResults).toBe(true);
expect(localState.messages.searchError).toBe(false);
expect(localState.projectTokens.length).toBe(0);
expect(localState.messages.minimumQuery).toBe(false);
});
});
......
......@@ -44,9 +44,6 @@ msgstr ""
msgid " or <#issue id>"
msgstr ""
msgid "\"%{query}\" in projects"
msgstr ""
msgid "%d comment"
msgid_plural "%d comments"
msgstr[0] ""
......@@ -584,9 +581,6 @@ msgstr ""
msgid "Add new directory"
msgstr ""
msgid "Add projects"
msgstr ""
msgid "Add reaction"
msgstr ""
......@@ -3865,6 +3859,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 ""
......@@ -7422,6 +7419,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 ""
......@@ -9751,6 +9757,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 ""
......@@ -11648,9 +11657,6 @@ msgstr ""
msgid "View details: %{details_url}"
msgstr ""
msgid "View documentation"
msgstr ""
msgid "View eligible approvers"
msgstr ""
......
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