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> <script>
import _ from 'underscore';
import { mapState, mapActions } from 'vuex'; import { mapState, mapActions } from 'vuex';
import { GlLoadingIcon, GlDashboardSkeleton } from '@gitlab/ui'; import { GlLoadingIcon, GlModal, GlModalDirective, GlButton } from '@gitlab/ui';
import ProjectSearch from 'ee/vue_shared/dashboards/components/project_search.vue'; import ProjectSelector from '~/vue_shared/components/project_selector/project_selector.vue';
import DashboardProject from './project.vue'; import DashboardProject from './project.vue';
export default { export default {
components: { components: {
DashboardProject, DashboardProject,
ProjectSearch, GlModal,
GlLoadingIcon, GlLoadingIcon,
GlDashboardSkeleton, GlButton,
ProjectSelector,
},
directives: {
'gl-modal': GlModalDirective,
}, },
props: { props: {
addPath: { addPath: {
...@@ -29,10 +34,23 @@ export default { ...@@ -29,10 +34,23 @@ export default {
required: true, required: true,
}, },
}, },
modalId: 'add-projects-modal',
computed: { computed: {
...mapState(['projects', 'projectTokens', 'isLoadingProjects']), ...mapState([
addIsDisabled() { 'projects',
return !this.projectTokens.length; 'projectTokens',
'isLoadingProjects',
'selectedProjects',
'projectSearchResults',
'searchCount',
'searchQuery',
'messages',
]),
isSearchingProjects() {
return this.searchCount > 0;
},
okDisabled() {
return _.isEmpty(this.selectedProjects);
}, },
}, },
created() { created() {
...@@ -43,11 +61,33 @@ export default { ...@@ -43,11 +61,33 @@ export default {
this.fetchProjects(); this.fetchProjects();
}, },
methods: { methods: {
...mapActions(['addProjectsToDashboard', 'fetchProjects', 'setProjectEndpoints']), ...mapActions([
'searchProjects',
'addProjectsToDashboard',
'fetchProjects',
'setProjectEndpoints',
'clearSearchResults',
'toggleSelectedProject',
'setSearchQuery',
]),
addProjects() { addProjects() {
if (!this.addIsDisabled) { this.addProjectsToDashboard();
this.addProjectsToDashboard(); },
} onModalShown() {
this.$refs.projectSelector.focusSearchInput();
},
onModalHidden() {
this.clearSearchResults();
},
onOk() {
this.addProjectsToDashboard();
},
searched(query) {
this.setSearchQuery(query);
this.searchProjects();
},
projectClicked(project) {
this.toggleSelectedProject(project);
}, },
}, },
}; };
...@@ -55,23 +95,41 @@ export default { ...@@ -55,23 +95,41 @@ export default {
<template> <template>
<div class="operations-dashboard"> <div class="operations-dashboard">
<div <gl-modal
class="page-title-holder flex-fill d-flex flex-column flex-md-row align-items-md-end align-items-stretch" :modal-id="$options.modalId"
:title="s__('OperationsDashboard|Add projects')"
:ok-title="s__('OperationsDashboard|Add projects')"
:ok-disabled="okDisabled"
ok-variant="success"
@shown="onModalShown"
@hidden="onModalHidden"
@ok="onOk"
> >
<div class="flex-fill append-right-20"> <project-selector
<h1 class="js-dashboard-title page-title text-nowrap">{{ __('Operations Dashboard') }}</h1> ref="projectSelector"
</div> :project-search-results="projectSearchResults"
<div class="d-flex flex-fill align-items-end append-bottom-default"> :selected-projects="selectedProjects"
<project-search class="flex-grow-1" /> :show-no-results-message="messages.noResults"
<button :show-loading-indicator="isSearchingProjects"
:class="{ disabled: addIsDisabled }" :show-minimum-search-query-message="messages.minimumQuery"
type="button" :show-search-error-message="messages.searchError"
class="js-add-projects-button btn btn-success prepend-left-8" @searched="searched"
@click="addProjects" @projectClicked="projectClicked"
> />
{{ __('Add projects') }} </gl-modal>
</button>
</div> <div class="page-title-holder flex-fill d-flex align-items-center">
<h1 class="js-dashboard-title page-title text-nowrap flex-fill">
{{ s__('OperationsDashboard|Operations Dashboard') }}
</h1>
<gl-button
v-if="projects.length"
v-gl-modal="$options.modalId"
type="button"
class="js-add-projects-button btn btn-success"
>
{{ s__('OperationsDashboard|Add projects') }}
</gl-button>
</div> </div>
<div class="prepend-top-default"> <div class="prepend-top-default">
<div v-if="projects.length" class="row prepend-top-default dashboard-cards"> <div v-if="projects.length" class="row prepend-top-default dashboard-cards">
...@@ -92,15 +150,19 @@ export default { ...@@ -92,15 +150,19 @@ export default {
s__(`OperationsDashboard|The operations dashboard provides a summary of each project's s__(`OperationsDashboard|The operations dashboard provides a summary of each project's
operational health, including pipeline and alert statuses.`) operational health, including pipeline and alert statuses.`)
}} }}
<a :href="emptyDashboardHelpPath" class="js-documentation-link">
{{ s__('OperationsDashboard|More information') }}
</a>
</span> </span>
</div> </div>
<div class="col-12"> <div class="col-12">
<a <gl-button
:href="emptyDashboardHelpPath" v-gl-modal="$options.modalId"
class="js-documentation-link btn btn-primary prepend-top-default append-bottom-default" type="button"
class="js-add-projects-button btn btn-success prepend-top-default append-bottom-default"
> >
{{ __('View documentation') }} {{ s__('OperationsDashboard|Add projects') }}
</a> </gl-button>
</div> </div>
</div> </div>
<gl-dashboard-skeleton v-else /> <gl-dashboard-skeleton v-else />
......
...@@ -5,6 +5,9 @@ import Poll from '~/lib/utils/poll'; ...@@ -5,6 +5,9 @@ import Poll from '~/lib/utils/poll';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { __, s__, n__, sprintf } from '~/locale'; import { __, s__, n__, sprintf } from '~/locale';
import * as types from './mutation_types'; import * as types from './mutation_types';
import _ from 'underscore';
const API_MINIMUM_QUERY_LENGTH = 3;
let eTagPoll; let eTagPoll;
...@@ -24,32 +27,29 @@ export const forceProjectsRequest = () => { ...@@ -24,32 +27,29 @@ export const forceProjectsRequest = () => {
export const addProjectsToDashboard = ({ state, dispatch }) => { export const addProjectsToDashboard = ({ state, dispatch }) => {
axios axios
.post(state.projectEndpoints.add, { .post(state.projectEndpoints.add, {
project_ids: state.projectTokens.map(project => project.id), project_ids: state.selectedProjects.map(p => p.id),
}) })
.then(response => dispatch('requestAddProjectsToDashboardSuccess', response.data)) .then(response => dispatch('requestAddProjectsToDashboardSuccess', response.data))
.catch(() => dispatch('requestAddProjectsToDashboardError')); .catch(() => dispatch('requestAddProjectsToDashboardError'));
}; };
export const clearInputValue = ({ commit }) => { export const toggleSelectedProject = ({ commit, state }, project) => {
commit(types.SET_INPUT_VALUE, ''); if (!_.findWhere(state.selectedProjects, { id: project.id })) {
}; commit(types.ADD_SELECTED_PROJECT, project);
} else {
export const clearProjectTokens = ({ commit }) => { commit(types.REMOVE_SELECTED_PROJECT, project);
commit(types.SET_PROJECT_TOKENS, []); }
}; };
export const filterProjectTokensById = ({ commit, state }, ids) => { export const clearSearchResults = ({ commit }) => {
const tokens = state.projectTokens.filter(token => ids.includes(token.id)); commit(types.CLEAR_SEARCH_RESULTS);
commit(types.SET_PROJECT_TOKENS, tokens);
}; };
export const requestAddProjectsToDashboardSuccess = ({ dispatch, state }, data) => { export const requestAddProjectsToDashboardSuccess = ({ dispatch, state }, data) => {
const { added, invalid } = data; const { added, invalid } = data;
dispatch('clearInputValue');
if (invalid.length) { if (invalid.length) {
const projectNames = state.projectTokens.reduce((accumulator, project) => { const projectNames = state.selectedProjects.reduce((accumulator, project) => {
if (invalid.includes(project.id)) { if (invalid.includes(project.id)) {
accumulator.push(project.name); accumulator.push(project.name);
} }
...@@ -73,9 +73,6 @@ export const requestAddProjectsToDashboardSuccess = ({ dispatch, state }, data) ...@@ -73,9 +73,6 @@ export const requestAddProjectsToDashboardSuccess = ({ dispatch, state }, data)
}, },
), ),
); );
dispatch('filterProjectTokensById', invalid);
} else {
dispatch('clearProjectTokens');
} }
if (added.length) { if (added.length) {
...@@ -86,20 +83,11 @@ export const requestAddProjectsToDashboardSuccess = ({ dispatch, state }, data) ...@@ -86,20 +83,11 @@ export const requestAddProjectsToDashboardSuccess = ({ dispatch, state }, data)
export const requestAddProjectsToDashboardError = ({ state }) => { export const requestAddProjectsToDashboardError = ({ state }) => {
createFlash( createFlash(
sprintf(__('Something went wrong, unable to add %{project} to dashboard'), { sprintf(__('Something went wrong, unable to add %{project} to dashboard'), {
project: n__('project', 'projects', state.projectTokens.length), project: n__('project', 'projects', state.selectedProjects.length),
}), }),
); );
}; };
export const addProjectToken = ({ commit }, project) => {
commit(types.ADD_PROJECT_TOKEN, project);
commit(types.SET_INPUT_VALUE, '');
};
export const clearProjectSearchResults = ({ commit }) => {
commit(types.SET_PROJECT_SEARCH_RESULTS, []);
};
export const fetchProjects = ({ state, dispatch }) => { export const fetchProjects = ({ state, dispatch }) => {
if (eTagPoll) return; if (eTagPoll) return;
...@@ -155,25 +143,38 @@ export const requestRemoveProjectError = () => { ...@@ -155,25 +143,38 @@ export const requestRemoveProjectError = () => {
createFlash(__('Something went wrong, unable to remove project')); createFlash(__('Something went wrong, unable to remove project'));
}; };
export const removeProjectTokenAt = ({ commit }, index) => { export const setSearchQuery = ({ commit }, query) => {
commit(types.REMOVE_PROJECT_TOKEN_AT, index); commit(types.SET_SEARCH_QUERY, query);
};
export const searchProjects = ({ commit }, query) => {
commit(types.INCREMENT_PROJECT_SEARCH_COUNT, 1);
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 setInputValue = ({ commit }, value) => { export const searchProjects = ({ commit, state }) => {
commit(types.SET_INPUT_VALUE, value); 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);
// 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 => {
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(() => {
commit(types.SEARCHED_WITH_API_ERROR);
commit(types.DECREMENT_PROJECT_SEARCH_COUNT, 1);
});
}
}; };
export const setProjectEndpoints = ({ commit }, endpoints) => { export const setProjectEndpoints = ({ commit }, endpoints) => {
......
export const ADD_PROJECT_TOKEN = 'ADD_PROJECT_TOKEN';
export const INCREMENT_PROJECT_SEARCH_COUNT = 'INCREMENT_PROJECT_SEARCH_COUNT'; export const INCREMENT_PROJECT_SEARCH_COUNT = 'INCREMENT_PROJECT_SEARCH_COUNT';
export const DECREMENT_PROJECT_SEARCH_COUNT = 'DECREMENT_PROJECT_SEARCH_COUNT'; export const DECREMENT_PROJECT_SEARCH_COUNT = 'DECREMENT_PROJECT_SEARCH_COUNT';
export const SET_INPUT_VALUE = 'SET_INPUT_VALUE';
export const SET_PROJECT_ENDPOINT_LIST = 'SET_PROJECT_ENDPOINT_LIST'; export const SET_PROJECT_ENDPOINT_LIST = 'SET_PROJECT_ENDPOINT_LIST';
export const SET_PROJECT_ENDPOINT_ADD = 'SET_PROJECT_ENDPOINT_ADD'; export const SET_PROJECT_ENDPOINT_ADD = 'SET_PROJECT_ENDPOINT_ADD';
export const SET_PROJECT_SEARCH_RESULTS = 'SET_PROJECT_SEARCH_RESULTS';
export const SET_PROJECTS = 'SET_PROJECTS'; export const SET_PROJECTS = 'SET_PROJECTS';
export const SET_PROJECT_TOKENS = 'SET_PROJECT_TOKENS'; export const SET_SEARCH_QUERY = 'SET_SEARCH_QUERY';
export const REMOVE_PROJECT_TOKEN_AT = 'REMOVE_PROJECT_TOKEN_AT'; export const REMOVE_PROJECT_TOKEN_AT = 'REMOVE_PROJECT_TOKEN_AT';
export const REQUEST_PROJECTS = 'REQUEST_PROJECTS'; 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'; import * as types from './mutation_types';
export default { export default {
[types.ADD_PROJECT_TOKEN](state, project) {
const projectsWithMatchingId = state.projectTokens.filter(token => token.id === project.id);
if (!projectsWithMatchingId.length) {
state.projectTokens.push(project);
}
},
[types.DECREMENT_PROJECT_SEARCH_COUNT](state, value) { [types.DECREMENT_PROJECT_SEARCH_COUNT](state, value) {
state.searchCount -= value; state.searchCount -= value;
}, },
[types.INCREMENT_PROJECT_SEARCH_COUNT](state, value) { [types.INCREMENT_PROJECT_SEARCH_COUNT](state, value) {
state.searchCount += value; state.searchCount += value;
}, },
[types.SET_INPUT_VALUE](state, value) {
state.inputValue = value;
},
[types.SET_PROJECT_ENDPOINT_LIST](state, url) { [types.SET_PROJECT_ENDPOINT_LIST](state, url) {
state.projectEndpoints.list = url; state.projectEndpoints.list = url;
}, },
[types.SET_PROJECT_ENDPOINT_ADD](state, url) { [types.SET_PROJECT_ENDPOINT_ADD](state, url) {
state.projectEndpoints.add = url; state.projectEndpoints.add = url;
}, },
[types.SET_PROJECT_SEARCH_RESULTS](state, results) {
state.projectSearchResults = results;
},
[types.SET_PROJECTS](state, projects) { [types.SET_PROJECTS](state, projects) {
state.projects = projects || []; state.projects = projects || [];
state.isLoadingProjects = false; state.isLoadingProjects = false;
}, },
[types.SET_PROJECT_TOKENS](state, tokens) { [types.SET_SEARCH_QUERY](state, query) {
state.projectTokens = tokens; state.searchQuery = query;
}, },
[types.REMOVE_PROJECT_TOKEN_AT](state, index) {
state.projectTokens.splice(index, 1); [types.SET_MESSAGE_MINIMUM_QUERY](state, bool) {
state.messages.minimumQuery = bool;
}, },
[types.REQUEST_PROJECTS](state) { [types.REQUEST_PROJECTS](state) {
state.isLoadingProjects = true; 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 () => ({ ...@@ -5,8 +5,14 @@ export default () => ({
list: null, list: null,
add: null, add: null,
}, },
searchQuery: '',
projects: [], projects: [],
projectTokens: [],
projectSearchResults: [], projectSearchResults: [],
selectedProjects: [],
messages: {
noResults: false,
searchError: false,
minimumQuery: false,
},
searchCount: 0, searchCount: 0,
}); });
<script>
import { mapState, mapActions } from 'vuex';
import Icon from '~/vue_shared/components/icon.vue';
import inputFocus from '../mixins';
export default {
components: {
Icon,
},
mixins: [inputFocus],
computed: {
...mapState(['inputValue', 'projectTokens']),
localInputValue: {
get() {
return this.inputValue;
},
set(newValue) {
this.setInputValue(newValue);
},
},
},
methods: {
...mapActions(['setInputValue', 'removeProjectTokenAt']),
focusInput() {
this.$refs.input.focus();
},
},
};
</script>
<template>
<div
:class="{ focus: isInputFocused }"
class="form-control tokenized-input-wrapper d-flex flex-wrap align-items-center"
@click="focusInput"
>
<div v-for="(token, index) in projectTokens" :key="token.id" class="d-flex" @click.stop>
<div class="js-input-token input-token text-secondary py-0 pl-2 pr-1 rounded-left">
{{ token.name_with_namespace }}
</div>
<div
class="js-token-remove tokenized-input-token-remove d-flex align-items-center text-secondary py-0 px-1 rounded-right"
@click="removeProjectTokenAt(index)"
>
<icon name="close" />
</div>
</div>
<div class="d-flex align-items-center flex-grow-1">
<input
ref="input"
v-model="localInputValue"
:placeholder="__('Search your projects')"
class="tokenized-input flex-grow-1"
type="text"
@focus="onFocus"
@blur="onBlur"
/>
<icon name="search" class="text-secondary" />
</div>
</div>
</template>
<script>
import { mapState, mapActions } from 'vuex';
import _ from 'underscore';
import { __, sprintf } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
import ProjectAvatar from '~/vue_shared/components/project_avatar/default.vue';
import { GlLoadingIcon } from '@gitlab/ui';
import TokenizedInput from './input.vue';
import inputFocus from '../mixins';
const inputSearchDelay = 300;
export default {
components: {
Icon,
ProjectAvatar,
TokenizedInput,
GlLoadingIcon,
},
mixins: [inputFocus],
data() {
return {
hasSearchedInput: false,
};
},
computed: {
...mapState(['inputValue', 'projectTokens', 'projectSearchResults', 'searchCount']),
isSearchingProjects() {
return this.searchCount > 0;
},
searchDescription() {
return sprintf(__('"%{query}" in projects'), { query: this.inputValue });
},
shouldShowSearch() {
return this.inputValue.length && this.isInputFocused;
},
foundNoResults() {
return !this.projectSearchResults.length && this.hasSearchedInput;
},
},
watch: {
inputValue() {
this.queryInputInProjects();
},
},
methods: {
...mapActions(['addProjectToken', 'searchProjects', 'clearProjectSearchResults']),
queryInputInProjects: _.debounce(function search() {
this.searchProjects(this.inputValue);
this.hasSearchedInput = true;
}, inputSearchDelay),
},
};
</script>
<template>
<div :class="{ show: shouldShowSearch }" class="dropdown">
<tokenized-input @focus="onFocus" @blur="onBlur" />
<div class="js-search-results dropdown-menu w-100 mw-100" @mousedown.prevent>
<div class="py-2 px-4 text-tertiary"><icon name="search" /> {{ searchDescription }}</div>
<div class="dropdown-divider"></div>
<gl-loading-icon v-if="isSearchingProjects" :size="2" class="py-2 px-4" />
<div v-else-if="foundNoResults" class="py-2 px-4 text-tertiary">
{{ __('Sorry, no projects matched your search') }}
</div>
<button
v-for="project in projectSearchResults"
:key="project.id"
type="button"
class="js-search-result dropdown-item btn-link d-flex align-items-center cgray py-2 px-4"
@mousedown="addProjectToken(project)"
>
<project-avatar :project="project" :size="20" class="flex-shrink-0 mr-3" />
<div class="flex-grow-1">
<div class="js-name-with-namespace bold ws-initial">
{{ project.name_with_namespace }}
</div>
</div>
</button>
</div>
</div>
</template>
---
title: Move project search bar into modal dialog on Operations Dashboard page
merge_request: 9260
author:
type: changed
This diff is collapsed.
import Vue from 'vue'; import Vue from 'vue';
import store from 'ee/operations/store/index'; import store from 'ee/operations/store/index';
import Dashboard from 'ee/operations/components/dashboard/dashboard.vue'; import Dashboard from 'ee/operations/components/dashboard/dashboard.vue';
import ProjectSearch from 'ee/vue_shared/dashboards/components/project_search.vue';
import DashboardProject from 'ee/operations/components/dashboard/project.vue'; import DashboardProject from 'ee/operations/components/dashboard/project.vue';
import { trimText } from 'spec/helpers/vue_component_helper';
import { getChildInstances, clearState } from '../../helpers'; import { getChildInstances, clearState } from '../../helpers';
import { mockProjectData, mockText } from '../../mock_data'; import { mockProjectData, mockText } from '../../mock_data';
describe('dashboard component', () => { describe('dashboard component', () => {
const DashboardComponent = Vue.extend(Dashboard); const DashboardComponent = Vue.extend(Dashboard);
const ProjectSearchComponent = Vue.extend(ProjectSearch);
const DashboardProjectComponent = Vue.extend(DashboardProject); const DashboardProjectComponent = Vue.extend(DashboardProject);
const projectTokens = mockProjectData(1);
const mount = () => const mount = () =>
new DashboardComponent({ new DashboardComponent({
store, store,
...@@ -52,29 +50,14 @@ describe('dashboard component', () => { ...@@ -52,29 +50,14 @@ describe('dashboard component', () => {
expect(button.innerText.trim()).toBe(mockText.ADD_PROJECTS); expect(button.innerText.trim()).toBe(mockText.ADD_PROJECTS);
}); });
it('calls action to add projects on click if projectTokens have been added', () => { it('opens the Add Projects modal', () => {
const spy = spyOn(vm, 'addProjectsToDashboard').and.stub();
vm.$store.state.projectTokens = projectTokens;
button.click(); button.click();
expect(spy).toHaveBeenCalled(); expect(vm.$el.querySelector('.add-projects-modal')).toBeDefined();
});
it('does not call action to add projects on click when projectTokens is empty', () => {
const spy = spyOn(vm, 'addProjectsToDashboard').and.stub();
button.click();
expect(spy).not.toHaveBeenCalled();
}); });
}); });
describe('wrapped components', () => { describe('wrapped components', () => {
describe('project search component', () => {
it('renders project search component', () => {
expect(getChildInstances(vm, ProjectSearchComponent).length).toBe(1);
});
});
describe('dashboard project component', () => { describe('dashboard project component', () => {
const projectCount = 1; const projectCount = 1;
const projects = mockProjectData(projectCount); const projects = mockProjectData(projectCount);
...@@ -113,7 +96,7 @@ describe('dashboard component', () => { ...@@ -113,7 +96,7 @@ describe('dashboard component', () => {
}); });
it('renders sub-title', () => { it('renders sub-title', () => {
expect(vm.$el.querySelector('.js-sub-title').innerText.trim()).toBe( expect(trimText(vm.$el.querySelector('.js-sub-title').innerText)).toBe(
mockText.EMPTY_SUBTITLE, mockText.EMPTY_SUBTITLE,
); );
}); });
...@@ -121,7 +104,7 @@ describe('dashboard component', () => { ...@@ -121,7 +104,7 @@ describe('dashboard component', () => {
it('renders link to documentation', () => { it('renders link to documentation', () => {
const link = vm.$el.querySelector('.js-documentation-link'); const link = vm.$el.querySelector('.js-documentation-link');
expect(link.innerText.trim()).toBe('View documentation'); expect(link.innerText.trim()).toBe('More information');
}); });
it('links to documentation', () => { it('links to documentation', () => {
......
import Vue from 'vue';
import store from 'ee/operations/store/index';
import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import ProjectSearch from 'ee/vue_shared/dashboards/components/project_search.vue';
import TokenizedInput from 'ee/vue_shared/dashboards/components/input.vue';
import { mockText, mockProjectData } from '../../mock_data';
import { getChildInstances, mouseEvent, clearState } from '../../helpers';
describe('project search component', () => {
const ProjectSearchComponent = Vue.extend(ProjectSearch);
const TokenizedInputComponent = Vue.extend(TokenizedInput);
const mockProjects = mockProjectData(1);
const [mockOneProject] = mockProjects;
const mockInputValue = 'mock-inputValue';
const mount = () =>
mountComponentWithStore(ProjectSearchComponent, {
store,
});
let vm;
beforeEach(() => {
vm = mount();
});
afterEach(() => {
vm.$destroy();
clearState(store);
});
describe('dropdown menu', () => {
it('renders dropdown menu when input gains focus', done => {
vm.$store.dispatch('setInputValue', mockInputValue);
vm.isInputFocused = true;
vm.$nextTick(() => {
expect(vm.$el.classList.contains('show')).toBe(true);
expect(vm.$el.querySelector('.js-search-results')).not.toBeNull();
done();
});
});
it('does not render when input is not focused', () => {
vm.$store.dispatch('setInputValue', mockInputValue);
expect(vm.$el.classList.contains('show')).toBe(false);
});
it('does not render when input value is empty', () => {
vm.isInputFocused = true;
expect(vm.$el.classList.contains('show')).toBe(false);
});
it('renders search icon', () => {
expect(vm.$el.querySelector('.ic-search')).not.toBe(null);
});
it('renders search description', () => {
store.state.inputValue = mockInputValue;
vm = mountComponentWithStore(ProjectSearchComponent, {
store,
});
expect(vm.$el.querySelector('.js-search-results').innerText.trim()).toBe(
`"${mockInputValue}" ${mockText.SEARCH_DESCRIPTION_SUFFIX}`,
);
});
it('renders no search results after searching input with no matches', done => {
vm.hasSearchedInput = true;
vm.$nextTick(() => {
expect(
vm.$el
.querySelector('.js-search-results')
.innerText.trim()
.slice(-mockText.NO_SEARCH_RESULTS.length),
).toBe(mockText.NO_SEARCH_RESULTS);
done();
});
});
it('renders loading icon when searching', () => {
store.state.searchCount = 1;
vm = mount();
expect(vm.$el).toContainElement('.loading-container');
});
it('renders search results', () => {
store.state.projectSearchResults = mockProjects;
vm = mount();
expect(vm.$el.getElementsByClassName('js-search-result').length).toBe(mockProjects.length);
});
});
it('searches projects when input value changes', done => {
const spy = spyOn(vm, 'queryInputInProjects');
vm.$store.dispatch('setInputValue', mockInputValue);
vm.$nextTick(() => {
expect(spy).toHaveBeenCalled();
done();
});
});
describe('project search item', () => {
let item;
beforeEach(() => {
store.state.projectSearchResults = mockProjects;
vm = mount();
item = vm.$el.querySelector('.js-search-result');
});
it('renders project name with namespace', () => {
expect(item.querySelector('.js-name-with-namespace').innerText.trim()).toBe(
mockOneProject.name_with_namespace,
);
});
it('calls action to add project token on mousedown', done => {
const spy = spyOn(vm.$store, 'dispatch');
mouseEvent(item, 'mousedown');
vm.$nextTick(() => {
expect(spy).toHaveBeenCalledWith('addProjectToken', mockOneProject);
done();
});
});
});
describe('wrapped components', () => {
describe('tokenized input', () => {
const getInput = parent => getChildInstances(parent, TokenizedInputComponent)[0];
it('renders', () => {
expect(getChildInstances(vm, TokenizedInputComponent).length).toBe(1);
});
it('handles focus', () => {
getInput(vm).$emit('focus');
expect(vm.isInputFocused).toBe(true);
});
it('handles blur', () => {
getInput(vm).$emit('blur');
expect(vm.isInputFocused).toBe(false);
});
});
describe('project avatar', () => {
let avatars;
beforeEach(() => {
store.state.projectSearchResults = mockProjects;
vm = mount();
avatars = vm.$el.querySelectorAll('.project-avatar');
});
it('renders project avatar component', () => {
expect(avatars.length).toBe(1);
});
it('binds project to project', () => {
const [avatar] = avatars;
const identicon = avatar.querySelector('.identicon');
const [identiconLetter] = mockOneProject.name;
expect(identicon.textContent.trim()).toEqual(identiconLetter.toUpperCase());
});
});
});
});
import Vue from 'vue';
import store from 'ee/operations/store/index';
import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import TokenizedInput from 'ee/vue_shared/dashboards/components/input.vue';
import { clearState } from '../../helpers';
import { mockProjectData } from '../../mock_data';
describe('tokenized input component', () => {
const TokenizedInputComponent = Vue.extend(TokenizedInput);
const mockProjects = mockProjectData(1);
const [mockOneProject] = mockProjects;
const mockInputValue = 'mock-inputValue';
let vm;
const getInput = () => vm.$refs.input;
beforeEach(() => {
store.state.projectTokens = mockProjects;
vm = mountComponentWithStore(TokenizedInputComponent, { store });
});
afterEach(() => {
vm.$destroy();
clearState(store);
});
it('focuses input on click', () => {
const spy = spyOn(getInput(), 'focus');
vm.$el.click();
expect(spy).toHaveBeenCalled();
});
it('renders input token', () => {
expect(vm.$el.querySelector('.js-input-token').innerText.trim()).toBe(
mockOneProject.name_with_namespace,
);
});
it('removes input tokens on click', () => {
const spy = spyOn(vm.$store, 'dispatch');
vm.$el.querySelector('.js-token-remove').click();
expect(spy).toHaveBeenCalledWith('removeProjectTokenAt', mockOneProject.id);
});
describe('input', () => {
it('updates input value when local value changes', done => {
vm.localInputValue = mockInputValue;
vm.$nextTick(() => {
expect(getInput().value).toBe(mockInputValue);
done();
});
});
it('handles focus', () => {
const spy = spyOn(vm, '$emit');
vm.onFocus();
expect(spy).toHaveBeenCalledWith('focus');
});
it('handles blur', () => {
const spy = spyOn(vm, '$emit');
vm.onBlur();
expect(spy).toHaveBeenCalledWith('blur');
});
});
describe('wrapped components', () => {
describe('icon', () => {
it('should render close for input tokens', () => {
expect(vm.$el.querySelectorAll('.ic-close').length).toBe(mockProjects.length);
});
it('should render search', () => {
expect(vm.$el.querySelector('.ic-search')).not.toBe(null);
});
});
});
});
...@@ -7,7 +7,7 @@ export const mockText = { ...@@ -7,7 +7,7 @@ export const mockText = {
DASHBOARD_TITLE: 'Operations Dashboard', DASHBOARD_TITLE: 'Operations Dashboard',
EMPTY_TITLE: 'Add a project to the dashboard', EMPTY_TITLE: 'Add a project to the dashboard',
EMPTY_SUBTITLE: EMPTY_SUBTITLE:
"The operations dashboard provides a summary of each project's operational health, including pipeline and alert statuses.", "The operations dashboard provides a summary of each project's operational health, including pipeline and alert statuses. More information",
EMPTY_SVG_SOURCE: '/assets/illustrations/operations-dashboard_empty.svg', EMPTY_SVG_SOURCE: '/assets/illustrations/operations-dashboard_empty.svg',
NO_SEARCH_RESULTS: 'Sorry, no projects matched your search', NO_SEARCH_RESULTS: 'Sorry, no projects matched your search',
RECEIVE_PROJECTS_ERROR: 'Something went wrong, unable to get operations projects', RECEIVE_PROJECTS_ERROR: 'Something went wrong, unable to get operations projects',
......
...@@ -4,8 +4,7 @@ import * as types from 'ee/operations/store/mutation_types'; ...@@ -4,8 +4,7 @@ import * as types from 'ee/operations/store/mutation_types';
import { mockProjectData } from '../mock_data'; import { mockProjectData } from '../mock_data';
describe('mutations', () => { describe('mutations', () => {
const projects = mockProjectData(1); const projects = mockProjectData(3);
const [oneProject] = projects;
const mockEndpoint = 'https://mock-endpoint'; const mockEndpoint = 'https://mock-endpoint';
const mockSearches = new Array(5).fill(null); const mockSearches = new Array(5).fill(null);
let localState; let localState;
...@@ -14,11 +13,14 @@ describe('mutations', () => { ...@@ -14,11 +13,14 @@ describe('mutations', () => {
localState = state(); localState = state();
}); });
describe('ADD_PROJECT_TOKEN', () => { describe('DECREMENT_PROJECT_SEARCH_COUNT', () => {
it('adds project token to projectTokens', () => { it('removes search from searchCount', () => {
mutations[types.ADD_PROJECT_TOKEN](localState, oneProject); 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', () => { ...@@ -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', () => { describe('SET_PROJECT_ENDPOINT_LIST', () => {
it('sets project list endpoint', () => { it('sets project list endpoint', () => {
mutations[types.SET_PROJECT_ENDPOINT_LIST](localState, mockEndpoint); mutations[types.SET_PROJECT_ENDPOINT_LIST](localState, mockEndpoint);
...@@ -59,14 +50,6 @@ describe('mutations', () => { ...@@ -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', () => { describe('SET_PROJECTS', () => {
it('sets projects', () => { it('sets projects', () => {
mutations[types.SET_PROJECTS](localState, projects); mutations[types.SET_PROJECTS](localState, projects);
...@@ -76,12 +59,174 @@ describe('mutations', () => { ...@@ -76,12 +59,174 @@ describe('mutations', () => {
}); });
}); });
describe('REMOVE_PROJECT_TOKEN_AT', () => { describe('TOGGLE_IS_LOADING_PROJECTS', () => {
it('removes project token', () => { it('toggles the isLoadingProjects boolean', () => {
localState.projectTokens = projects; mutations[types.TOGGLE_IS_LOADING_PROJECTS](localState);
mutations[types.REMOVE_PROJECT_TOKEN_AT](localState, oneProject.id);
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 "" ...@@ -44,9 +44,6 @@ msgstr ""
msgid " or <#issue id>" msgid " or <#issue id>"
msgstr "" msgstr ""
msgid "\"%{query}\" in projects"
msgstr ""
msgid "%d comment" msgid "%d comment"
msgid_plural "%d comments" msgid_plural "%d comments"
msgstr[0] "" msgstr[0] ""
...@@ -584,9 +581,6 @@ msgstr "" ...@@ -584,9 +581,6 @@ msgstr ""
msgid "Add new directory" msgid "Add new directory"
msgstr "" msgstr ""
msgid "Add projects"
msgstr ""
msgid "Add reaction" msgid "Add reaction"
msgstr "" msgstr ""
...@@ -3865,6 +3859,9 @@ msgstr "" ...@@ -3865,6 +3859,9 @@ msgstr ""
msgid "Enforced SSO" msgid "Enforced SSO"
msgstr "" msgstr ""
msgid "Enter at least three characters to search"
msgstr ""
msgid "Enter in your Bitbucket Server URL and personal access token below" msgid "Enter in your Bitbucket Server URL and personal access token below"
msgstr "" msgstr ""
...@@ -7422,6 +7419,15 @@ msgstr "" ...@@ -7422,6 +7419,15 @@ msgstr ""
msgid "OperationsDashboard|Add a project to the dashboard" msgid "OperationsDashboard|Add a project to the dashboard"
msgstr "" msgstr ""
msgid "OperationsDashboard|Add projects"
msgstr ""
msgid "OperationsDashboard|More information"
msgstr ""
msgid "OperationsDashboard|Operations Dashboard"
msgstr ""
msgid "OperationsDashboard|The operations dashboard provides a summary of each project's operational health, including pipeline and alert statuses." msgid "OperationsDashboard|The operations dashboard provides a summary of each project's operational health, including pipeline and alert statuses."
msgstr "" msgstr ""
...@@ -9751,6 +9757,9 @@ msgstr "" ...@@ -9751,6 +9757,9 @@ msgstr ""
msgid "Something went wrong, unable to remove project" msgid "Something went wrong, unable to remove project"
msgstr "" msgstr ""
msgid "Something went wrong, unable to search projects"
msgstr ""
msgid "Something went wrong. Please try again." msgid "Something went wrong. Please try again."
msgstr "" msgstr ""
...@@ -11648,9 +11657,6 @@ msgstr "" ...@@ -11648,9 +11657,6 @@ msgstr ""
msgid "View details: %{details_url}" msgid "View details: %{details_url}"
msgstr "" msgstr ""
msgid "View documentation"
msgstr ""
msgid "View eligible approvers" msgid "View eligible approvers"
msgstr "" msgstr ""
......
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