Commit 76f97a52 authored by Fatih Acet's avatar Fatih Acet

Merge branch...

Merge branch '31309-add-ability-for-users-to-organize-projects-on-the-operations-dashboard' into 'master'

Can organize projects on operations dashboard

See merge request gitlab-org/gitlab!18855
parents 11124412 5829d104
---
title: Add ability to reorder projects on operations dashboard
merge_request: 18855
author:
type: added
...@@ -25,6 +25,10 @@ last commit, pipeline status, and when it was last deployed. ...@@ -25,6 +25,10 @@ last commit, pipeline status, and when it was last deployed.
![Operations Dashboard with projects](img/index_operations_dashboard_with_projects.png) ![Operations Dashboard with projects](img/index_operations_dashboard_with_projects.png)
## Arranging projects on a dashboard
You can drag project cards to change their order. The card order is currently only saved to your browser, so will not change the dashboard for other people.
## Making it the default dashboard when you sign in ## Making it the default dashboard when you sign in
The Operations Dashboard can also be made the default GitLab dashboard shown when The Operations Dashboard can also be made the default GitLab dashboard shown when
......
...@@ -8,6 +8,7 @@ import { ...@@ -8,6 +8,7 @@ import {
GlButton, GlButton,
GlDashboardSkeleton, GlDashboardSkeleton,
} from '@gitlab/ui'; } from '@gitlab/ui';
import VueDraggable from 'vuedraggable';
import ProjectSelector from '~/vue_shared/components/project_selector/project_selector.vue'; import ProjectSelector from '~/vue_shared/components/project_selector/project_selector.vue';
import DashboardProject from './project.vue'; import DashboardProject from './project.vue';
...@@ -19,6 +20,7 @@ export default { ...@@ -19,6 +20,7 @@ export default {
GlLoadingIcon, GlLoadingIcon,
GlButton, GlButton,
ProjectSelector, ProjectSelector,
VueDraggable,
}, },
directives: { directives: {
'gl-modal': GlModalDirective, 'gl-modal': GlModalDirective,
...@@ -44,15 +46,20 @@ export default { ...@@ -44,15 +46,20 @@ export default {
modalId: 'add-projects-modal', modalId: 'add-projects-modal',
computed: { computed: {
...mapState([ ...mapState([
'projects',
'projectTokens',
'isLoadingProjects', 'isLoadingProjects',
'selectedProjects', 'selectedProjects',
'projectSearchResults', 'projectSearchResults',
'searchCount', 'searchCount',
'searchQuery',
'messages', 'messages',
]), ]),
projects: {
get() {
return this.$store.state.projects;
},
set(projects) {
this.setProjects(projects);
},
},
isSearchingProjects() { isSearchingProjects() {
return this.searchCount > 0; return this.searchCount > 0;
}, },
...@@ -76,6 +83,7 @@ export default { ...@@ -76,6 +83,7 @@ export default {
'clearSearchResults', 'clearSearchResults',
'toggleSelectedProject', 'toggleSelectedProject',
'setSearchQuery', 'setSearchQuery',
'setProjects',
]), ]),
addProjects() { addProjects() {
this.addProjectsToDashboard(); this.addProjectsToDashboard();
...@@ -136,11 +144,16 @@ export default { ...@@ -136,11 +144,16 @@ export default {
</gl-button> </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"> <vue-draggable
v-if="projects.length"
v-model="projects"
group="dashboard-projects"
class="row prepend-top-default dashboard-cards"
>
<div v-for="project in projects" :key="project.id" class="col-12 col-md-6 col-xl-4 px-2"> <div v-for="project in projects" :key="project.id" class="col-12 col-md-6 col-xl-4 px-2">
<dashboard-project :project="project" /> <dashboard-project :project="project" />
</div> </div>
</div> </vue-draggable>
<div v-else-if="!isLoadingProjects" class="row prepend-top-20 text-center"> <div v-else-if="!isLoadingProjects" class="row prepend-top-20 text-center">
<div class="col-12 d-flex justify-content-center svg-content"> <div class="col-12 d-flex justify-content-center svg-content">
<img :src="emptyDashboardSvgPath" class="js-empty-state-svg col-12 prepend-top-20" /> <img :src="emptyDashboardSvgPath" class="js-empty-state-svg col-12 prepend-top-20" />
......
...@@ -182,5 +182,9 @@ export const minimumQueryMessage = ({ commit }) => { ...@@ -182,5 +182,9 @@ export const minimumQueryMessage = ({ commit }) => {
commit(types.MINIMUM_QUERY_MESSAGE); commit(types.MINIMUM_QUERY_MESSAGE);
}; };
export const setProjects = ({ commit }, projects) => {
commit(types.SET_PROJECTS, projects);
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests // prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {}; export default () => {};
import Vue from 'vue'; import Vue from 'vue';
import AccessorUtilities from '~/lib/utils/accessor';
import createFlash from '~/flash';
import { __ } from '~/locale';
import * as types from './mutation_types'; import * as types from './mutation_types';
export default { export default {
...@@ -8,9 +11,17 @@ export default { ...@@ -8,9 +11,17 @@ export default {
[types.SET_PROJECT_ENDPOINT_ADD](state, url) { [types.SET_PROJECT_ENDPOINT_ADD](state, url) {
state.projectEndpoints.add = url; state.projectEndpoints.add = url;
}, },
[types.SET_PROJECTS](state, projects) { [types.SET_PROJECTS](state, projects = []) {
state.projects = projects || []; state.projects = projects;
state.isLoadingProjects = false; state.isLoadingProjects = false;
if (AccessorUtilities.isLocalStorageAccessSafe()) {
localStorage.setItem(state.projectEndpoints.list, state.projects.map(p => p.id));
} else {
createFlash(
__('Project order will not be saved as local storage is not available.'),
'warning',
);
}
}, },
[types.SET_SEARCH_QUERY](state, query) { [types.SET_SEARCH_QUERY](state, query) {
state.searchQuery = query; state.searchQuery = query;
...@@ -33,8 +44,18 @@ export default { ...@@ -33,8 +44,18 @@ export default {
state.isLoadingProjects = true; state.isLoadingProjects = true;
}, },
[types.RECEIVE_PROJECTS_SUCCESS](state, projects) { [types.RECEIVE_PROJECTS_SUCCESS](state, projects) {
state.projects = projects; let projectIds = [];
if (AccessorUtilities.isLocalStorageAccessSafe()) {
projectIds = (localStorage.getItem(state.projectEndpoints.list) || '').split(',');
}
// order Projects by ID, with any unassigned ones added to the end
state.projects = projects.sort(
(a, b) => projectIds.indexOf(a.id.toString()) - projectIds.indexOf(b.id.toString()),
);
state.isLoadingProjects = false; state.isLoadingProjects = false;
if (AccessorUtilities.isLocalStorageAccessSafe()) {
localStorage.setItem(state.projectEndpoints.list, state.projects.map(p => p.id));
}
}, },
[types.RECEIVE_PROJECTS_ERROR](state) { [types.RECEIVE_PROJECTS_ERROR](state) {
state.projects = null; state.projects = null;
......
...@@ -95,9 +95,9 @@ describe('dashboard component', () => { ...@@ -95,9 +95,9 @@ describe('dashboard component', () => {
describe('wrapped components', () => { describe('wrapped components', () => {
describe('dashboard project component', () => { describe('dashboard project component', () => {
const projectCount = 1; const projectCount = 1;
const projects = mockProjectData(projectCount);
beforeEach(() => { beforeEach(() => {
const projects = mockProjectData(projectCount);
store.state.projects = projects; store.state.projects = projects;
wrapper = mountComponent(); wrapper = mountComponent();
}); });
...@@ -109,16 +109,25 @@ describe('dashboard component', () => { ...@@ -109,16 +109,25 @@ describe('dashboard component', () => {
}); });
it('passes each project to the dashboard project component', () => { it('passes each project to the dashboard project component', () => {
const [oneProject] = projects; const [oneProject] = store.state.projects;
const projectComponent = wrapper.find(Project); const projectComponent = wrapper.find(Project);
expect(projectComponent.props().project).toEqual(oneProject); expect(projectComponent.props().project).toEqual(oneProject);
}); });
it('dispatches setProjects when projects changes', () => {
const dispatch = spyOn(wrapper.vm.$store, 'dispatch');
const projects = mockProjectData(3);
wrapper.vm.projects = projects;
expect(dispatch).toHaveBeenCalledWith('setProjects', projects);
});
describe('when a project is removed', () => { describe('when a project is removed', () => {
it('immediately requests the project list again', done => { it('immediately requests the project list again', done => {
mockAxios.reset(); mockAxios.reset();
mockAxios.onDelete(projects[0].remove_path).reply(200); mockAxios.onDelete(store.state.projects[0].remove_path).reply(200);
mockAxios.onGet(mockListEndpoint).replyOnce(200, { projects: [] }); mockAxios.onGet(mockListEndpoint).replyOnce(200, { projects: [] });
wrapper.find('button.js-remove-button').vm.$emit('click'); wrapper.find('button.js-remove-button').vm.$emit('click');
......
...@@ -5,6 +5,7 @@ import { mockProjectData } from '../mock_data'; ...@@ -5,6 +5,7 @@ import { mockProjectData } from '../mock_data';
describe('mutations', () => { describe('mutations', () => {
const projects = mockProjectData(3); const projects = mockProjectData(3);
const projectIds = projects.map(p => p.id);
const mockEndpoint = 'https://mock-endpoint'; const mockEndpoint = 'https://mock-endpoint';
let localState; let localState;
...@@ -29,12 +30,38 @@ describe('mutations', () => { ...@@ -29,12 +30,38 @@ describe('mutations', () => {
}); });
describe('SET_PROJECTS', () => { describe('SET_PROJECTS', () => {
beforeEach(() => {
localState.projectEndpoints.list = 'listEndpoint';
});
it('sets projects', () => { it('sets projects', () => {
spyOn(window.localStorage, 'setItem');
mutations[types.SET_PROJECTS](localState, projects); mutations[types.SET_PROJECTS](localState, projects);
expect(localState.projects).toEqual(projects); expect(localState.projects).toEqual(projects);
expect(localState.isLoadingProjects).toEqual(false); expect(localState.isLoadingProjects).toEqual(false);
}); });
it('stores project IDs in localstorage', () => {
const saveToLocalStorage = spyOn(window.localStorage, 'setItem');
mutations[types.SET_PROJECTS](localState, projects);
expect(saveToLocalStorage).toHaveBeenCalledWith('listEndpoint', projectIds);
});
it('shows warning Alert if localStorage not available', () => {
spyOn(window.localStorage, 'setItem').and.throwError('QUOTA_EXCEEDED_ERR: DOM Exception 22');
const createFlash = spyOnDependency(mutations, 'createFlash');
mutations[types.SET_PROJECTS](localState, projects);
expect(createFlash).toHaveBeenCalledWith(
'Project order will not be saved as local storage is not available.',
'warning',
);
});
}); });
describe('SET_MESSAGE_MINIMUM_QUERY', () => { describe('SET_MESSAGE_MINIMUM_QUERY', () => {
...@@ -84,13 +111,54 @@ describe('mutations', () => { ...@@ -84,13 +111,54 @@ describe('mutations', () => {
}); });
describe('RECEIVE_PROJECTS_SUCCESS', () => { describe('RECEIVE_PROJECTS_SUCCESS', () => {
const projectListEndpoint = 'projectListEndpoint';
let saveToLocalStorage;
beforeEach(() => {
localState.projectEndpoints.list = projectListEndpoint;
saveToLocalStorage = spyOn(window.localStorage, 'setItem');
});
it('sets the project list and clears the loading status', () => { it('sets the project list and clears the loading status', () => {
mutations[types.RECEIVE_PROJECTS_SUCCESS](localState, projects); mutations[types.RECEIVE_PROJECTS_SUCCESS](localState, projects);
expect(localState.projects).toEqual(projects); expect(localState.projects).toEqual(projects);
expect(localState.isLoadingProjects).toBe(false); expect(localState.isLoadingProjects).toBe(false);
}); });
it('saves projects to localStorage', () => {
mutations[types.RECEIVE_PROJECTS_SUCCESS](localState, projects);
expect(saveToLocalStorage).toHaveBeenCalledWith(projectListEndpoint, projectIds);
});
it('orders the projects from localstorage', () => {
spyOn(window.localStorage, 'getItem').and.callFake(key => {
if (key === projectListEndpoint) {
return '2,0,1';
}
return null;
});
const expectedOrder = [projects[2], projects[0], projects[1]];
mutations[types.RECEIVE_PROJECTS_SUCCESS](localState, projects);
expect(localState.projects).toEqual(expectedOrder);
});
it('places unsorted projects after sorted ones', () => {
spyOn(window.localStorage, 'getItem').and.callFake(key => {
if (key === projectListEndpoint) {
return '1,2';
}
return null;
});
const expectedOrder = [projects[1], projects[2], projects[0]];
mutations[types.RECEIVE_PROJECTS_SUCCESS](localState, projects);
expect(localState.projects).toEqual(expectedOrder);
});
}); });
describe('RECEIVE_PROJECTS_ERROR', () => { describe('RECEIVE_PROJECTS_ERROR', () => {
......
...@@ -12997,6 +12997,9 @@ msgstr "" ...@@ -12997,6 +12997,9 @@ msgstr ""
msgid "Project name" msgid "Project name"
msgstr "" msgstr ""
msgid "Project order will not be saved as local storage is not available."
msgstr ""
msgid "Project overview" msgid "Project overview"
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