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.
![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
The Operations Dashboard can also be made the default GitLab dashboard shown when
......
......@@ -8,6 +8,7 @@ import {
GlButton,
GlDashboardSkeleton,
} from '@gitlab/ui';
import VueDraggable from 'vuedraggable';
import ProjectSelector from '~/vue_shared/components/project_selector/project_selector.vue';
import DashboardProject from './project.vue';
......@@ -19,6 +20,7 @@ export default {
GlLoadingIcon,
GlButton,
ProjectSelector,
VueDraggable,
},
directives: {
'gl-modal': GlModalDirective,
......@@ -44,15 +46,20 @@ export default {
modalId: 'add-projects-modal',
computed: {
...mapState([
'projects',
'projectTokens',
'isLoadingProjects',
'selectedProjects',
'projectSearchResults',
'searchCount',
'searchQuery',
'messages',
]),
projects: {
get() {
return this.$store.state.projects;
},
set(projects) {
this.setProjects(projects);
},
},
isSearchingProjects() {
return this.searchCount > 0;
},
......@@ -76,6 +83,7 @@ export default {
'clearSearchResults',
'toggleSelectedProject',
'setSearchQuery',
'setProjects',
]),
addProjects() {
this.addProjectsToDashboard();
......@@ -136,11 +144,16 @@ export default {
</gl-button>
</div>
<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">
<dashboard-project :project="project" />
</div>
</div>
</vue-draggable>
<div v-else-if="!isLoadingProjects" class="row prepend-top-20 text-center">
<div class="col-12 d-flex justify-content-center svg-content">
<img :src="emptyDashboardSvgPath" class="js-empty-state-svg col-12 prepend-top-20" />
......
......@@ -182,5 +182,9 @@ export const minimumQueryMessage = ({ commit }) => {
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
export default () => {};
import Vue from 'vue';
import AccessorUtilities from '~/lib/utils/accessor';
import createFlash from '~/flash';
import { __ } from '~/locale';
import * as types from './mutation_types';
export default {
......@@ -8,9 +11,17 @@ export default {
[types.SET_PROJECT_ENDPOINT_ADD](state, url) {
state.projectEndpoints.add = url;
},
[types.SET_PROJECTS](state, projects) {
state.projects = projects || [];
[types.SET_PROJECTS](state, projects = []) {
state.projects = projects;
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) {
state.searchQuery = query;
......@@ -33,8 +44,18 @@ export default {
state.isLoadingProjects = true;
},
[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;
if (AccessorUtilities.isLocalStorageAccessSafe()) {
localStorage.setItem(state.projectEndpoints.list, state.projects.map(p => p.id));
}
},
[types.RECEIVE_PROJECTS_ERROR](state) {
state.projects = null;
......
......@@ -95,9 +95,9 @@ describe('dashboard component', () => {
describe('wrapped components', () => {
describe('dashboard project component', () => {
const projectCount = 1;
const projects = mockProjectData(projectCount);
beforeEach(() => {
const projects = mockProjectData(projectCount);
store.state.projects = projects;
wrapper = mountComponent();
});
......@@ -109,16 +109,25 @@ describe('dashboard component', () => {
});
it('passes each project to the dashboard project component', () => {
const [oneProject] = projects;
const [oneProject] = store.state.projects;
const projectComponent = wrapper.find(Project);
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', () => {
it('immediately requests the project list again', done => {
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: [] });
wrapper.find('button.js-remove-button').vm.$emit('click');
......
......@@ -5,6 +5,7 @@ import { mockProjectData } from '../mock_data';
describe('mutations', () => {
const projects = mockProjectData(3);
const projectIds = projects.map(p => p.id);
const mockEndpoint = 'https://mock-endpoint';
let localState;
......@@ -29,12 +30,38 @@ describe('mutations', () => {
});
describe('SET_PROJECTS', () => {
beforeEach(() => {
localState.projectEndpoints.list = 'listEndpoint';
});
it('sets projects', () => {
spyOn(window.localStorage, 'setItem');
mutations[types.SET_PROJECTS](localState, projects);
expect(localState.projects).toEqual(projects);
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', () => {
......@@ -84,13 +111,54 @@ describe('mutations', () => {
});
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', () => {
mutations[types.RECEIVE_PROJECTS_SUCCESS](localState, projects);
expect(localState.projects).toEqual(projects);
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', () => {
......
......@@ -12997,6 +12997,9 @@ msgstr ""
msgid "Project name"
msgstr ""
msgid "Project order will not be saved as local storage is not available."
msgstr ""
msgid "Project overview"
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