Commit f01edc80 authored by Brandon Labuschagne's avatar Brandon Labuschagne Committed by Martin Wortschack

Add manual sorting to VSA

The manual sorting is added by means of drag and drop functionality.
To achieve this we've made use of sortablejs which is also used for
issue lists and boards.
parent 273b6a6c
......@@ -19,7 +19,7 @@ export default {
<template>
<li
:class="[active ? activeClass : inactiveClass]"
class="js-add-stage-button stage-nav-item ml-2 mb-1 rounded d-flex justify-content-center border-width-1px"
class="js-add-stage-button stage-nav-item mb-1 rounded d-flex justify-content-center border-width-1px"
@click.prevent="$emit('showform')"
>
{{ s__('CustomCycleAnalytics|Add a stage') }}
......
......@@ -84,6 +84,7 @@ export default {
'durationChartMedianData',
'activeStages',
'selectedProjectIds',
'enableCustomOrdering',
]),
shouldRenderEmptyState() {
return !this.selectedGroup;
......@@ -134,6 +135,9 @@ export default {
created_before: toYmd(this.endDate),
};
},
stageCount() {
return this.activeStages.length;
},
},
mounted() {
this.setFeatureFlags({
......@@ -162,6 +166,7 @@ export default {
'clearCustomStageFormErrors',
'updateStage',
'setTasksByTypeFilters',
'reorderStage',
]),
onGroupSelect(group) {
this.setSelectedGroup(group);
......@@ -194,6 +199,9 @@ export default {
onDurationStageSelect(stages) {
this.updateSelectedDurationChartStages(stages);
},
onStageReorder(data) {
this.reorderStage(data);
},
},
multiProjectSelect: true,
dateOptions: [7, 30, 90],
......@@ -281,6 +289,7 @@ export default {
<summary-table class="js-summary-table" :items="summary" />
<stage-table
v-if="selectedStage"
:key="stageCount"
class="js-stage-table"
:current-stage="selectedStage"
:stages="activeStages"
......@@ -297,6 +306,7 @@ export default {
:no-data-svg-path="noDataSvgPath"
:no-access-svg-path="noAccessSvgPath"
:can-edit-stages="hasCustomizableCycleAnalytics"
:custom-ordering="enableCustomOrdering"
@clearCustomStageFormErrors="clearCustomStageFormErrors"
@selectStage="onStageSelect"
@editStage="onShowEditStageForm"
......@@ -305,6 +315,7 @@ export default {
@removeStage="onRemoveStage"
@createStage="onCreateCustomStage"
@updateStage="onUpdateCustomStage"
@reorderStage="onStageReorder"
/>
</div>
</div>
......
......@@ -9,6 +9,7 @@ import {
GlDropdown,
GlDropdownHeader,
GlDropdownItem,
GlSprintf,
} from '@gitlab/ui';
import { s__ } from '~/locale';
import { convertObjectPropsToSnakeCase } from '~/lib/utils/common_utils';
......@@ -64,6 +65,7 @@ export default {
GlDropdown,
GlDropdownHeader,
GlDropdownItem,
GlSprintf,
},
props: {
events: {
......@@ -390,5 +392,19 @@ export default {
{{ saveStageText }}
</button>
</div>
<div class="mt-2">
<gl-sprintf
:message="
__(
'%{strongStart}Note:%{strongEnd} Once a custom stage has been added you can re-order stages by dragging them into the desired position.',
)
"
>
<template #strong="{ content }">
<strong>{{ content }}</strong>
</template>
</gl-sprintf>
</div>
</form>
</template>
......@@ -24,7 +24,7 @@ export default {
<template>
<div
:class="[isActive ? activeClass : inactiveClass]"
class="stage-nav-item d-flex pl-4 pr-4 m-0 mb-1 ml-2 rounded border-width-1px border-style-solid"
class="stage-nav-item d-flex px-4 m-0 mb-1 rounded border-width-1px border-style-solid"
>
<slot></slot>
</div>
......
......@@ -36,6 +36,12 @@ export default {
default: false,
required: false,
},
id: {
// The IDs of stages are strings until custom stages have been added.
// Only at this point the IDs become numbers, so we have to allow both.
type: [String, Number],
required: true,
},
},
data() {
return {
......@@ -88,7 +94,12 @@ export default {
</script>
<template>
<li @click="handleSelectStage" @mouseover="handleHover(true)" @mouseleave="handleHover()">
<li
:data-id="id"
@click="handleSelectStage"
@mouseover="handleHover(true)"
@mouseleave="handleHover()"
>
<stage-card-list-item
:is-active="isActive"
:can-edit="editable"
......
<script>
import { mapState } from 'vuex';
import Sortable from 'sortablejs';
import { GlTooltipDirective, GlLoadingIcon, GlEmptyState } from '@gitlab/ui';
import { __, s__ } from '~/locale';
import StageNavItem from './stage_nav_item.vue';
......@@ -8,6 +9,8 @@ import StageTableHeader from './stage_table_header.vue';
import AddStageButton from './add_stage_button.vue';
import CustomStageForm from './custom_stage_form.vue';
import { STAGE_ACTIONS } from '../constants';
import { NO_DRAG_CLASS } from '../../shared/constants';
import sortableDefaultOptions from '../../shared/mixins/sortable_default_options';
export default {
name: 'StageTable',
......@@ -85,6 +88,16 @@ export default {
type: Boolean,
required: true,
},
customOrdering: {
type: Boolean,
required: false,
default: false,
},
errorSavingStageOrder: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
......@@ -106,6 +119,12 @@ export default {
customStageFormActive() {
return this.isCreatingCustomStage;
},
allowCustomOrdering() {
return this.customOrdering && !this.errorSavingStageOrder;
},
manualOrderingClass() {
return this.allowCustomOrdering ? 'js-manual-ordering' : '';
},
stageHeaders() {
return [
{
......@@ -137,6 +156,23 @@ export default {
},
mounted() {
this.$set(this, 'stageNavHeight', this.$refs.stageNav.clientHeight);
if (this.allowCustomOrdering) {
const options = Object.assign({}, sortableDefaultOptions(), {
onUpdate: event => {
const el = event.item;
const { previousElementSibling, nextElementSibling } = el;
const { id } = el.dataset;
const moveAfterId = previousElementSibling?.dataset?.id || null;
const moveBeforeId = nextElementSibling?.dataset?.id || null;
this.$emit('reorderStage', { id, moveAfterId, moveBeforeId });
},
});
this.sortable = Sortable.create(this.$refs.list, options);
}
},
methods: {
medianValue(id) {
......@@ -144,6 +180,7 @@ export default {
},
},
STAGE_ACTIONS,
noDragClass: NO_DRAG_CLASS,
};
</script>
<template>
......@@ -164,10 +201,11 @@ export default {
</nav>
</div>
<div class="stage-panel-body">
<nav ref="stageNav" class="stage-nav">
<ul>
<nav ref="stageNav" class="stage-nav pl-2">
<ul ref="list" :class="manualOrderingClass">
<stage-nav-item
v-for="stage in stages"
:id="stage.id"
:key="`ca-stage-title-${stage.title}`"
:title="stage.title"
:value="medianValue(stage.id)"
......@@ -181,6 +219,7 @@ export default {
/>
<add-stage-button
v-if="canEditStages"
:class="$options.noDragClass"
:active="customStageFormActive"
@showform="$emit('showAddStageForm')"
/>
......
......@@ -597,3 +597,30 @@ export const initializeCycleAnalytics = ({ dispatch, commit }, initialData = {})
return dispatch('initializeCycleAnalyticsSuccess');
};
export const requestReorderStage = ({ commit }) => commit(types.REQUEST_REORDER_STAGE);
export const receiveReorderStageSuccess = ({ commit }) =>
commit(types.RECEIVE_REORDER_STAGE_SUCCESS);
export const receiveReorderStageError = ({ commit }) => {
commit(types.RECEIVE_REORDER_STAGE_ERROR);
createFlash(__('There was an error updating the stage order. Please try reloading the page.'));
};
export const reorderStage = ({ dispatch, state }, initialData) => {
dispatch('requestReorderStage');
const {
selectedGroup: { fullPath },
} = state;
const { id, moveAfterId, moveBeforeId } = initialData;
const params = moveAfterId ? { move_after_id: moveAfterId } : { move_before_id: moveBeforeId };
return Api.cycleAnalyticsUpdateStage(id, fullPath, params)
.then(({ data }) => dispatch('receiveReorderStageSuccess', data))
.catch(({ response: { status = 400, data: responseData } = {} }) =>
dispatch('receiveReorderStageError', { status, responseData }),
);
};
import dateFormat from 'dateformat';
import { isNumber } from 'lodash';
import httpStatus from '~/lib/utils/http_status';
import { dateFormats } from '../../shared/constants';
import { getDurationChartData, getDurationChartMedianData, getTasksByTypeData } from '../utils';
......@@ -53,3 +54,6 @@ const filterStagesByHiddenStatus = (stages = [], isHidden = true) =>
export const hiddenStages = ({ stages }) => filterStagesByHiddenStatus(stages);
export const activeStages = ({ stages }) => filterStagesByHiddenStatus(stages, false);
export const enableCustomOrdering = ({ stages, errorSavingStageOrder }) =>
stages.some(stage => isNumber(stage.id)) && !errorSavingStageOrder;
......@@ -70,3 +70,7 @@ export const SET_TASKS_BY_TYPE_FILTERS = 'SET_TASKS_BY_TYPE_FILTERS';
export const INITIALIZE_CYCLE_ANALYTICS = 'INITIALIZE_CYCLE_ANALYTICS';
export const INITIALIZE_CYCLE_ANALYTICS_SUCCESS = 'INITIALIZE_CYCLE_ANALYTICS_SUCCESS';
export const REQUEST_REORDER_STAGE = 'REQUEST_REORDER_STAGE';
export const RECEIVE_REORDER_STAGE_SUCCESS = 'RECEIVE_REORDER_STAGE_SUCCESS';
export const RECEIVE_REORDER_STAGE_ERROR = 'RECEIVE_REORDER_STAGE_ERROR';
......@@ -261,4 +261,16 @@ export default {
[types.INITIALIZE_CYCLE_ANALYTICS_SUCCESS](state) {
state.isLoading = false;
},
[types.REQUEST_REORDER_STAGE](state) {
state.isSavingStageOrder = true;
state.errorSavingStageOrder = false;
},
[types.RECEIVE_REORDER_STAGE_SUCCESS](state) {
state.isSavingStageOrder = false;
state.errorSavingStageOrder = false;
},
[types.RECEIVE_REORDER_STAGE_ERROR](state) {
state.isSavingStageOrder = false;
state.errorSavingStageOrder = true;
},
};
......@@ -18,6 +18,8 @@ export default () => ({
isSavingCustomStage: false,
isCreatingCustomStage: false,
isEditingCustomStage: false,
isSavingStageOrder: false,
errorSavingStageOrder: false,
selectedGroup: null,
selectedProjects: [],
......
......@@ -19,3 +19,5 @@ export const LAST_ACTIVITY_AT = 'last_activity_at';
export const DATE_RANGE_LIMIT = 180;
export const OFFSET_DATE_BY_ONE = 1;
export const NO_DRAG_CLASS = 'no-drag';
/* global DocumentTouch */
import sortableConfig from 'ee_else_ce/sortable/sortable_config';
import { NO_DRAG_CLASS } from '../constants';
export default () => {
const touchEnabled =
'ontouchstart' in window || (window.DocumentTouch && document instanceof DocumentTouch);
return Object.assign({}, sortableConfig, {
fallbackOnBody: false,
group: {
name: 'stages',
},
dataIdAttr: 'data-id',
dragClass: 'sortable-drag',
filter: `.${NO_DRAG_CLASS}`,
delay: touchEnabled ? 100 : 0,
scrollSensitivity: touchEnabled ? 60 : 100,
scrollSpeed: 20,
fallbackTolerance: 1,
onMove(e) {
return !e.related.classList.contains(NO_DRAG_CLASS);
},
});
};
......@@ -27,3 +27,12 @@
}
}
}
// This can be refactored once the offical design implementation is complete
// https://gitlab.com/gitlab-org/gitlab/-/issues/211796
.sortable-drag {
border-radius: 0.25rem;
background-color: $white;
box-shadow: 0 0 0.25rem $gl-btn-active-background,
0 0.25rem 0.75rem $gl-btn-active-background;
}
---
title: Allow Value Stream Analytics custom stages to be manually ordered
merge_request: 26074
author:
type: changed
......@@ -2,6 +2,8 @@
require 'spec_helper'
describe 'Group Value Stream Analytics', :js do
include DragTo
let!(:user) { create(:user) }
let!(:group) { create(:group, name: "CA-test-group") }
let!(:group2) { create(:group, name: "CA-bad-test-group") }
......@@ -427,11 +429,86 @@ describe 'Group Value Stream Analytics', :js do
page.find("[name=#{field}] .dropdown-menu").all('.dropdown-item')[index].click
end
def confirm_stage_order(stages)
page.within('.stage-nav>ul') do
stages.each_with_index do |stage, index|
expect(find("li:nth-child(#{index + 1})")).to have_content(stage)
end
end
end
def drag_from_index_to_index(from, to)
drag_to(selector: '.stage-nav>ul',
from_index: from,
to_index: to)
end
default_stage_order = %w[Issue Plan Code Test Review Staging Total].freeze
default_custom_stage_order = %w[Issue Plan Code Test Review Staging Total Cool\ beans].freeze
stages_near_middle_swapped = %w[Issue Plan Test Code Review Staging Total Cool\ beans].freeze
stage_dragged_to_top = %w[Review Issue Plan Code Test Staging Total Cool\ beans].freeze
stage_dragged_to_bottom = %w[Issue Plan Code Test Staging Total Cool\ beans Review].freeze
shared_examples 'manual ordering disabled' do
it 'does not allow stages to be draggable', :js do
confirm_stage_order(default_stage_order)
drag_from_index_to_index(0, 1)
confirm_stage_order(default_stage_order)
end
end
context 'enabled' do
before do
select_group
end
context 'Manual ordering' do
context 'with only default stages' do
it_behaves_like 'manual ordering disabled'
end
context 'with at least one custom stage' do
shared_examples 'draggable stage' do |original_order, updated_order, start_index, end_index,|
before do
page.driver.browser.manage.window.resize_to(1650, 1150)
create_custom_stage
select_group
end
it 'allows a stage to be dragged' do
confirm_stage_order(original_order)
drag_from_index_to_index(start_index, end_index)
confirm_stage_order(updated_order)
end
it 'persists the order when a group is selected' do
drag_from_index_to_index(start_index, end_index)
select_group
confirm_stage_order(updated_order)
end
end
context 'dragging a stage to the top', :js do
it_behaves_like 'draggable stage', default_custom_stage_order, stage_dragged_to_top, 4, 0
end
context 'dragging a stage to the bottom', :js do
it_behaves_like 'draggable stage', default_custom_stage_order, stage_dragged_to_bottom, 4, 7
end
context 'dragging stages in the middle', :js do
it_behaves_like 'draggable stage', default_custom_stage_order, stages_near_middle_swapped, 2, 3
end
end
end
context 'Add a stage button' do
it 'is visible' do
expect(page).to have_selector(add_stage_button, visible: true)
......@@ -798,6 +875,8 @@ describe 'Group Value Stream Analytics', :js do
expect(page).to have_selector('.js-add-stage-button', visible: false)
end
end
it_behaves_like 'manual ordering disabled'
end
end
end
......@@ -7,7 +7,7 @@ exports[`CustomStageForm Editing a custom stage isSavingCustomStage=true display
`;
exports[`CustomStageForm Start event with events does not select events with canBeStartEvent=false for the start events dropdown 1`] = `
"<select name=\\"custom-stage-start-event\\" required=\\"required\\" aria-required=\\"true\\" class=\\"gl-form-select custom-select\\" id=\\"__BVID__257\\">
"<select name=\\"custom-stage-start-event\\" required=\\"required\\" aria-required=\\"true\\" class=\\"gl-form-select custom-select\\" id=\\"__BVID__277\\">
<option value=\\"\\">Select start event</option>
<option value=\\"issue_created\\">Issue created</option>
<option value=\\"issue_first_mentioned_in_commit\\">Issue first mentioned in a commit</option>
......@@ -30,7 +30,7 @@ exports[`CustomStageForm Start event with events does not select events with can
`;
exports[`CustomStageForm Start event with events selects events with canBeStartEvent=true for the start events dropdown 1`] = `
"<select name=\\"custom-stage-start-event\\" required=\\"required\\" aria-required=\\"true\\" class=\\"gl-form-select custom-select\\" id=\\"__BVID__217\\">
"<select name=\\"custom-stage-start-event\\" required=\\"required\\" aria-required=\\"true\\" class=\\"gl-form-select custom-select\\" id=\\"__BVID__237\\">
<option value=\\"\\">Select start event</option>
<option value=\\"issue_created\\">Issue created</option>
<option value=\\"issue_first_mentioned_in_commit\\">Issue first mentioned in a commit</option>
......
......@@ -115,6 +115,14 @@ describe('CustomStageForm', () => {
});
});
describe('Helper text', () => {
it('displays the manual ordering helper text', () => {
expect(wrapper.text()).toContain(
'Note: Once a custom stage has been added you can re-order stages by dragging them into the desired position.',
);
});
});
describe('Name', () => {
describe('with a reserved name', () => {
beforeEach(() => {
......
......@@ -7,10 +7,12 @@ import { approximateDuration } from '~/lib/utils/datetime_utility';
describe('StageNavItem', () => {
const title = 'Rad stage';
const median = 50;
const id = 1;
function createComponent({ props = {}, opts = {} } = {}) {
return shallowMount(StageNavItem, {
propsData: {
id,
title,
value: median,
...props,
......
......@@ -188,4 +188,36 @@ describe('StageTable', () => {
expect(wrapper.html()).toContain('Add a stage');
});
});
describe('customOrdering = true', () => {
beforeEach(() => {
wrapper = createComponent({
customOrdering: true,
});
});
afterEach(() => {
wrapper.destroy();
});
it('renders the manual-ordering class', () => {
expect(wrapper.find('.js-manual-ordering').exists()).toBeTruthy();
});
});
describe('customOrdering = false', () => {
beforeEach(() => {
wrapper = createComponent({
customOrdering: false,
});
});
afterEach(() => {
wrapper.destroy();
});
it('does not render the manual-ordering class', () => {
expect(wrapper.find('.js-manual-ordering').exists()).toBeFalsy();
});
});
});
......@@ -1620,4 +1620,86 @@ describe('Cycle analytics actions', () => {
}));
});
});
describe('reorderStage', () => {
const stageId = 'cool-stage';
const payload = { id: stageId, move_after_id: '2', move_before_id: '8' };
beforeEach(() => {
state = { selectedGroup };
});
describe('with no errors', () => {
beforeEach(() => {
mock.onPut(stageEndpoint({ stageId })).replyOnce(httpStatusCodes.OK);
});
it(`dispatches the ${types.REQUEST_REORDER_STAGE} and ${types.RECEIVE_REORDER_STAGE_SUCCESS} actions`, done => {
testAction(
actions.reorderStage,
payload,
state,
[],
[{ type: 'requestReorderStage' }, { type: 'receiveReorderStageSuccess' }],
done,
);
});
});
describe('with errors', () => {
beforeEach(() => {
mock.onPut(stageEndpoint({ stageId })).replyOnce(httpStatusCodes.NOT_FOUND);
});
it(`dispatches the ${types.REQUEST_REORDER_STAGE} and ${types.RECEIVE_REORDER_STAGE_ERROR} actions `, done => {
testAction(
actions.reorderStage,
payload,
state,
[],
[
{ type: 'requestReorderStage' },
{ type: 'receiveReorderStageError', payload: { status: httpStatusCodes.NOT_FOUND } },
],
done,
);
});
});
});
describe('receiveReorderStageError', () => {
beforeEach(() => {
setFixtures('<div class="flash-container"></div>');
});
it(`commits the ${types.RECEIVE_REORDER_STAGE_ERROR} mutation and flashes an error`, () => {
testAction(
actions.receiveReorderStageError,
null,
state,
[
{
type: types.RECEIVE_REORDER_STAGE_ERROR,
},
],
[],
);
shouldFlashAMessage(
'There was an error updating the stage order. Please try reloading the page.',
);
});
});
describe('receiveReorderStageSuccess', () => {
it(`commits the ${types.RECEIVE_REORDER_STAGE_SUCCESS} mutation`, done => {
testAction(
actions.receiveReorderStageSuccess,
null,
state,
[{ type: types.RECEIVE_REORDER_STAGE_SUCCESS }],
[],
done,
);
});
});
});
......@@ -161,4 +161,42 @@ describe('Cycle analytics getters', () => {
expect(getters[func]({ stages: [] })).toEqual([]);
});
});
describe('enableCustomOrdering', () => {
describe('with no errors saving the stage order', () => {
beforeEach(() => {
state = {
errorSavingStageOrder: false,
};
});
it('returns true when stages have numeric IDs', () => {
state.stages = [{ id: 1 }, { id: 2 }];
expect(getters.enableCustomOrdering(state)).toEqual(true);
});
it('returns false when stages have string based IDs', () => {
state.stages = [{ id: 'one' }, { id: 'two' }];
expect(getters.enableCustomOrdering(state)).toEqual(false);
});
});
describe('with errors saving the stage order', () => {
beforeEach(() => {
state = {
errorSavingStageOrder: true,
};
});
it('returns false when stages have numeric IDs', () => {
state.stages = [{ id: 1 }, { id: 2 }];
expect(getters.enableCustomOrdering(state)).toEqual(false);
});
it('returns false when stages have string based IDs', () => {
state.stages = [{ id: 'one' }, { id: 'two' }];
expect(getters.enableCustomOrdering(state)).toEqual(false);
});
});
});
});
......@@ -446,6 +446,9 @@ msgstr ""
msgid "%{state} epics"
msgstr ""
msgid "%{strongStart}Note:%{strongEnd} Once a custom stage has been added you can re-order stages by dragging them into the desired position."
msgstr ""
msgid "%{strong_start}%{branch_count}%{strong_end} Branch"
msgid_plural "%{strong_start}%{branch_count}%{strong_end} Branches"
msgstr[0] ""
......@@ -20177,6 +20180,9 @@ msgstr ""
msgid "There was an error updating the dashboard, branch named: %{branch} already exists."
msgstr ""
msgid "There was an error updating the stage order. Please try reloading the page."
msgstr ""
msgid "There was an error when reseting email token."
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