Commit 8fd5bf8f authored by Nick Thomas's avatar Nick Thomas

Merge branch '9367-dragging-and-dropping-issues-and-epics-in-the-epic-tree' into 'master'

Drag and drop issues and epics in the epic tree

See merge request gitlab-org/gitlab!14565
parents eb18c0d5 d15fab27
...@@ -2,8 +2,8 @@ function simulateEvent(el, type, options = {}) { ...@@ -2,8 +2,8 @@ function simulateEvent(el, type, options = {}) {
let event; let event;
if (!el) return null; if (!el) return null;
if (/^mouse/.test(type)) { if (/^(pointer|mouse)/.test(type)) {
event = el.ownerDocument.createEvent('MouseEvents'); event = el.ownerDocument.createEvent('MouseEvent');
event.initMouseEvent( event.initMouseEvent(
type, type,
true, true,
...@@ -125,7 +125,7 @@ export default function simulateDrag(options) { ...@@ -125,7 +125,7 @@ export default function simulateDrag(options) {
const startTime = new Date().getTime(); const startTime = new Date().getTime();
const duration = options.duration || 1000; const duration = options.duration || 1000;
simulateEvent(fromEl, 'mousedown', { simulateEvent(fromEl, 'pointerdown', {
button: 0, button: 0,
clientX: fromRect.cx, clientX: fromRect.cx,
clientY: fromRect.cy, clientY: fromRect.cy,
...@@ -146,7 +146,7 @@ export default function simulateDrag(options) { ...@@ -146,7 +146,7 @@ export default function simulateDrag(options) {
const y = fromRect.cy + (toRect.cy - fromRect.cy) * progress; const y = fromRect.cy + (toRect.cy - fromRect.cy) * progress;
const overEl = fromEl.ownerDocument.elementFromPoint(x, y); const overEl = fromEl.ownerDocument.elementFromPoint(x, y);
simulateEvent(overEl, 'mousemove', { simulateEvent(overEl, 'pointermove', {
clientX: x, clientX: x,
clientY: y, clientY: y,
}); });
......
...@@ -90,3 +90,21 @@ ...@@ -90,3 +90,21 @@
padding: 0; padding: 0;
} }
} }
.is-dragging {
// Important because plugin sets inline CSS
opacity: 1 !important;
* {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
// !important to make sure no style can override this when dragging
cursor: grabbing !important;
}
&.no-drop * {
cursor: no-drop !important;
}
}
...@@ -2,20 +2,6 @@ ...@@ -2,20 +2,6 @@
cursor: grab; cursor: grab;
} }
.is-dragging {
// Important because plugin sets inline CSS
opacity: 1 !important;
* {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
// !important to make sure no style can override this when dragging
cursor: grabbing !important;
}
}
.is-ghost { .is-ghost {
opacity: 0.3; opacity: 0.3;
pointer-events: none; pointer-events: none;
......
...@@ -4,11 +4,16 @@ import initRoadmap from 'ee/roadmap/roadmap_bundle'; ...@@ -4,11 +4,16 @@ import initRoadmap from 'ee/roadmap/roadmap_bundle';
export default class EpicTabs { export default class EpicTabs {
constructor() { constructor() {
this.epicTreesEnabled = gon.features && gon.features.epicTrees;
this.wrapper = document.querySelector('.content-wrapper .container-fluid:not(.breadcrumbs)'); this.wrapper = document.querySelector('.content-wrapper .container-fluid:not(.breadcrumbs)');
this.epicTabs = this.wrapper.querySelector('.js-epic-tabs-container'); this.epicTabs = this.wrapper.querySelector('.js-epic-tabs-container');
this.discussionFilterContainer = this.epicTabs.querySelector('.js-discussion-filter-container'); this.discussionFilterContainer = this.epicTabs.querySelector('.js-discussion-filter-container');
this.treeTabLoaded = false; if (this.epicTreesEnabled) {
initRelatedItemsTree();
}
this.treeTabLoaded = this.epicTreesEnabled;
this.roadmapTabLoaded = false; this.roadmapTabLoaded = false;
this.bindEvents(); this.bindEvents();
...@@ -25,7 +30,7 @@ export default class EpicTabs { ...@@ -25,7 +30,7 @@ export default class EpicTabs {
} }
onTreeShow() { onTreeShow() {
this.discussionFilterContainer.classList.add('hidden'); if (!this.epicTreesEnabled) this.discussionFilterContainer.classList.add('hidden');
if (!this.treeTabLoaded) { if (!this.treeTabLoaded) {
initRelatedItemsTree(); initRelatedItemsTree();
this.treeTabLoaded = true; this.treeTabLoaded = true;
...@@ -33,12 +38,12 @@ export default class EpicTabs { ...@@ -33,12 +38,12 @@ export default class EpicTabs {
} }
onTreeHide() { onTreeHide() {
this.discussionFilterContainer.classList.remove('hidden'); if (!this.epicTreesEnabled) this.discussionFilterContainer.classList.remove('hidden');
} }
onRoadmapShow() { onRoadmapShow() {
this.wrapper.classList.remove('container-limited'); this.wrapper.classList.remove('container-limited');
this.discussionFilterContainer.classList.add('hidden'); if (!this.epicTreesEnabled) this.discussionFilterContainer.classList.add('hidden');
if (!this.roadmapTabLoaded) { if (!this.roadmapTabLoaded) {
initRoadmap(); initRoadmap();
this.roadmapTabLoaded = true; this.roadmapTabLoaded = true;
...@@ -47,6 +52,6 @@ export default class EpicTabs { ...@@ -47,6 +52,6 @@ export default class EpicTabs {
onRoadmapHide() { onRoadmapHide() {
this.wrapper.classList.add('container-limited'); this.wrapper.classList.add('container-limited');
this.discussionFilterContainer.classList.remove('hidden'); if (!this.epicTreesEnabled) this.discussionFilterContainer.classList.remove('hidden');
} }
} }
...@@ -109,7 +109,7 @@ export default { ...@@ -109,7 +109,7 @@ export default {
</div> </div>
<div <div
v-else v-else
class="related-items-tree card card-slim mt-2" class="related-items-tree card card-slim border-top-0"
:class="{ :class="{
'disabled-content': disableContents, 'disabled-content': disableContents,
'overflow-auto': directChildren.length > $options.OVERFLOW_AFTER, 'overflow-auto': directChildren.length > $options.OVERFLOW_AFTER,
......
...@@ -80,8 +80,8 @@ export default { ...@@ -80,8 +80,8 @@ export default {
:class="{ :class="{
'has-children': hasChildren, 'has-children': hasChildren,
'item-expanded': childrenFlags[itemReference].itemExpanded, 'item-expanded': childrenFlags[itemReference].itemExpanded,
'js-item-type-epic': item.type === $options.ChildType.Epic, 'js-item-type-epic item-type-epic': item.type === $options.ChildType.Epic,
'js-item-type-issue': item.type === $options.ChildType.Issue, 'js-item-type-issue item-type-issue': item.type === $options.ChildType.Issue,
}" }"
> >
<div class="list-item-body d-flex align-items-center"> <div class="list-item-body d-flex align-items-center">
......
<script> <script>
import { mapState, mapActions } from 'vuex'; import { mapState, mapActions } from 'vuex';
import Draggable from 'vuedraggable';
import { GlButton, GlLoadingIcon } from '@gitlab/ui'; import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import { ChildType } from '../constants';
import TreeDragAndDropMixin from '../mixins/tree_dd_mixin';
export default { export default {
components: { components: {
Draggable,
GlButton, GlButton,
GlLoadingIcon, GlLoadingIcon,
}, },
mixins: [TreeDragAndDropMixin],
props: { props: {
parentItem: { parentItem: {
type: Object, type: Object,
...@@ -24,6 +30,9 @@ export default { ...@@ -24,6 +30,9 @@ export default {
}, },
computed: { computed: {
...mapState(['childrenFlags']), ...mapState(['childrenFlags']),
currentItemIssuesBeginAtIndex() {
return this.children.findIndex(item => item.type === ChildType.Issue);
},
hasMoreChildren() { hasMoreChildren() {
const flags = this.childrenFlags[this.parentItem.reference]; const flags = this.childrenFlags[this.parentItem.reference];
...@@ -31,7 +40,7 @@ export default { ...@@ -31,7 +40,7 @@ export default {
}, },
}, },
methods: { methods: {
...mapActions(['fetchNextPageItems']), ...mapActions(['fetchNextPageItems', 'reorderItem']),
handleShowMoreClick() { handleShowMoreClick() {
this.fetchInProgress = true; this.fetchInProgress = true;
this.fetchNextPageItems({ this.fetchNextPageItems({
...@@ -49,7 +58,17 @@ export default { ...@@ -49,7 +58,17 @@ export default {
</script> </script>
<template> <template>
<ul class="list-unstyled related-items-list tree-root"> <draggable
tag="ul"
v-bind="dragOptions"
class="list-unstyled related-items-list tree-root"
ghost-class="tree-item-drag-active"
:data-parent-reference="parentItem.reference"
:value="children"
:move="handleDragOnMove"
@start="handleDragOnStart"
@end="handleDragOnEnd"
>
<tree-item v-for="item in children" :key="item.id" :parent-item="parentItem" :item="item" /> <tree-item v-for="item in children" :key="item.id" :parent-item="parentItem" :item="item" />
<li v-if="hasMoreChildren" class="tree-item list-item pt-0 pb-0 d-flex justify-content-center"> <li v-if="hasMoreChildren" class="tree-item list-item pt-0 pb-0 d-flex justify-content-center">
<gl-button <gl-button
...@@ -61,5 +80,5 @@ export default { ...@@ -61,5 +80,5 @@ export default {
> >
<gl-loading-icon v-else size="sm" class="mt-1 mb-1" /> <gl-loading-icon v-else size="sm" class="mt-1 mb-1" />
</li> </li>
</ul> </draggable>
</template> </template>
...@@ -22,6 +22,16 @@ export const ActionType = { ...@@ -22,6 +22,16 @@ export const ActionType = {
Issue: 'issue', Issue: 'issue',
}; };
export const idProp = {
Epic: 'id',
Issue: 'epicIssueId',
};
export const relativePositions = {
Before: 'before',
After: 'after',
};
export const RemoveItemModalProps = { export const RemoveItemModalProps = {
Epic: { Epic: {
title: s__('Epics|Remove epic'), title: s__('Epics|Remove epic'),
......
import defaultSortableConfig from '~/sortable/sortable_config';
import { ChildType, idProp, relativePositions } from '../constants';
export default {
computed: {
dragOptions() {
return {
...defaultSortableConfig,
fallbackOnBody: false,
group: this.parentItem.reference,
};
},
},
methods: {
/**
* This method returns an object containing
* - `id` Global ID of target item.
* - `adjacentReferenceId` Global ID of adjacent item that's
* either above or below new position of target item.
* - `relativePosition` String representation of adjacent item which can be
* either `above` or `below`.
*
* Note: Current implementation of this method handles Epics and Issues separately
* But once we support interspersed reordering, we won't need to treat
* them separately.
*
* @param {number} object.newIndex new position of target item
* @param {object} object.targetItem target item object
*/
getTreeReorderMutation({ newIndex, targetItem }) {
const currentItemEpicsBeginAtIndex = 0;
const { currentItemIssuesBeginAtIndex, children } = this;
const isEpic = targetItem.type === ChildType.Epic;
const idPropVal = idProp[targetItem.type];
let adjacentReferenceId;
let relativePosition;
// This condition does either of the two checks as follows;
// 1. If target item is of type *Epic* and newIndex is *NOT* on top of Epics list.
// 2. If target item is of type *Issue* and newIndex is *NOT* on top of Issues list.
if (
(isEpic && newIndex > currentItemEpicsBeginAtIndex) ||
(!isEpic && newIndex > currentItemIssuesBeginAtIndex)
) {
// We set `adjacentReferenceId` to the item ID that's _above_ the target items new position.
// And since adjacent item is above, we set `relativePosition` to `above`.
adjacentReferenceId = children[newIndex - 1][idPropVal];
relativePosition = relativePositions.After;
} else {
// We set `adjacentReferenceId` to the item ID that's on top of the list (either Epics or Issues)
// And since adjacent item is below, we set `relativePosition` to `below`.
adjacentReferenceId =
children[isEpic ? currentItemEpicsBeginAtIndex : currentItemIssuesBeginAtIndex][
idPropVal
];
relativePosition = relativePositions.Before;
}
return {
id: targetItem[idPropVal],
adjacentReferenceId,
relativePosition,
};
},
/**
* This event handler is triggered the moment dragging
* of item is started, and it sets `is-dragging` class
* to page body.
*/
handleDragOnStart() {
document.body.classList.add('is-dragging');
},
/**
* This event handler is constantly fired as user is dragging
* the item around the UI.
*
* This method returns boolean value based on following
* condition checks, thus preventing interspersed ordering;
* 1. If item being dragged is Epic,
* and it is moved on top of Issues; return `false`
* 2. If item being dragged is Issue,
* and it is moved on top of Epics; return `false`.
* 3. If above two conditions are not met; return `true`.
*
* @param {object} event Object representing drag move event.
*/
handleDragOnMove({ dragged, related }) {
let isAllowed = false;
if (dragged.classList.contains('js-item-type-epic')) {
isAllowed = related.classList.contains('js-item-type-epic');
} else {
isAllowed = related.classList.contains('js-item-type-issue');
}
document.body.classList.toggle('no-drop', !isAllowed);
return isAllowed;
},
/**
* This event handler is fired when user releases the dragging
* item.
*
* This method actually fires Vuex action `reorderItem`
* that performs GraphQL mutation to update item order
* within tree.
*
* @param {object} event Object representing drag end event.
*/
handleDragOnEnd({ oldIndex, newIndex }) {
document.body.classList.remove('is-dragging');
// If both old and new index of target are same,
// nothing was moved, we do an early return.
if (oldIndex === newIndex) return;
const targetItem = this.children[oldIndex];
this.reorderItem({
treeReorderMutation: this.getTreeReorderMutation({ newIndex, targetItem }),
parentItem: this.parentItem,
targetItem,
oldIndex,
newIndex,
});
},
},
};
...@@ -3,6 +3,7 @@ fragment BaseEpic on Epic { ...@@ -3,6 +3,7 @@ fragment BaseEpic on Epic {
iid iid
title title
webPath webPath
relativePosition
userPermissions { userPermissions {
adminEpic adminEpic
createEpic createEpic
......
mutation epicReorder($epicTreeReorderInput: EpicTreeReorderInput!) {
epicTreeReorder(input: $epicTreeReorderInput) {
clientMutationId
errors
}
}
fragment IssueNode on EpicIssue { fragment IssueNode on EpicIssue {
iid iid
epicIssueId
title title
closedAt closedAt
state state
...@@ -10,6 +11,7 @@ fragment IssueNode on EpicIssue { ...@@ -10,6 +11,7 @@ fragment IssueNode on EpicIssue {
webPath webPath
reference reference
relationPath relationPath
relativePosition
assignees { assignees {
edges { edges {
node { node {
......
...@@ -16,7 +16,7 @@ export default () => { ...@@ -16,7 +16,7 @@ export default () => {
return false; return false;
} }
const { iid, fullPath, autoCompleteEpics, autoCompleteIssues } = el.dataset; const { id, iid, fullPath, autoCompleteEpics, autoCompleteIssues } = el.dataset;
const initialData = JSON.parse(el.dataset.initial); const initialData = JSON.parse(el.dataset.initial);
Vue.component('tree-root', TreeRoot); Vue.component('tree-root', TreeRoot);
...@@ -29,6 +29,7 @@ export default () => { ...@@ -29,6 +29,7 @@ export default () => {
created() { created() {
this.setInitialParentItem({ this.setInitialParentItem({
fullPath, fullPath,
id,
iid: Number(iid), iid: Number(iid),
title: initialData.initialTitleText, title: initialData.initialTitleText,
reference: `${initialData.fullPath}${initialData.issuableRef}`, reference: `${initialData.fullPath}${initialData.issuableRef}`,
......
...@@ -15,6 +15,7 @@ import { processQueryResponse, formatChildItem, gqClient } from '../utils/epic_u ...@@ -15,6 +15,7 @@ import { processQueryResponse, formatChildItem, gqClient } from '../utils/epic_u
import { ActionType, ChildType, ChildState } from '../constants'; import { ActionType, ChildType, ChildState } from '../constants';
import epicChildren from '../queries/epicChildren.query.graphql'; import epicChildren from '../queries/epicChildren.query.graphql';
import epicChildReorder from '../queries/epicChildReorder.mutation.graphql';
import * as types from './mutation_types'; import * as types from './mutation_types';
...@@ -266,7 +267,7 @@ export const receiveAddItemSuccess = ({ dispatch, commit, getters }, { actionTyp ...@@ -266,7 +267,7 @@ export const receiveAddItemSuccess = ({ dispatch, commit, getters }, { actionTyp
); );
commit(types.RECEIVE_ADD_ITEM_SUCCESS, { commit(types.RECEIVE_ADD_ITEM_SUCCESS, {
insertAt: isEpic ? getters.epicsBeginAtIndex : 0, insertAt: isEpic ? 0 : getters.issuesBeginAtIndex,
items, items,
}); });
...@@ -314,17 +315,23 @@ export const addItem = ({ state, dispatch }) => { ...@@ -314,17 +315,23 @@ export const addItem = ({ state, dispatch }) => {
export const requestCreateItem = ({ commit }) => commit(types.REQUEST_CREATE_ITEM); export const requestCreateItem = ({ commit }) => commit(types.REQUEST_CREATE_ITEM);
export const receiveCreateItemSuccess = ( export const receiveCreateItemSuccess = (
{ commit, dispatch, getters }, { state, commit, dispatch, getters },
{ actionType, rawItem }, { actionType, rawItem },
) => { ) => {
const isEpic = actionType === ActionType.Epic; const isEpic = actionType === ActionType.Epic;
const item = formatChildItem({ const item = formatChildItem({
...convertObjectPropsToCamelCase(rawItem, { deep: !isEpic }), ...convertObjectPropsToCamelCase(rawItem, { deep: !isEpic }),
type: isEpic ? ChildType.Epic : ChildType.Issue, type: isEpic ? ChildType.Epic : ChildType.Issue,
// This is needed since Rails API to create Epic
// doesn't return global ID, we can remove this
// change once create epic action is moved to
// GraphQL.
id: `gid://gitlab/Epic/${rawItem.id}`,
reference: `${state.parentItem.fullPath}${rawItem.reference}`,
}); });
commit(types.RECEIVE_CREATE_ITEM_SUCCESS, { commit(types.RECEIVE_CREATE_ITEM_SUCCESS, {
insertAt: isEpic ? getters.epicsBeginAtIndex : 0, insertAt: getters.issuesBeginAtIndex > 0 ? getters.issuesBeginAtIndex - 1 : 0,
item, item,
}); });
...@@ -371,5 +378,50 @@ export const createItem = ({ state, dispatch }, { itemTitle }) => { ...@@ -371,5 +378,50 @@ export const createItem = ({ state, dispatch }, { itemTitle }) => {
}); });
}; };
export const receiveReorderItemFailure = ({ commit }, data) => {
commit(types.REORDER_ITEM, data);
flash(s__('Epics|Something went wrong while ordering item.'));
};
export const reorderItem = (
{ dispatch, commit },
{ treeReorderMutation, parentItem, targetItem, oldIndex, newIndex },
) => {
// We proactively update the store to reflect new order of item
commit(types.REORDER_ITEM, { parentItem, targetItem, oldIndex, newIndex });
return gqClient
.mutate({
mutation: epicChildReorder,
variables: {
epicTreeReorderInput: {
baseEpicId: parentItem.id,
moved: treeReorderMutation,
},
},
})
.then(({ data }) => {
// Mutation was unsuccessful;
// revert to original order and show flash error
if (data.epicTreeReorder.errors.length) {
dispatch('receiveReorderItemFailure', {
parentItem,
targetItem,
oldIndex: newIndex,
newIndex: oldIndex,
});
}
})
.catch(() => {
// Mutation was unsuccessful;
// revert to original order and show flash error
dispatch('receiveReorderItemFailure', {
parentItem,
targetItem,
oldIndex: newIndex,
newIndex: oldIndex,
});
});
};
// 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 () => {};
...@@ -22,8 +22,8 @@ export const headerItems = state => [ ...@@ -22,8 +22,8 @@ export const headerItems = state => [
}, },
]; ];
export const epicsBeginAtIndex = (state, getters) => export const issuesBeginAtIndex = (state, getters) =>
getters.directChildren.findIndex(item => item.type === ChildType.Epic); getters.directChildren.findIndex(item => item.type === ChildType.Issue);
export const itemAutoCompleteSources = (state, getters) => { export const itemAutoCompleteSources = (state, getters) => {
if (state.actionType === ActionType.Epic) { if (state.actionType === ActionType.Epic) {
......
...@@ -36,3 +36,5 @@ export const RECEIVE_ADD_ITEM_FAILURE = 'RECEIVE_ADD_ITEM_FAILURE'; ...@@ -36,3 +36,5 @@ export const RECEIVE_ADD_ITEM_FAILURE = 'RECEIVE_ADD_ITEM_FAILURE';
export const REQUEST_CREATE_ITEM = 'REQUEST_CREATE_ITEM'; export const REQUEST_CREATE_ITEM = 'REQUEST_CREATE_ITEM';
export const RECEIVE_CREATE_ITEM_SUCCESS = 'RECEIVE_CREATE_ITEM_SUCCESS'; export const RECEIVE_CREATE_ITEM_SUCCESS = 'RECEIVE_CREATE_ITEM_SUCCESS';
export const RECEIVE_CREATE_ITEM_FAILURE = 'RECEIVE_CREATE_ITEM_FAILURE'; export const RECEIVE_CREATE_ITEM_FAILURE = 'RECEIVE_CREATE_ITEM_FAILURE';
export const REORDER_ITEM = 'REORDER_ITEM';
...@@ -173,4 +173,12 @@ export default { ...@@ -173,4 +173,12 @@ export default {
[types.RECEIVE_CREATE_ITEM_FAILURE](state) { [types.RECEIVE_CREATE_ITEM_FAILURE](state) {
state.itemCreateInProgress = false; state.itemCreateInProgress = false;
}, },
[types.REORDER_ITEM](state, { parentItem, targetItem, oldIndex, newIndex }) {
// Remove from old position
state.children[parentItem.reference].splice(oldIndex, 1);
// Insert at new position
state.children[parentItem.reference].splice(newIndex, 0, targetItem);
},
}; };
...@@ -4,6 +4,18 @@ import { ChildType, PathIdSeparator } from '../constants'; ...@@ -4,6 +4,18 @@ import { ChildType, PathIdSeparator } from '../constants';
export const gqClient = createGqClient(); export const gqClient = createGqClient();
/**
* Returns a numeric representation of item
* order in an array.
*
* This method is to be used as comparision
* function for Array.sort
*
* @param {cbject} childA
* @param {object} childB
*/
export const sortChildren = (childA, childB) => childB.relativePosition - childA.relativePosition;
/** /**
* Returns formatted child item to include additional * Returns formatted child item to include additional
* flags and properties to use while rendering tree. * flags and properties to use while rendering tree.
...@@ -21,13 +33,15 @@ export const formatChildItem = item => ...@@ -21,13 +33,15 @@ export const formatChildItem = item =>
* @param {Array} children * @param {Array} children
*/ */
export const extractChildEpics = children => export const extractChildEpics = children =>
children.edges.map(({ node, epicNode = node }) => children.edges
.map(({ node, epicNode = node }) =>
formatChildItem({ formatChildItem({
...epicNode, ...epicNode,
fullPath: epicNode.group.fullPath, fullPath: epicNode.group.fullPath,
type: ChildType.Epic, type: ChildType.Epic,
}), }),
); )
.sort(sortChildren);
/** /**
* Returns formatted array of Assignees that doesn't contain * Returns formatted array of Assignees that doesn't contain
...@@ -47,13 +61,15 @@ export const extractIssueAssignees = assignees => ...@@ -47,13 +61,15 @@ export const extractIssueAssignees = assignees =>
* @param {Array} issues * @param {Array} issues
*/ */
export const extractChildIssues = issues => export const extractChildIssues = issues =>
issues.edges.map(({ node, issueNode = node }) => issues.edges
.map(({ node, issueNode = node }) =>
formatChildItem({ formatChildItem({
...issueNode, ...issueNode,
type: ChildType.Issue, type: ChildType.Issue,
assignees: extractIssueAssignees(issueNode.assignees), assignees: extractIssueAssignees(issueNode.assignees),
}), }),
); )
.sort(sortChildren);
/** /**
* Parses Graph query response and updates * Parses Graph query response and updates
......
.related-items-tree { .related-items-tree {
border-top-left-radius: 0;
border-top-right-radius: 0;
.btn-create-epic { .btn-create-epic {
.dropdown-menu { .dropdown-menu {
top: 100%; top: 100%;
...@@ -18,6 +21,15 @@ ...@@ -18,6 +21,15 @@
} }
.tree-item { .tree-item {
&.tree-item-drag-active {
opacity: 0.3;
pointer-events: none;
}
.item-body {
cursor: grab;
}
.btn-tree-item-chevron { .btn-tree-item-chevron {
margin-bottom: $gl-padding-4; margin-bottom: $gl-padding-4;
margin-right: $gl-padding-4; margin-right: $gl-padding-4;
...@@ -43,9 +55,11 @@ ...@@ -43,9 +55,11 @@
} }
} }
.related-items-tree-body > .tree-root { .related-items-tree-body {
> .tree-root {
padding-top: $gl-vert-padding; padding-top: $gl-vert-padding;
padding-bottom: 0; padding-bottom: 0;
}
} }
.tree-root > .list-item:last-child .tree-root:last-child { .tree-root > .list-item:last-child .tree-root:last-child {
......
...@@ -12,6 +12,10 @@ ...@@ -12,6 +12,10 @@
} }
} }
.epic-discussion-separator {
border-color: $border-color;
}
.epic-sidebar { .epic-sidebar {
.block.date { .block.date {
.help-icon, .help-icon,
......
...@@ -8,6 +8,7 @@ module EE ...@@ -8,6 +8,7 @@ module EE
prepended do prepended do
mount_mutation ::Mutations::DesignManagement::Upload, calls_gitaly: true mount_mutation ::Mutations::DesignManagement::Upload, calls_gitaly: true
mount_mutation ::Mutations::DesignManagement::Delete, calls_gitaly: true mount_mutation ::Mutations::DesignManagement::Delete, calls_gitaly: true
mount_mutation ::Mutations::EpicTree::Reorder
end end
end end
end end
......
# frozen_string_literal: true
module Mutations
module EpicTree
class Reorder < ::Mutations::BaseMutation
graphql_name "EpicTreeReorder"
authorize :admin_epic
argument :base_epic_id,
GraphQL::ID_TYPE,
required: true,
description: 'The id of the base epic of the tree'
argument :moved,
Types::EpicTree::EpicTreeNodeInputType,
required: true,
description: 'Parameters for updating the tree positions'
def resolve(args)
params = args[:moved]
moving_params = params.to_hash.slice(:adjacent_reference_id, :relative_position).merge(base_epic_id: args[:base_epic_id])
result = Epics::TreeReorderService.new(current_user, params[:id], moving_params).execute
errors = result[:status] == :error ? [result[:message]] : []
{ errors: errors }
end
end
end
end
...@@ -13,6 +13,14 @@ module Types ...@@ -13,6 +13,14 @@ module Types
field :relation_path, GraphQL::STRING_TYPE, null: true, resolve: -> (issue, args, ctx) do # rubocop:disable Graphql/Descriptions field :relation_path, GraphQL::STRING_TYPE, null: true, resolve: -> (issue, args, ctx) do # rubocop:disable Graphql/Descriptions
issue.group_epic_issue_path(ctx[:current_user]) issue.group_epic_issue_path(ctx[:current_user])
end end
field :id, GraphQL::ID_TYPE, null: true, resolve: -> (issue) do
issue.to_global_id
end, description: 'The global id of the epic-issue relation'
def epic_issue_id
"gid://gitlab/EpicIssue/#{object.epic_issue_id}"
end
# rubocop: enable Graphql/AuthorizeTypes # rubocop: enable Graphql/AuthorizeTypes
end end
end end
# frozen_string_literal: true
module Types
module EpicTree
# rubocop: disable Graphql/AuthorizeTypes
class EpicTreeNodeInputType < BaseInputObject
graphql_name 'EpicTreeNodeFieldsInputType'
MoveTypeEnum = GraphQL::EnumType.define do
name 'MoveType'
description 'The position the adjacent object should be moved.'
value('before', 'The adjacent object will be moved before the object that is being moved.')
value('after', 'The adjacent object will be moved after the object that is being moved.')
end
argument :id,
GraphQL::ID_TYPE,
required: true,
description: 'The id of the epic_issue or epic that is being moved'
argument :adjacent_reference_id,
GraphQL::ID_TYPE,
required: true,
description: 'The id of the epic_issue or issue that the actual epic or issue is switched with'
argument :relative_position,
MoveTypeEnum,
required: true,
description: 'The type of the switch, after or before allowed'
end
# rubocop: enable Graphql/AuthorizeTypes
end
end
...@@ -52,6 +52,7 @@ module Types ...@@ -52,6 +52,7 @@ module Types
field :web_path, GraphQL::STRING_TYPE, null: false, method: :group_epic_path # rubocop:disable Graphql/Descriptions field :web_path, GraphQL::STRING_TYPE, null: false, method: :group_epic_path # rubocop:disable Graphql/Descriptions
field :web_url, GraphQL::STRING_TYPE, null: false, method: :group_epic_url # rubocop:disable Graphql/Descriptions field :web_url, GraphQL::STRING_TYPE, null: false, method: :group_epic_url # rubocop:disable Graphql/Descriptions
field :relative_position, GraphQL::INT_TYPE, null: true, description: 'The relative position of the epic in the Epic tree'
field :relation_path, GraphQL::STRING_TYPE, null: true, method: :group_epic_link_path # rubocop:disable Graphql/Descriptions field :relation_path, GraphQL::STRING_TYPE, null: true, method: :group_epic_link_path # rubocop:disable Graphql/Descriptions
field :reference, GraphQL::STRING_TYPE, null: false, method: :epic_reference do # rubocop:disable Graphql/Descriptions field :reference, GraphQL::STRING_TYPE, null: false, method: :epic_reference do # rubocop:disable Graphql/Descriptions
argument :full, GraphQL::BOOLEAN_TYPE, required: false, default_value: false # rubocop:disable Graphql/Descriptions argument :full, GraphQL::BOOLEAN_TYPE, required: false, default_value: false # rubocop:disable Graphql/Descriptions
......
# frozen_string_literal: true
module Epics
class TreeReorderService < BaseService
attr_reader :current_user, :moving_object, :params
def initialize(current_user, moving_object_id, params)
@current_user = current_user
@params = params
@moving_object = find_object(moving_object_id)&.sync
end
def execute
klass = case moving_object
when EpicIssue
EpicIssues::UpdateService
when Epic
EpicLinks::UpdateService
end
return error('Only epics and epic_issues are supported.') unless klass
error_message = validate_objects
return error(error_message) if error_message.present?
klass.new(moving_object, current_user, moving_params).execute
end
private
def moving_params
key = case params[:relative_position].to_sym
when :after
:move_after_id
when :before
:move_before_id
end
{}.tap { |p| p[key] = adjacent_reference.id }
end
# for now we support only ordering within the same type
# Follow-up issue: https://gitlab.com/gitlab-org/gitlab/issues/13633
def validate_objects
return 'You don\'t have permissions to move the objects.' unless authorized?
return 'Provided objects are not the same type.' if moving_object.class != adjacent_reference.class
end
def authorized?
return false unless can?(current_user, :admin_epic, base_epic.group)
return false unless can?(current_user, :admin_epic, adjacent_reference_group)
true
end
def adjacent_reference_group
case adjacent_reference
when EpicIssue
adjacent_reference&.epic&.group
when Epic
adjacent_reference&.group
else
nil
end
end
def base_epic
@base_epic ||= find_object(params[:base_epic_id])&.sync
end
def adjacent_reference
@adjacent_reference ||= find_object(params[:adjacent_reference_id])&.sync
end
def find_object(id)
GitlabSchema.object_from_id(id)
end
end
end
...@@ -13,20 +13,62 @@ ...@@ -13,20 +13,62 @@
#epic-app-root{ data: epic_show_app_data(@epic) } #epic-app-root{ data: epic_show_app_data(@epic) }
.content-block.content-block-small.emoji-list-container.js-noteable-awards - if Feature.enabled?(:epic_trees, @group)
.epic-tabs-holder
.epic-tabs-container.js-epic-tabs-container
%ul.epic-tabs.nav-tabs.nav.nav-links.scrolling-tabs
%li.tree-tab
%a#tree-tab.active{ href: '#tree', data: { toggle: 'tab' } }
= _('Epics and Issues')
%li.roadmap-tab
%a#roadmap-tab{ href: '#roadmap', data: { toggle: 'tab' } }
= _('Roadmap')
.tab-content.epic-tabs-content.js-epic-tabs-content
#tree.tab-pane.show.active
.row
%section.col-md-12
#js-tree{ data: { id: @epic.to_global_id,
iid: @epic.iid,
full_path: @group.full_path,
auto_complete_epics: 'true',
auto_complete_issues: 'false',
initial: issuable_initial_data(@epic).to_json } }
#roadmap.tab-pane
.row
%section.col-md-12
#js-roadmap{ data: { epics_path: group_epics_path(@group, parent_id: @epic.id, format: :json),
group_id: @group.id,
empty_state_illustration: image_path('illustrations/epics/roadmap.svg'),
has_filters_applied: 'false',
new_epic_endpoint: group_epics_path(@group),
preset_type: roadmap_layout,
epics_state: 'all',
sorted_by: roadmap_sort_order,
inner_height: '600',
child_epics: 'true' } }
%hr.epic-discussion-separator.mt-1.mb-0
.d-flex.justify-content-between.content-block.content-block-small.emoji-list-container.js-noteable-awards
= render 'award_emoji/awards_block', awardable: @epic, inline: true
.d-flex.flex-wrap.align-items-center.justify-content-lg-end
#js-vue-discussion-filter{ data: { default_filter: current_user&.notes_filter_for(@epic),
notes_filters: UserPreference.notes_filters.to_json } }
.row
%section.col-md-12
%script.js-notes-data{ type: "application/json" }= initial_notes_data(true).to_json.html_safe
.issuable-discussion
= render 'discussion'
- else # Everything below will go away once we remove this feature flag
.content-block.content-block-small.emoji-list-container.js-noteable-awards
= render 'award_emoji/awards_block', awardable: @epic, inline: true = render 'award_emoji/awards_block', awardable: @epic, inline: true
.epic-tabs-holder .epic-tabs-holder
.epic-tabs-container.js-epic-tabs-container .epic-tabs-container.js-epic-tabs-container
%ul.epic-tabs.nav-tabs.nav.nav-links.scrolling-tabs %ul.epic-tabs.nav-tabs.nav.nav-links.scrolling-tabs
%li.notes-tab.qa-notes-tab %li.notes-tab.qa-notes-tab
%a#discussion-tab.active{ href: '#discussion', data: { toggle: 'tab' } } %a#discussion-tab.active{ href: '#discussion', data: { toggle: 'tab' } }
= _('Discussion') = _('Discussion')
%span.badge.badge-pill= @epic.notes.user.count %span.badge.badge-pill= @epic.notes.user.count
- if Feature.enabled?(:epic_trees, @group)
%li.tree-tab
%a#tree-tab{ href: '#tree', data: { toggle: 'tab' } }
= _('Tree')
%li.roadmap-tab %li.roadmap-tab
%a#roadmap-tab{ href: '#roadmap', data: { toggle: 'tab' } } %a#roadmap-tab{ href: '#roadmap', data: { toggle: 'tab' } }
= _('Roadmap') = _('Roadmap')
...@@ -34,22 +76,13 @@ ...@@ -34,22 +76,13 @@
#js-vue-discussion-filter{ data: { default_filter: current_user&.notes_filter_for(@epic), #js-vue-discussion-filter{ data: { default_filter: current_user&.notes_filter_for(@epic),
notes_filters: UserPreference.notes_filters.to_json } } notes_filters: UserPreference.notes_filters.to_json } }
.tab-content.epic-tabs-content.js-epic-tabs-content .tab-content.epic-tabs-content.js-epic-tabs-content
#discussion.tab-pane.show.active #discussion.tab-pane.show.active
.row .row
%section.col-md-12 %section.col-md-12
%script.js-notes-data{ type: "application/json" }= initial_notes_data(true).to_json.html_safe %script.js-notes-data{ type: "application/json" }= initial_notes_data(true).to_json.html_safe
.issuable-discussion .issuable-discussion
= render 'discussion' = render 'discussion'
- if Feature.enabled?(:epic_trees, @group)
#tree.tab-pane
.row
%section.col-md-12
#js-tree{ data: { iid: @epic.iid,
full_path: @group.full_path,
auto_complete_epics: 'true',
auto_complete_issues: 'false',
initial: issuable_initial_data(@epic).to_json } }
#roadmap.tab-pane #roadmap.tab-pane
.row .row
%section.col-md-12 %section.col-md-12
......
---
title: Support reordering issues and epics using Drag&Drop
merge_request: 14565
author:
type: added
...@@ -48,25 +48,19 @@ describe 'Epic show', :js do ...@@ -48,25 +48,19 @@ describe 'Epic show', :js do
it 'shows epic tabs' do it 'shows epic tabs' do
page.within('.js-epic-tabs-container') do page.within('.js-epic-tabs-container') do
expect(find('.epic-tabs #discussion-tab')).to have_content('Discussion') expect(find('.epic-tabs #tree-tab')).to have_content('Epics and Issues')
expect(find('.epic-tabs #tree-tab')).to have_content('Tree')
expect(find('.epic-tabs #roadmap-tab')).to have_content('Roadmap') expect(find('.epic-tabs #roadmap-tab')).to have_content('Roadmap')
end end
end end
it 'shows epic thread filter dropdown' do it 'shows epic thread filter dropdown' do
page.within('.js-epic-tabs-container') do page.within('.js-noteable-awards') do
expect(find('.js-discussion-filter-container #discussion-filter-dropdown')).to have_content('Show all activity') expect(find('.js-discussion-filter-container #discussion-filter-dropdown')).to have_content('Show all activity')
end end
end end
end end
describe 'Tree tab' do describe 'Epics and Issues tab' do
before do
find('.js-epic-tabs-container #tree-tab').click
wait_for_requests
end
it 'shows Related items tree with child epics' do it 'shows Related items tree with child epics' do
page.within('.js-epic-tabs-content #tree') do page.within('.js-epic-tabs-content #tree') do
expect(page).to have_selector('.related-items-tree-container') expect(page).to have_selector('.related-items-tree-container')
...@@ -98,7 +92,7 @@ describe 'Epic show', :js do ...@@ -98,7 +92,7 @@ describe 'Epic show', :js do
end end
it 'does not show thread filter dropdown' do it 'does not show thread filter dropdown' do
expect(find('.js-epic-tabs-container')).to have_selector('.js-discussion-filter-container', visible: false) expect(find('.js-noteable-awards')).to have_selector('.js-discussion-filter-container', visible: false)
end end
it 'has no limit on container width' do it 'has no limit on container width' do
......
...@@ -26,7 +26,7 @@ describe('RelatedItemsTree', () => { ...@@ -26,7 +26,7 @@ describe('RelatedItemsTree', () => {
}; };
mockGetters = { mockGetters = {
directChildren: [mockIssue1, mockIssue2, mockEpic1, mockEpic2].map(item => ({ directChildren: [mockEpic1, mockEpic2, mockIssue1, mockIssue2].map(item => ({
...item, ...item,
type: item.reference.indexOf('&') > -1 ? ChildType.Epic : ChildType.Issue, type: item.reference.indexOf('&') > -1 ? ChildType.Epic : ChildType.Issue,
})), })),
...@@ -102,9 +102,9 @@ describe('RelatedItemsTree', () => { ...@@ -102,9 +102,9 @@ describe('RelatedItemsTree', () => {
}); });
}); });
describe('epicsBeginAtIndex', () => { describe('issuesBeginAtIndex', () => {
it('returns number representing index at which epics begin in direct children array', () => { it('returns number representing index at which Issues begin in direct children array', () => {
expect(getters.epicsBeginAtIndex(state, mockGetters)).toBe(2); expect(getters.issuesBeginAtIndex(state, mockGetters)).toBe(2);
}); });
}); });
......
...@@ -501,6 +501,26 @@ describe('RelatedItemsTree', () => { ...@@ -501,6 +501,26 @@ describe('RelatedItemsTree', () => {
expect(state.itemCreateInProgress).toBe(false); expect(state.itemCreateInProgress).toBe(false);
}); });
}); });
describe(types.REORDER_ITEM, () => {
it('should reorder an item within children of provided parent based on provided indices', () => {
state.parentItem = { reference: '&1' };
state.children[state.parentItem.reference] = ['foo', 'bar'];
mutations[types.REORDER_ITEM](state, {
parentItem: {
reference: '&1',
},
targetItem: 'bar',
oldIndex: 1,
newIndex: 0,
});
expect(state.children[state.parentItem.reference]).toEqual(
expect.arrayContaining(['bar', 'foo']),
);
});
});
}); });
}); });
}); });
...@@ -10,7 +10,7 @@ describe GitlabSchema.types['Epic'] do ...@@ -10,7 +10,7 @@ describe GitlabSchema.types['Epic'] do
due_date due_date_is_fixed due_date_fixed due_date_from_milestones due_date due_date_is_fixed due_date_fixed due_date_from_milestones
closed_at created_at updated_at children has_children has_issues closed_at created_at updated_at children has_children has_issues
web_path web_url relation_path reference issues web_path web_url relation_path reference issues
user_permissions notes discussions user_permissions notes discussions relative_position
] ]
end end
......
...@@ -6,7 +6,7 @@ import TreeRoot from 'ee/related_items_tree/components/tree_root.vue'; ...@@ -6,7 +6,7 @@ import TreeRoot from 'ee/related_items_tree/components/tree_root.vue';
import createDefaultStore from 'ee/related_items_tree/store'; import createDefaultStore from 'ee/related_items_tree/store';
import * as epicUtils from 'ee/related_items_tree/utils/epic_utils'; import * as epicUtils from 'ee/related_items_tree/utils/epic_utils';
import { mockQueryResponse, mockParentItem } from '../mock_data'; import { mockQueryResponse, mockParentItem, mockEpic1, mockIssue1 } from '../mock_data';
const { epic } = mockQueryResponse.data.group; const { epic } = mockQueryResponse.data.group;
...@@ -61,6 +61,223 @@ describe('RelatedItemsTree', () => { ...@@ -61,6 +61,223 @@ describe('RelatedItemsTree', () => {
wrapper.destroy(); wrapper.destroy();
}); });
describe('mixins', () => {
describe('TreeDragAndDropMixin', () => {
const containedDragClassOriginally = document.body.classList.contains('is-dragging');
const containedNoDropClassOriginally = document.body.classList.contains('no-drop');
beforeEach(() => {
document.body.classList.remove('is-dragging');
document.body.classList.remove('no-drop');
});
afterAll(() => {
// Prevent side-effects of this test.
document.body.classList.toggle('is-dragging', containedDragClassOriginally);
document.body.classList.toggle('no-drop', containedNoDropClassOriginally);
});
describe('computed', () => {
describe('dragOptions', () => {
it('should return object containing Vue.Draggable config extended from `defaultSortableConfig`', () => {
expect(wrapper.vm.dragOptions).toEqual(
jasmine.objectContaining({
animation: 200,
forceFallback: true,
fallbackClass: 'is-dragging',
fallbackOnBody: false,
ghostClass: 'is-ghost',
group: mockParentItem.reference,
}),
);
});
});
});
describe('methods', () => {
describe('getTreeReorderMutation', () => {
it('returns an object containing `id`, `adjacentReferenceId` & `relativePosition` when newIndex param is 0 and targetItem is Epic', () => {
const targetItem = wrapper.vm.children[1]; // 2nd Epic position
const newIndex = 0; // We're moving targetItem to top of Epics list & Epics begin at 0
const treeReorderMutation = wrapper.vm.getTreeReorderMutation({
targetItem,
newIndex,
});
expect(treeReorderMutation).toEqual(
jasmine.objectContaining({
id: targetItem.id,
adjacentReferenceId: mockEpic1.id,
relativePosition: 'before',
}),
);
});
it('returns an object containing `id`, `adjacentReferenceId` & `relativePosition` when newIndex param is 1 and targetItem is Epic', () => {
const targetItem = wrapper.vm.children[0];
const newIndex = 1;
const treeReorderMutation = wrapper.vm.getTreeReorderMutation({
targetItem,
newIndex,
});
expect(treeReorderMutation).toEqual(
jasmine.objectContaining({
id: targetItem.id,
adjacentReferenceId: mockEpic1.id,
relativePosition: 'after',
}),
);
});
it('returns an object containing `id`, `adjacentReferenceId` & `relativePosition` when newIndex param is 0 and targetItem is Issue', () => {
const targetItem = wrapper.vm.children[3]; // 2nd Issue position
const newIndex = 2; // We're moving targetItem to top of Issues list & Issues begin at 2
const treeReorderMutation = wrapper.vm.getTreeReorderMutation({
targetItem,
newIndex,
});
expect(treeReorderMutation).toEqual(
jasmine.objectContaining({
id: targetItem.epicIssueId,
adjacentReferenceId: mockIssue1.epicIssueId,
relativePosition: 'before',
}),
);
});
it('returns an object containing `id`, `adjacentReferenceId` & `relativePosition` when newIndex param is 1 and targetItem is Issue', () => {
const targetItem = wrapper.vm.children[2];
const newIndex = 3; // Here 3 is first issue of the list, hence spec descripton says `newIndex` as 1.
const treeReorderMutation = wrapper.vm.getTreeReorderMutation({
targetItem,
newIndex,
});
expect(treeReorderMutation).toEqual(
jasmine.objectContaining({
id: targetItem.epicIssueId,
adjacentReferenceId: mockIssue1.epicIssueId,
relativePosition: 'after',
}),
);
});
});
describe('handleDragOnStart', () => {
it('adds a class `is-dragging` to document body', () => {
expect(document.body.classList.contains('is-dragging')).toBe(false);
wrapper.vm.handleDragOnStart();
expect(document.body.classList.contains('is-dragging')).toBe(true);
});
});
describe('handleDragOnMove', () => {
let dragged;
let related;
let mockEvent;
beforeEach(() => {
dragged = document.createElement('li');
related = document.createElement('li');
mockEvent = {
dragged,
related,
};
});
it('returns `true` when an epic is reordered within epics list', () => {
dragged.classList.add('js-item-type-epic');
related.classList.add('js-item-type-epic');
expect(wrapper.vm.handleDragOnMove(mockEvent)).toBe(true);
});
it('returns `true` when an issue is reordered within issues list', () => {
dragged.classList.add('js-item-type-issue');
related.classList.add('js-item-type-issue');
expect(wrapper.vm.handleDragOnMove(mockEvent)).toBe(true);
});
it('returns `false` when an issue is reordered within epics list', () => {
dragged.classList.add('js-item-type-issue');
related.classList.add('js-item-type-epic');
expect(wrapper.vm.handleDragOnMove(mockEvent)).toBe(false);
});
it('returns `false` when an epic is reordered within issues list', () => {
dragged.classList.add('js-item-type-epic');
related.classList.add('js-item-type-issue');
expect(wrapper.vm.handleDragOnMove(mockEvent)).toBe(false);
});
it('adds class `no-drop` to body element when reordering is not allowed', () => {
dragged.classList.add('js-item-type-epic');
related.classList.add('js-item-type-issue');
wrapper.vm.handleDragOnMove(mockEvent);
expect(document.body.classList.contains('no-drop')).toBe(true);
});
});
describe('handleDragOnEnd', () => {
it('removes class `is-dragging` from document body', () => {
spyOn(wrapper.vm, 'reorderItem').and.stub();
document.body.classList.add('is-dragging');
wrapper.vm.handleDragOnEnd({
oldIndex: 1,
newIndex: 0,
});
expect(document.body.classList.contains('is-dragging')).toBe(false);
});
it('does not call `reorderItem` action when newIndex is same as oldIndex', () => {
spyOn(wrapper.vm, 'reorderItem').and.stub();
wrapper.vm.handleDragOnEnd({
oldIndex: 0,
newIndex: 0,
});
expect(wrapper.vm.reorderItem).not.toHaveBeenCalled();
});
it('calls `reorderItem` action when newIndex is different from oldIndex', () => {
spyOn(wrapper.vm, 'reorderItem').and.stub();
wrapper.vm.handleDragOnEnd({
oldIndex: 1,
newIndex: 0,
});
expect(wrapper.vm.reorderItem).toHaveBeenCalledWith(
jasmine.objectContaining({
treeReorderMutation: jasmine.any(Object),
parentItem: wrapper.vm.parentItem,
targetItem: wrapper.vm.children[1],
oldIndex: 1,
newIndex: 0,
}),
);
});
});
});
});
});
describe('computed', () => { describe('computed', () => {
describe('hasMoreChildren', () => { describe('hasMoreChildren', () => {
it('returns `true` when either `hasMoreEpics` or `hasMoreIssues` is true', () => { it('returns `true` when either `hasMoreEpics` or `hasMoreIssues` is true', () => {
......
...@@ -17,7 +17,7 @@ export const mockParentItem = { ...@@ -17,7 +17,7 @@ export const mockParentItem = {
}; };
export const mockEpic1 = { export const mockEpic1 = {
id: '4', id: 'gid://gitlab/Epic/4',
iid: '4', iid: '4',
title: 'Quo ea ipsa enim perferendis at omnis officia.', title: 'Quo ea ipsa enim perferendis at omnis officia.',
state: 'opened', state: 'opened',
...@@ -38,7 +38,7 @@ export const mockEpic1 = { ...@@ -38,7 +38,7 @@ export const mockEpic1 = {
}; };
export const mockEpic2 = { export const mockEpic2 = {
id: '3', id: 'gid://gitlab/Epic/3',
iid: '3', iid: '3',
title: 'A nisi mollitia explicabo quam soluta dolor hic.', title: 'A nisi mollitia explicabo quam soluta dolor hic.',
state: 'closed', state: 'closed',
...@@ -60,6 +60,7 @@ export const mockEpic2 = { ...@@ -60,6 +60,7 @@ export const mockEpic2 = {
export const mockIssue1 = { export const mockIssue1 = {
iid: '8', iid: '8',
epicIssueId: 'gid://gitlab/EpicIssue/3',
title: 'Nostrum cum mollitia quia recusandae fugit deleniti voluptatem delectus.', title: 'Nostrum cum mollitia quia recusandae fugit deleniti voluptatem delectus.',
closedAt: null, closedAt: null,
state: 'opened', state: 'opened',
...@@ -92,6 +93,7 @@ export const mockIssue1 = { ...@@ -92,6 +93,7 @@ export const mockIssue1 = {
export const mockIssue2 = { export const mockIssue2 = {
iid: '33', iid: '33',
epicIssueId: 'gid://gitlab/EpicIssue/4',
title: 'Dismiss Cipher with no integrity', title: 'Dismiss Cipher with no integrity',
closedAt: null, closedAt: null,
state: 'opened', state: 'opened',
...@@ -159,3 +161,19 @@ export const mockQueryResponse = { ...@@ -159,3 +161,19 @@ export const mockQueryResponse = {
}, },
}, },
}; };
export const mockReorderMutationResponse = {
epicTreeReorder: {
clientMutationId: null,
errors: [],
__typename: 'EpicTreeReorderPayload',
},
};
export const mockEpicTreeReorderInput = {
baseEpicId: 'gid://gitlab/Epic/1',
moved: {
id: 'gid://gitlab/Epic/2',
moveAfterId: 'gid://gitlab/Epic/3',
},
};
...@@ -19,6 +19,8 @@ import { ...@@ -19,6 +19,8 @@ import {
mockInitialConfig, mockInitialConfig,
mockParentItem, mockParentItem,
mockQueryResponse, mockQueryResponse,
mockEpicTreeReorderInput,
mockReorderMutationResponse,
mockEpics, mockEpics,
mockIssues, mockIssues,
mockEpic1, mockEpic1,
...@@ -965,7 +967,15 @@ describe('RelatedItemTree', () => { ...@@ -965,7 +967,15 @@ describe('RelatedItemTree', () => {
describe('receiveCreateItemSuccess', () => { describe('receiveCreateItemSuccess', () => {
it('should set `state.itemCreateInProgress` to false', done => { it('should set `state.itemCreateInProgress` to false', done => {
const createdEpic = Object.assign({}, mockEpics[0], {
id: `gid://gitlab/Epic/${mockEpics[0].id}`,
reference: `${mockEpics[0].group.fullPath}${mockEpics[0].reference}`,
pathIdSeparator: '&',
});
state.epicsBeginAtIndex = 0; state.epicsBeginAtIndex = 0;
state.parentItem = {
fullPath: createdEpic.group.fullPath,
};
testAction( testAction(
actions.receiveCreateItemSuccess, actions.receiveCreateItemSuccess,
...@@ -974,17 +984,17 @@ describe('RelatedItemTree', () => { ...@@ -974,17 +984,17 @@ describe('RelatedItemTree', () => {
[ [
{ {
type: types.RECEIVE_CREATE_ITEM_SUCCESS, type: types.RECEIVE_CREATE_ITEM_SUCCESS,
payload: { insertAt: 0, item: mockItems[0] }, payload: { insertAt: 0, item: createdEpic },
}, },
], ],
[ [
{ {
type: 'setChildrenCount', type: 'setChildrenCount',
payload: { children: [mockItems[0]] }, payload: { children: [createdEpic] },
}, },
{ {
type: 'setItemChildrenFlags', type: 'setItemChildrenFlags',
payload: { children: [mockItems[0]], isSubItem: false }, payload: { children: [createdEpic], isSubItem: false },
}, },
{ {
type: 'toggleCreateItemForm', type: 'toggleCreateItemForm',
...@@ -1091,6 +1101,161 @@ describe('RelatedItemTree', () => { ...@@ -1091,6 +1101,161 @@ describe('RelatedItemTree', () => {
); );
}); });
}); });
describe('receiveReorderItemFailure', () => {
beforeEach(() => {
setFixtures('<div class="flash-container"></div>');
});
it('should revert reordered item back to its original position via REORDER_ITEM mutation', done => {
testAction(
actions.receiveReorderItemFailure,
{},
{},
[{ type: types.REORDER_ITEM, payload: {} }],
[],
done,
);
});
it('should show flash error with message "Something went wrong while ordering item."', () => {
const message = 'Something went wrong while ordering item.';
actions.receiveReorderItemFailure(
{
commit: () => {},
},
{
message,
},
);
expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(
message,
);
});
});
describe('reorderItem', () => {
it('should perform REORDER_ITEM mutation before request and do nothing on request success', done => {
spyOn(epicUtils.gqClient, 'mutate').and.returnValue(
Promise.resolve({
data: mockReorderMutationResponse,
}),
);
testAction(
actions.reorderItem,
{
treeReorderMutation: mockEpicTreeReorderInput.moved,
parentItem: mockParentItem,
targetItem: mockItems[1],
oldIndex: 1,
newIndex: 0,
},
{},
[
{
type: types.REORDER_ITEM,
payload: {
parentItem: mockParentItem,
targetItem: mockItems[1],
oldIndex: 1,
newIndex: 0,
},
},
],
[],
done,
);
});
it('should perform REORDER_ITEM mutation before request and dispatch `receiveReorderItemFailure` when request response has errors on request success', done => {
spyOn(epicUtils.gqClient, 'mutate').and.returnValue(
Promise.resolve({
data: {
epicTreeReorder: {
...mockReorderMutationResponse.epicTreeReorder,
errors: [{ foo: 'bar' }],
},
},
}),
);
testAction(
actions.reorderItem,
{
treeReorderMutation: mockEpicTreeReorderInput.moved,
parentItem: mockParentItem,
targetItem: mockItems[1],
oldIndex: 1,
newIndex: 0,
},
{},
[
{
type: types.REORDER_ITEM,
payload: {
parentItem: mockParentItem,
targetItem: mockItems[1],
oldIndex: 1,
newIndex: 0,
},
},
],
[
{
type: 'receiveReorderItemFailure',
payload: {
parentItem: mockParentItem,
targetItem: mockItems[1],
oldIndex: 0,
newIndex: 1,
},
},
],
done,
);
});
it('should perform REORDER_ITEM mutation before request and dispatch `receiveReorderItemFailure` on request failure', done => {
spyOn(epicUtils.gqClient, 'mutate').and.returnValue(Promise.reject());
testAction(
actions.reorderItem,
{
treeReorderMutation: mockEpicTreeReorderInput.moved,
parentItem: mockParentItem,
targetItem: mockItems[1],
oldIndex: 1,
newIndex: 0,
},
{},
[
{
type: types.REORDER_ITEM,
payload: {
parentItem: mockParentItem,
targetItem: mockItems[1],
oldIndex: 1,
newIndex: 0,
},
},
],
[
{
type: 'receiveReorderItemFailure',
payload: {
parentItem: mockParentItem,
targetItem: mockItems[1],
oldIndex: 0,
newIndex: 1,
},
},
],
done,
);
});
});
}); });
}); });
}); });
# frozen_string_literal: true
require 'spec_helper'
describe 'Updating an epic tree' do
include GraphqlHelpers
let(:current_user) { create(:user) }
let(:group) { create(:group) }
let(:project) { create(:project, group: group) }
let(:base_epic) { create(:epic, group: group) }
let(:epic1) { create(:epic, group: group, parent: base_epic, relative_position: 10) }
let(:epic2) { create(:epic, group: group, parent: base_epic, relative_position: 20) }
let(:issue1) { create(:issue, project: project) }
let(:issue2) { create(:issue, project: project) }
let(:epic_issue1) { create(:epic_issue, epic: base_epic, issue: issue1, relative_position: 10)}
let(:epic_issue2) { create(:epic_issue, epic: base_epic, issue: issue2, relative_position: 20)}
let(:mutation) do
graphql_mutation(:epic_tree_reorder, variables)
end
let(:relative_position) { :after }
let(:variables) do
{
base_epic_id: GitlabSchema.id_from_object(base_epic).to_s,
moved: {
id: GitlabSchema.id_from_object(epic2).to_s,
adjacent_reference_id: GitlabSchema.id_from_object(epic1).to_s,
relative_position: relative_position
}
}
end
def mutation_response
graphql_mutation_response(:epic_tree_reorder)
end
shared_examples 'a mutation that does not update the tree' do
it 'does not change relative_positions' do
post_graphql_mutation(mutation, current_user: current_user)
expect(epic1.reload.relative_position).to eq(10)
expect(epic2.reload.relative_position).to eq(20)
expect(epic_issue1.reload.relative_position).to eq(10)
expect(epic_issue2.reload.relative_position).to eq(20)
end
end
context 'when epic feature is enabled' do
before do
stub_licensed_features(epics: true)
end
context 'when the user does not have permission' do
it_behaves_like 'a mutation that does not update the tree'
it 'returns the error message' do
post_graphql_mutation(mutation, current_user: current_user)
expect(mutation_response['errors']).to eq(['You don\'t have permissions to move the objects.'])
end
end
context 'when the user has permission' do
before do
group.add_developer(current_user)
end
context 'when moving an epic' do
context 'when moving an epic is successful' do
it 'updates the epics relative positions' do
post_graphql_mutation(mutation, current_user: current_user)
expect(epic1.reload.relative_position).to be > epic2.reload.relative_position
end
it 'returns nil in errors' do
post_graphql_mutation(mutation, current_user: current_user)
expect(mutation_response['array']).to be_nil
end
end
context 'when relative_position is invalid' do
let(:relative_position) { :invalid }
before do
post_graphql_mutation(mutation, current_user: current_user)
end
it_behaves_like 'a mutation that returns top-level errors',
errors: ['Variable epicTreeReorderInput of type EpicTreeReorderInput! was provided invalid value for moved.relativePosition (Expected "invalid" to be one of: before, after)']
end
context 'when object being moved is not supported type' do
before do
variables[:moved][:id] = GitlabSchema.id_from_object(issue1).to_s
variables[:moved][:adjacent_reference_id] = GitlabSchema.id_from_object(issue2).to_s
end
it 'returns the error message' do
post_graphql_mutation(mutation, current_user: current_user)
expect(mutation_response['errors']).to eq(['Only epics and epic_issues are supported.'])
end
end
context 'when moving an epic fails' do
let(:epic2) { create(:epic, relative_position: 20) }
it_behaves_like 'a mutation that does not update the tree'
it 'returns the error message' do
post_graphql_mutation(mutation, current_user: current_user)
expect(mutation_response['errors']).to eq(['Epic not found for given params'])
end
end
end
context 'when moving an issue' do
before do
variables[:moved][:id] = GitlabSchema.id_from_object(epic_issue2).to_s
variables[:moved][:adjacent_reference_id] = GitlabSchema.id_from_object(epic_issue1).to_s
end
it 'updates the epics relative positions' do
post_graphql_mutation(mutation, current_user: current_user)
expect(epic_issue1.reload.relative_position).to be > epic_issue2.reload.relative_position
end
it 'returns nil in errors' do
post_graphql_mutation(mutation, current_user: current_user)
expect(mutation_response['array']).to be_nil
end
end
context 'when moving an issue fails' do
let(:epic_issue2) { create(:epic_issue, relative_position: 20) }
before do
variables[:moved][:id] = GitlabSchema.id_from_object(epic_issue2).to_s
variables[:moved][:adjacent_reference_id] = GitlabSchema.id_from_object(epic_issue1).to_s
end
it_behaves_like 'a mutation that does not update the tree'
it 'returns the error message' do
post_graphql_mutation(mutation, current_user: current_user)
expect(mutation_response['errors']).to eq(['Epic issue not found for given params'])
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Epics::TreeReorderService do
describe '#execute' do
let(:user) { create(:user) }
let(:group) { create(:group) }
let(:project) { create(:project, group: group) }
let(:epic) { create(:epic, group: group) }
let(:issue1) { create(:issue, project: project) }
let(:issue2) { create(:issue, project: project) }
let(:epic1) { create(:epic, group: group, parent: epic, relative_position: 10) }
let(:epic2) { create(:epic, group: group, parent: epic, relative_position: 20) }
let(:epic_issue1) { create(:epic_issue, epic: epic, issue: issue1, relative_position: 10) }
let(:epic_issue2) { create(:epic_issue, epic: epic, issue: issue2, relative_position: 20) }
let(:relative_position) { :after }
let!(:tree_object_1) { epic1 }
let!(:tree_object_2) { epic2 }
let(:adjacent_reference_id) { GitlabSchema.id_from_object(tree_object_1) }
let(:moving_object_id) { GitlabSchema.id_from_object(tree_object_2) }
let(:params) do
{
base_epic_id: GitlabSchema.id_from_object(epic),
adjacent_reference_id: adjacent_reference_id,
relative_position: relative_position
}
end
subject { described_class.new(user, moving_object_id, params).execute }
shared_examples 'error for the tree update' do |expected_error|
it 'does not change relative_positions' do
subject
expect(tree_object_1.relative_position).to eq(10)
expect(tree_object_2.relative_position).to eq(20)
end
it 'returns error status' do
expect(subject[:status]).to eq(:error)
end
it 'returns correct error' do
expect(subject[:message]).to eq(expected_error)
end
end
context 'when epics feature is enabled' do
it_behaves_like 'error for the tree update', 'You don\'t have permissions to move the objects.'
end
context 'when epics feature is enabled' do
before do
stub_licensed_features(epics: true)
end
context 'when user does not have permissions to admin the base epic' do
it_behaves_like 'error for the tree update', 'You don\'t have permissions to move the objects.'
end
context 'when user does has permissions to admin the base epic' do
before do
group.add_developer(user)
end
context 'when moving EpicIssue' do
let!(:tree_object_1) { epic_issue1 }
let!(:tree_object_2) { epic_issue2 }
# for now we support only ordering within the same type
# Follow-up issue: https://gitlab.com/gitlab-org/gitlab/issues/13633
context 'when object being moved is not the same type as the switched object' do
let(:adjacent_reference_id) { GitlabSchema.id_from_object(epic2) }
it_behaves_like 'error for the tree update', 'Provided objects are not the same type.'
end
context 'when no object to switch is provided' do
let(:adjacent_reference_id) { nil }
it 'raises an error' do
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ArgumentError)
end
end
context 'when object being moved is not supported type' do
let(:moving_object_id) { GitlabSchema.id_from_object(issue1) }
let(:adjacent_reference_id) { GitlabSchema.id_from_object(issue2) }
it_behaves_like 'error for the tree update', 'Only epics and epic_issues are supported.'
end
context 'when the epics of reordered epic-issue links are not subepics of the base epic' do
let(:another_group) { create(:group) }
let(:another_epic) { create(:epic, group: another_group) }
before do
epic_issue1.update(epic: another_epic)
epic_issue2.update(epic: another_epic)
end
it_behaves_like 'error for the tree update', 'You don\'t have permissions to move the objects.'
end
context 'when moving is successful' do
it 'updates the links relative positions' do
subject
expect(tree_object_1.reload.relative_position).to be > tree_object_2.reload.relative_position
end
end
end
context 'when moving Epic' do
let!(:tree_object_1) { epic1 }
let!(:tree_object_2) { epic2 }
# for now we support only ordering within the same type
# Follow-up issue: https://gitlab.com/gitlab-org/gitlab/issues/13633
context 'when object being moved is not the same type as the switched object' do
let(:adjacent_reference_id) { GitlabSchema.id_from_object(epic_issue2) }
it_behaves_like 'error for the tree update', 'Provided objects are not the same type.'
end
context 'when the reordered epics are not subepics of the base epic' do
let(:another_group) { create(:group) }
let(:another_epic) { create(:epic, group: another_group) }
before do
epic1.update(group: another_group, parent: another_epic)
epic2.update(group: another_group, parent: another_epic)
end
it_behaves_like 'error for the tree update', 'You don\'t have permissions to move the objects.'
end
context 'when moving is successful' do
it 'updates the links relative positions' do
subject
expect(tree_object_1.reload.relative_position).to be > tree_object_2.reload.relative_position
end
end
end
end
end
end
end
...@@ -5845,6 +5845,9 @@ msgstr "" ...@@ -5845,6 +5845,9 @@ msgstr ""
msgid "Epics Roadmap" msgid "Epics Roadmap"
msgstr "" msgstr ""
msgid "Epics and Issues"
msgstr ""
msgid "Epics let you manage your portfolio of projects more efficiently and with less effort" msgid "Epics let you manage your portfolio of projects more efficiently and with less effort"
msgstr "" msgstr ""
...@@ -5896,6 +5899,9 @@ msgstr "" ...@@ -5896,6 +5899,9 @@ msgstr ""
msgid "Epics|Something went wrong while fetching group epics." msgid "Epics|Something went wrong while fetching group epics."
msgstr "" msgstr ""
msgid "Epics|Something went wrong while ordering item."
msgstr ""
msgid "Epics|Something went wrong while removing issue from epic." msgid "Epics|Something went wrong while removing issue from epic."
msgstr "" msgstr ""
...@@ -16376,9 +16382,6 @@ msgstr "" ...@@ -16376,9 +16382,6 @@ msgstr ""
msgid "TransferProject|Transfer failed, please contact an admin." msgid "TransferProject|Transfer failed, please contact an admin."
msgstr "" msgstr ""
msgid "Tree"
msgstr ""
msgid "Tree view" msgid "Tree view"
msgstr "" msgstr ""
......
...@@ -11122,10 +11122,15 @@ sort-keys@^2.0.0: ...@@ -11122,10 +11122,15 @@ sort-keys@^2.0.0:
dependencies: dependencies:
is-plain-obj "^1.0.0" is-plain-obj "^1.0.0"
sortablejs@^1.7.0: sortablejs@^1.10.0:
version "1.7.0" version "1.10.0"
resolved "https://registry.yarnpkg.com/sortablejs/-/sortablejs-1.7.0.tgz#80a2b2370abd568e1cec8c271131ef30a904fa28" resolved "https://registry.yarnpkg.com/sortablejs/-/sortablejs-1.10.0.tgz#0ebc054acff2486569194a2f975b2b145dd5e7d6"
integrity sha1-gKKyNwq9Vo4c7IwnETHvMKkE+ig= integrity sha512-+e0YakK1BxgEZpf9l9UiFaiQ8ZOBn1p/4qkkXr8QDVmYyCrUDTyDRRGm0AgW4E4cD0wtgxJ6yzIRkSPUwqhuhg==
sortablejs@^1.9.0:
version "1.9.0"
resolved "https://registry.yarnpkg.com/sortablejs/-/sortablejs-1.9.0.tgz#2d1e74ae6bac2cb4ad0622908f340848969eb88d"
integrity sha512-Ot6bYJ6PoqPmpsqQYXjn1+RKrY2NWQvQt/o4jfd/UYwVWndyO5EPO8YHbnm5HIykf8ENsm4JUrdAvolPT86yYA==
source-list-map@^2.0.0: source-list-map@^2.0.0:
version "2.0.0" version "2.0.0"
...@@ -12793,6 +12798,13 @@ vue@^2.6.10: ...@@ -12793,6 +12798,13 @@ vue@^2.6.10:
resolved "https://registry.yarnpkg.com/vue/-/vue-2.6.10.tgz#a72b1a42a4d82a721ea438d1b6bf55e66195c637" resolved "https://registry.yarnpkg.com/vue/-/vue-2.6.10.tgz#a72b1a42a4d82a721ea438d1b6bf55e66195c637"
integrity sha512-ImThpeNU9HbdZL3utgMCq0oiMzAkt1mcgy3/E6zWC/G6AaQoeuFdsl9nDhTDU3X1R6FK7nsIUuRACVcjI+A2GQ== integrity sha512-ImThpeNU9HbdZL3utgMCq0oiMzAkt1mcgy3/E6zWC/G6AaQoeuFdsl9nDhTDU3X1R6FK7nsIUuRACVcjI+A2GQ==
vuedraggable@^2.23.0:
version "2.23.0"
resolved "https://registry.yarnpkg.com/vuedraggable/-/vuedraggable-2.23.0.tgz#1f4a5a601675a5dbf0d96ee61aebfffa43445262"
integrity sha512-RgdH16k43WNoxyRcv/OarB/DZh9SY5TYthk9TS4YiHXpelD1DytEG0phLAXiXx5EhsmdH8ltSWxklGa4g1WTCw==
dependencies:
sortablejs "^1.9.0"
vuex@^3.1.0: vuex@^3.1.0:
version "3.1.0" version "3.1.0"
resolved "https://registry.yarnpkg.com/vuex/-/vuex-3.1.0.tgz#634b81515cf0cfe976bd1ffe9601755e51f843b9" resolved "https://registry.yarnpkg.com/vuex/-/vuex-3.1.0.tgz#634b81515cf0cfe976bd1ffe9601755e51f843b9"
......
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