Commit 3cd7f380 authored by Jan Provaznik's avatar Jan Provaznik

Merge branch '13633-order-epic-issues-everything' into 'master'

Add support for relative ordering between classes for epics and epic_issues

Closes #13633

See merge request gitlab-org/gitlab!17800
parents f81c2219 cc66f7b7
# frozen_string_literal: true
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
class ScheduleEpicIssuesAfterEpicsMove < ActiveRecord::Migration[5.2]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
INTERVAL = 5.minutes.to_i
BATCH_SIZE = 100
MIGRATION = 'MoveEpicIssuesAfterEpics'
disable_ddl_transaction!
class Epic < ActiveRecord::Base
self.table_name = 'epics'
include ::EachBatch
end
def up
return unless ::Gitlab.ee?
Epic.each_batch(of: BATCH_SIZE) do |batch, index|
range = batch.pluck('MIN(id)', 'MAX(id)').first
delay = index * interval
BackgroundMigrationWorker.perform_in(delay, MIGRATION, *range)
end
end
def down
# no need
end
end
...@@ -122,7 +122,10 @@ export default { ...@@ -122,7 +122,10 @@ export default {
// Avoid tokenizing partial input when clicking an autocomplete item // Avoid tokenizing partial input when clicking an autocomplete item
if (!this.isAutoCompleteOpen) { if (!this.isAutoCompleteOpen) {
const { value } = this.$refs.input; const { value } = this.$refs.input;
this.$emit('addIssuableFormBlur', value); // Avoid event emission when only pathIdSeparator has been typed
if (value !== this.pathIdSeparator) {
this.$emit('addIssuableFormBlur', value);
}
} }
}, },
onFocus() { onFocus() {
......
import Draggable from 'vuedraggable'; import Draggable from 'vuedraggable';
import defaultSortableConfig from '~/sortable/sortable_config'; import defaultSortableConfig from '~/sortable/sortable_config';
import { ChildType, idProp, relativePositions } from '../constants'; import { idProp, relativePositions } from '../constants';
export default { export default {
computed: { computed: {
...@@ -17,59 +17,63 @@ export default { ...@@ -17,59 +17,63 @@ export default {
'ghost-class': 'tree-item-drag-active', 'ghost-class': 'tree-item-drag-active',
'data-parent-reference': this.parentItem.reference, 'data-parent-reference': this.parentItem.reference,
value: this.children, value: this.children,
move: this.handleDragOnMove,
}; };
return this.userSignedIn ? options : {}; return this.userSignedIn ? options : {};
}, },
}, },
methods: { methods: {
/**
* This method returns ID property's value for a given
* item by accessing it using correct property name.
*
* Here's the name of id property for item types;
* Type `Epic` -> `id`
* Type `Issue` -> `epicIssueId`
*
* @param {object} item
*/
getItemId(item) {
return item[idProp[item.type]];
},
/** /**
* This method returns an object containing * This method returns an object containing
*
* - `id` Global ID of target item. * - `id` Global ID of target item.
* - `adjacentReferenceId` Global ID of adjacent item that's * - `adjacentReferenceId` Global ID of adjacent item that's
* either above or below new position of target item. * either above or below new position of target item.
* - `relativePosition` String representation of adjacent item which can be * - `relativePosition` String representation of adjacent item with respect to
* either `above` or `below`. * target item, which can be either `before` or `after`.
*
* 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 {number} object.newIndex new position of target item
* @param {object} object.targetItem target item object * @param {object} object.targetItem target item object
*/ */
getTreeReorderMutation({ newIndex, targetItem }) { 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; let relativePosition;
// This condition does either of the two checks as follows; // adjacentReference is always the item that's at the position
// 1. If target item is of type *Epic* and newIndex is *NOT* on top of Epics list. // where target was moved.
// 2. If target item is of type *Issue* and newIndex is *NOT* on top of Issues list. const adjacentReferenceId = this.getItemId(this.children[newIndex]);
if (
(isEpic && newIndex > currentItemEpicsBeginAtIndex) || if (newIndex === 0) {
(!isEpic && newIndex > currentItemIssuesBeginAtIndex) // If newIndex is `0`, item was moved to the top.
) { // Adjacent reference will be the one which is currently at the top,
// We set `adjacentReferenceId` to the item ID that's _above_ the target items new position. // and it's relative position with respect to target's new position is `after`.
// And since adjacent item is above, we set `relativePosition` to `Before`. relativePosition = relativePositions.After;
adjacentReferenceId = children[newIndex - 1][idPropVal]; } else if (newIndex === this.children.length - 1) {
// If newIndex is last position in list, item was moved to the bottom.
// Adjacent reference will be the one which is currently at the bottom,
// and it's relative position with respect to target's new position is `before`.
relativePosition = relativePositions.Before; relativePosition = relativePositions.Before;
} else { } else {
// We set `adjacentReferenceId` to the item ID that's on top of the list (either Epics or Issues) // If newIndex is neither top nor bottom, it was moved somewhere in the middle.
// And since adjacent item is below, we set `relativePosition` to `After`. // Adjacent reference will be the one which currently at that position,
adjacentReferenceId = // and it's relative postion with respect to target's new position is `after`.
children[isEpic ? currentItemEpicsBeginAtIndex : currentItemIssuesBeginAtIndex][
idPropVal
];
relativePosition = relativePositions.After; relativePosition = relativePositions.After;
} }
return { return {
id: targetItem[idPropVal], id: this.getItemId(targetItem),
adjacentReferenceId, adjacentReferenceId,
relativePosition, relativePosition,
}; };
...@@ -82,33 +86,6 @@ export default { ...@@ -82,33 +86,6 @@ export default {
handleDragOnStart() { handleDragOnStart() {
document.body.classList.add('is-dragging'); 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 * This event handler is fired when user releases the dragging
* item. * item.
......
...@@ -259,13 +259,31 @@ export const setItemInputValue = ({ commit }, data) => commit(types.SET_ITEM_INP ...@@ -259,13 +259,31 @@ export const setItemInputValue = ({ commit }, data) => commit(types.SET_ITEM_INP
export const requestAddItem = ({ commit }) => commit(types.REQUEST_ADD_ITEM); export const requestAddItem = ({ commit }) => commit(types.REQUEST_ADD_ITEM);
export const receiveAddItemSuccess = ({ dispatch, commit, getters }, { rawItems }) => { export const receiveAddItemSuccess = ({ dispatch, commit, getters }, { rawItems }) => {
const items = rawItems.map(item => const items = rawItems.map(item => {
formatChildItem({ // This is needed since Rails API to add Epic/Issue
...convertObjectPropsToCamelCase(item, { deep: !getters.isEpic }), // doesn't return global ID string.
// We can remove this change once add epic/issue
// action is moved to GraphQL.
// See https://gitlab.com/gitlab-org/gitlab/issues/34529
const globalItemId = {};
if (getters.isEpic) {
globalItemId.id = !`${item.id}`.includes('gid://') ? `gid://gitlab/Epic/${item.id}` : item.id;
} else {
globalItemId.epicIssueId = !`${item.epic_issue_id}`.includes('gid://')
? `gid://gitlab/EpicIssue/${item.epic_issue_id}`
: item.epic_issue_id;
}
return formatChildItem({
...convertObjectPropsToCamelCase(item, {
deep: !getters.isEpic,
dropKeys: ['id', 'epic_issue_id'],
}),
...globalItemId,
type: getters.isEpic ? ChildType.Epic : ChildType.Issue, type: getters.isEpic ? ChildType.Epic : ChildType.Issue,
userPermissions: getters.isEpic ? { adminEpic: item.can_admin } : {}, userPermissions: getters.isEpic ? { adminEpic: item.can_admin } : {},
}), });
); });
commit(types.RECEIVE_ADD_ITEM_SUCCESS, { commit(types.RECEIVE_ADD_ITEM_SUCCESS, {
insertAt: getters.isEpic ? 0 : getters.issuesBeginAtIndex, insertAt: getters.isEpic ? 0 : getters.issuesBeginAtIndex,
......
...@@ -34,15 +34,13 @@ export const formatChildItem = item => ...@@ -34,15 +34,13 @@ export const formatChildItem = item =>
* @param {Array} children * @param {Array} children
*/ */
export const extractChildEpics = children => export const extractChildEpics = children =>
children.edges children.edges.map(({ node, epicNode = node }) =>
.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
...@@ -62,20 +60,20 @@ export const extractIssueAssignees = assignees => ...@@ -62,20 +60,20 @@ export const extractIssueAssignees = assignees =>
* @param {Array} issues * @param {Array} issues
*/ */
export const extractChildIssues = issues => export const extractChildIssues = issues =>
issues.edges issues.edges.map(({ node, issueNode = node }) =>
.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
* children array to include issues within it * children array to include issues within it
* and then sorts everything based on `relativePosition`
*
* @param {Object} responseRoot * @param {Object} responseRoot
*/ */
export const processQueryResponse = ({ epic }) => export const processQueryResponse = ({ epic }) =>
[].concat(extractChildEpics(epic.children), extractChildIssues(epic.issues)); [].concat(extractChildEpics(epic.children), extractChildIssues(epic.issues)).sort(sortChildren);
# frozen_string_literal: true
module EpicTreeSorting
extend ActiveSupport::Concern
include FromUnion
include RelativePositioning
class_methods do
def relative_positioning_query_base(object)
from_union([
EpicIssue.select("id, relative_position, epic_id, 'epic_issue' as object_type").in_epic(object.parent_ids),
Epic.select("id, relative_position, parent_id as epic_id, 'epic' as object_type").where(parent_id: object.parent_ids)
])
end
def relative_positioning_parent_column
:epic_id
end
end
included do
def move_sequence(start_pos, end_pos, delta)
items_to_update = scoped_items
.select(:id, :object_type)
.where('relative_position BETWEEN ? AND ?', start_pos, end_pos)
.where.not('object_type = ? AND id = ?', self.class.table_name.singularize, self.id)
items_to_update.group_by { |item| item.object_type }.each do |type, group_items|
ids = group_items.map(&:id)
items = type.camelcase.constantize.where(id: ids).select(:id)
items.update_all("relative_position = relative_position + #{delta}")
end
end
end
end
...@@ -12,9 +12,9 @@ module EE ...@@ -12,9 +12,9 @@ module EE
include Referable include Referable
include Awardable include Awardable
include LabelEventable include LabelEventable
include RelativePositioning
include UsageStatistics include UsageStatistics
include FromUnion include FromUnion
include EpicTreeSorting
enum state_id: { enum state_id: {
opened: ::Epic.available_states[:opened], opened: ::Epic.available_states[:opened],
...@@ -177,14 +177,6 @@ module EE ...@@ -177,14 +177,6 @@ module EE
::Group ::Group
end end
def relative_positioning_query_base(epic)
in_parents(epic.parent_ids)
end
def relative_positioning_parent_column
:parent_id
end
# Return the deepest relation level for an epic. # Return the deepest relation level for an epic.
# Example 1: # Example 1:
# epic1 - parent: nil # epic1 - parent: nil
......
# frozen_string_literal: true # frozen_string_literal: true
class EpicIssue < ApplicationRecord class EpicIssue < ApplicationRecord
include RelativePositioning include EpicTreeSorting
validates :epic, :issue, presence: true validates :epic, :issue, presence: true
validates :issue, uniqueness: true validates :issue, uniqueness: true
...@@ -10,14 +10,7 @@ class EpicIssue < ApplicationRecord ...@@ -10,14 +10,7 @@ class EpicIssue < ApplicationRecord
belongs_to :issue belongs_to :issue
alias_attribute :parent_ids, :epic_id alias_attribute :parent_ids, :epic_id
alias_attribute :parent, :epic
scope :in_epic, ->(epic_id) { where(epic_id: epic_id) } scope :in_epic, ->(epic_id) { where(epic_id: epic_id) }
def self.relative_positioning_query_base(epic_issue)
in_epic(epic_issue.parent_ids)
end
def self.relative_positioning_parent_column
:epic_id
end
end end
...@@ -11,40 +11,54 @@ module Epics ...@@ -11,40 +11,54 @@ module Epics
end end
def execute 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 error_message = validate_objects
return error(error_message) if error_message.present? return error(error_message) if error_message.present?
klass.new(moving_object, current_user, moving_params).execute move!
success
end end
private private
def moving_params def move!
key = case params[:relative_position].to_sym moving_object.move_between(before_object, after_object)
when :after moving_object.save!(touch: false)
:move_after_id end
when :before
:move_before_id def before_object
end return unless params[:relative_position] == 'before'
{}.tap { |p| p[key] = adjacent_reference.id } adjacent_reference
end
def after_object
return unless params[:relative_position] == 'after'
adjacent_reference
end 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 def validate_objects
return 'Relative position is not valid.' unless valid_relative_position?
unless supported_type?(moving_object) && supported_type?(adjacent_reference)
return 'Only epics and epic_issues are supported.'
end
return 'You don\'t have permissions to move the objects.' unless authorized? 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 return 'Both objects have to belong to the same parent epic.' unless same_parent?
end
def valid_relative_position?
%w(before after).include?(params[:relative_position])
end
def same_parent?
moving_object.parent == adjacent_reference.parent
end
def supported_type?(object)
object.is_a?(EpicIssue) || object.is_a?(Epic)
end end
def authorized? def authorized?
......
# frozen_string_literal: true
module Gitlab
module BackgroundMigration
class MoveEpicIssuesAfterEpics
class EpicIssue < ActiveRecord::Base
self.table_name = 'epic_issues'
end
class Epic < ActiveRecord::Base
self.table_name = 'epics'
end
def perform(start_id, stop_id)
maximum_epic_position = Epic.maximum(:relative_position)
return unless maximum_epic_position
max_position = Gitlab::Database::MAX_INT_VALUE
delta = ((maximum_epic_position - max_position) / 2.0).abs.ceil
EpicIssue.where(epic_id: start_id..stop_id).where('relative_position < ?', max_position - delta)
.update_all("relative_position = relative_position + #{delta}")
end
end
end
end
...@@ -116,7 +116,6 @@ describe('RelatedItemsTree', () => { ...@@ -116,7 +116,6 @@ describe('RelatedItemsTree', () => {
'ghost-class': 'tree-item-drag-active', 'ghost-class': 'tree-item-drag-active',
'data-parent-reference': mockParentItem.reference, 'data-parent-reference': mockParentItem.reference,
value: wrapper.vm.children, value: wrapper.vm.children,
move: wrapper.vm.handleDragOnMove,
}), }),
); );
}); });
...@@ -133,78 +132,115 @@ describe('RelatedItemsTree', () => { ...@@ -133,78 +132,115 @@ describe('RelatedItemsTree', () => {
}); });
describe('methods', () => { describe('methods', () => {
describe('getTreeReorderMutation', () => { describe('getItemId', () => {
it('returns an object containing `id`, `adjacentReferenceId` & `relativePosition` when newIndex param is 0 and targetItem is Epic', () => { it('returns value of `id` prop when item is an Epic', () => {
const targetItem = wrapper.vm.children[1]; // 2nd Epic position expect(wrapper.vm.getItemId(wrapper.vm.children[0])).toBe(mockEpic1.id);
const newIndex = 0; // We're moving targetItem to top of Epics list & Epics begin at 0 });
const treeReorderMutation = wrapper.vm.getTreeReorderMutation({ it('returns value of `epicIssueId` prop when item is an Issue', () => {
targetItem, expect(wrapper.vm.getItemId(wrapper.vm.children[2])).toBe(mockIssue1.epicIssueId);
newIndex, });
}); });
expect(treeReorderMutation).toEqual( describe('getTreeReorderMutation', () => {
it('returns an object containing ID of targetItem', () => {
const targetItemEpic = wrapper.vm.children[0];
const targetItemIssue = wrapper.vm.children[2];
const newIndex = 0;
expect(
wrapper.vm.getTreeReorderMutation({
targetItem: targetItemEpic,
newIndex,
}),
).toEqual(
jasmine.objectContaining({ jasmine.objectContaining({
id: targetItem.id, id: mockEpic1.id,
adjacentReferenceId: mockEpic1.id, }),
relativePosition: 'after', );
expect(
wrapper.vm.getTreeReorderMutation({
targetItem: targetItemIssue,
newIndex,
}),
).toEqual(
jasmine.objectContaining({
id: mockIssue1.epicIssueId,
}), }),
); );
}); });
it('returns an object containing `id`, `adjacentReferenceId` & `relativePosition` when newIndex param is 1 and targetItem is Epic', () => { it('returns an object containing `adjacentReferenceId` of children item at provided `newIndex`', () => {
const targetItem = wrapper.vm.children[0]; const targetItem = wrapper.vm.children[0];
const newIndex = 1;
const treeReorderMutation = wrapper.vm.getTreeReorderMutation({
targetItem,
newIndex,
});
expect(treeReorderMutation).toEqual( expect(
wrapper.vm.getTreeReorderMutation({
targetItem,
newIndex: 0,
}),
).toEqual(
jasmine.objectContaining({ jasmine.objectContaining({
id: targetItem.id,
adjacentReferenceId: mockEpic1.id, adjacentReferenceId: mockEpic1.id,
relativePosition: 'before',
}), }),
); );
});
it('returns an object containing `id`, `adjacentReferenceId` & `relativePosition` when newIndex param is 0 and targetItem is Issue', () => { expect(
const targetItem = wrapper.vm.children[3]; // 2nd Issue position wrapper.vm.getTreeReorderMutation({
const newIndex = 2; // We're moving targetItem to top of Issues list & Issues begin at 2 targetItem,
newIndex: 2,
}),
).toEqual(
jasmine.objectContaining({
adjacentReferenceId: mockIssue1.epicIssueId,
}),
);
});
const treeReorderMutation = wrapper.vm.getTreeReorderMutation({ it('returns object containing `relativePosition` containing `after` when `newIndex` param is 0', () => {
targetItem, const targetItem = wrapper.vm.children[0];
newIndex,
});
expect(treeReorderMutation).toEqual( expect(
wrapper.vm.getTreeReorderMutation({
targetItem,
newIndex: 0,
}),
).toEqual(
jasmine.objectContaining({ jasmine.objectContaining({
id: targetItem.epicIssueId,
adjacentReferenceId: mockIssue1.epicIssueId,
relativePosition: 'after', relativePosition: 'after',
}), }),
); );
}); });
it('returns an object containing `id`, `adjacentReferenceId` & `relativePosition` when newIndex param is 1 and targetItem is Issue', () => { it('returns object containing `relativePosition` containing `before` when `newIndex` param is last item index', () => {
const targetItem = wrapper.vm.children[2]; const targetItem = wrapper.vm.children[0];
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( expect(
wrapper.vm.getTreeReorderMutation({
targetItem,
newIndex: wrapper.vm.children.length - 1,
}),
).toEqual(
jasmine.objectContaining({ jasmine.objectContaining({
id: targetItem.epicIssueId,
adjacentReferenceId: mockIssue1.epicIssueId,
relativePosition: 'before', relativePosition: 'before',
}), }),
); );
}); });
it('returns object containing `relativePosition` containing `after` when `newIndex` param neither `0` nor last item index', () => {
const targetItem = wrapper.vm.children[0];
expect(
wrapper.vm.getTreeReorderMutation({
targetItem,
newIndex: 2,
}),
).toEqual(
jasmine.objectContaining({
relativePosition: 'after',
}),
);
});
}); });
describe('handleDragOnStart', () => { describe('handleDragOnStart', () => {
...@@ -217,58 +253,6 @@ describe('RelatedItemsTree', () => { ...@@ -217,58 +253,6 @@ describe('RelatedItemsTree', () => {
}); });
}); });
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', () => { describe('handleDragOnEnd', () => {
it('removes class `is-dragging` from document body', () => { it('removes class `is-dragging` from document body', () => {
spyOn(wrapper.vm, 'reorderItem').and.stub(); spyOn(wrapper.vm, 'reorderItem').and.stub();
......
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::BackgroundMigration::MoveEpicIssuesAfterEpics, :migration, schema: 20190926180443 do
let(:users) { table(:users) }
let(:namespaces) { table(:namespaces) }
let(:projects) { table(:projects) }
let(:issues) { table(:issues) }
let(:epics) { table(:epics) }
let(:epic_issues) { table(:epic_issues) }
subject { described_class.new }
describe '#perform' do
let(:epic_params) do
{
title: 'Epic',
title_html: 'Epic',
group_id: group.id,
author_id: user.id
}
end
let(:issue_params) do
{
title: 'Issue',
title_html: 'Issue',
project_id: project.id,
author_id: user.id
}
end
let(:user) { users.create(name: 'test', email: 'test@example.com', projects_limit: 5) }
let(:group) { namespaces.create(name: 'gitlab', path: 'gitlab-org') }
context 'when there are epic_issues present' do
let(:project) { projects.create(namespace_id: group.id, name: 'foo') }
let(:base_epic) { epics.create(epic_params.merge(iid: 3, relative_position: 500)) }
let(:issue_1) { issues.create(issue_params.merge(iid: 1)) }
let(:issue_2) { issues.create(issue_params.merge(iid: 2)) }
let(:issue_3) { issues.create(issue_params.merge(iid: 3)) }
let!(:epic_1) { epics.create(epic_params.merge(iid: 1, relative_position: 100)) }
let!(:epic_2) { epics.create(epic_params.merge(iid: 2, relative_position: 5000)) }
let!(:epic_issue_1) { epic_issues.create(issue_id: issue_1.id, epic_id: base_epic.id, relative_position: 400) }
let!(:epic_issue_2) { epic_issues.create(issue_id: issue_2.id, epic_id: base_epic.id, relative_position: 5010) }
let!(:epic_issue_3) { epic_issues.create(issue_id: issue_3.id, epic_id: base_epic.id, relative_position: Gitlab::Database::MAX_INT_VALUE - 10) }
before do
subject.perform(epics.first.id, epics.last.id)
end
it 'does not change relative_position of epics' do
expect(base_epic.relative_position).to eq(500)
expect(epic_1.relative_position).to eq(100)
expect(epic_2.relative_position).to eq(5000)
end
it 'moves epic_issues after epics' do
expect(epic_issue_1.reload.relative_position).to be > 5000
expect(epic_issue_2.reload.relative_position).to be > 5000
end
it 'keeps epic_issues order' do
expect(epic_issue_1.reload.relative_position).to be < epic_issue_2.reload.relative_position
end
it 'does not change the relative_position of epic_issue getting to the max value' do
expect(epic_issue_3.reload.relative_position).to eq(Gitlab::Database::MAX_INT_VALUE - 10)
end
end
context 'when there are no epics' do
it 'runs correctly' do
expect(subject.perform(1, 10)).to be_nil
end
end
context 'when there are no epic_issues' do
it 'runs correctly' do
epics.create(epic_params.merge(iid: 3, relative_position: 500))
expect(subject.perform(1, 10)).to be_zero
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe EpicTreeSorting do
let_it_be(:group) { create(:group) }
let_it_be(:base_epic) { create(:epic, group: group) }
let!(:epic_issue1) { create(:epic_issue, epic: base_epic, relative_position: 10) }
let!(:epic_issue2) { create(:epic_issue, epic: base_epic, relative_position: 500) }
let!(:epic_issue3) { create(:epic_issue, epic: base_epic, relative_position: 1002) }
let!(:epic1) { create(:epic, parent: base_epic, group: group, relative_position: 100) }
let!(:epic2) { create(:epic, parent: base_epic, group: group, relative_position: 1000) }
let!(:epic3) { create(:epic, parent: base_epic, group: group, relative_position: 1001) }
context '#move_after' do
it 'moves an epic' do
epic1.move_after(epic_issue2)
expect(epic1.relative_position).to be_between(epic_issue2.reload.relative_position, epic2.reload.relative_position).exclusive
end
it 'moves an epic_issue' do
epic_issue2.move_after(epic2)
expect(epic_issue2.relative_position).to be_between(epic2.reload.relative_position, epic3.reload.relative_position).exclusive
expect(epic_issue3.reload.relative_position).to be > epic3.reload.relative_position
end
end
context '#move_before' do
it 'moves an epic' do
epic2.move_before(epic_issue2)
expect(epic2.relative_position).to be_between(epic_issue1.reload.relative_position, epic_issue2.reload.relative_position).exclusive
end
it 'moves an epic_issue' do
epic_issue3.move_before(epic2)
expect(epic_issue3.relative_position).to be_between(epic_issue2.reload.relative_position, epic2.reload.relative_position).exclusive
end
end
context '#move_between' do
it 'moves an epic' do
epic1.move_between(epic_issue1, epic_issue2)
expect(epic1.relative_position).to be_between(epic_issue1.reload.relative_position, epic_issue2.reload.relative_position).exclusive
end
it 'moves an epic_issue' do
epic_issue3.move_between(epic1, epic_issue2)
expect(epic_issue3.relative_position).to be_between(epic1.reload.relative_position, epic_issue2.reload.relative_position).exclusive
end
end
context '#move_sequence' do
let!(:epic_issue1) { create(:epic_issue, epic: base_epic, relative_position: 1000) }
let!(:epic_issue2) { create(:epic_issue, epic: base_epic, relative_position: 1001) }
let!(:epic_issue3) { create(:epic_issue, epic: base_epic, relative_position: 1004) }
let!(:epic1) { create(:epic, parent: base_epic, group: group, relative_position: 1002) }
let!(:epic2) { create(:epic, parent: base_epic, group: group, relative_position: 1003) }
let!(:epic3) { create(:epic, parent: base_epic, group: group, relative_position: 1005) }
context 'when self is an epic' do
it 'moves all objects correctly' do
epic1.move_sequence(1003, 1005, 500)
expect(epic_issue1.reload.relative_position).to eq(1000)
expect(epic_issue2.reload.relative_position).to eq(1001)
expect(epic_issue3.reload.relative_position).to eq(1504)
expect(epic1.reload.relative_position).to eq(1002)
expect(epic2.reload.relative_position).to eq(1503)
expect(epic3.reload.relative_position).to eq(1505)
end
end
context 'when self is an epic_issue' do
it 'moves all objects correctly' do
epic_issue1.move_sequence(1001, 1005, 500)
expect(epic_issue1.reload.relative_position).to eq(1000)
expect(epic_issue2.reload.relative_position).to eq(1501)
expect(epic_issue3.reload.relative_position).to eq(1504)
expect(epic1.reload.relative_position).to eq(1502)
expect(epic2.reload.relative_position).to eq(1503)
expect(epic3.reload.relative_position).to eq(1505)
end
end
end
end
...@@ -106,7 +106,7 @@ describe 'Updating an epic tree' do ...@@ -106,7 +106,7 @@ describe 'Updating an epic tree' do
end end
end end
context 'when moving an epic fails' do context 'when moving an epic fails due to another parent' do
let(:epic2) { create(:epic, relative_position: 20) } let(:epic2) { create(:epic, relative_position: 20) }
it_behaves_like 'a mutation that does not update the tree' it_behaves_like 'a mutation that does not update the tree'
...@@ -114,7 +114,7 @@ describe 'Updating an epic tree' do ...@@ -114,7 +114,7 @@ describe 'Updating an epic tree' do
it 'returns the error message' do it 'returns the error message' do
post_graphql_mutation(mutation, current_user: current_user) post_graphql_mutation(mutation, current_user: current_user)
expect(mutation_response['errors']).to eq(['Epic not found for given params']) expect(mutation_response['errors']).to eq(['Both objects have to belong to the same parent epic.'])
end end
end end
end end
...@@ -138,7 +138,7 @@ describe 'Updating an epic tree' do ...@@ -138,7 +138,7 @@ describe 'Updating an epic tree' do
end end
end end
context 'when moving an issue fails' do context 'when moving an issue fails due to another parent' do
let(:epic_issue2) { create(:epic_issue, relative_position: 20) } let(:epic_issue2) { create(:epic_issue, relative_position: 20) }
before do before do
...@@ -151,7 +151,7 @@ describe 'Updating an epic tree' do ...@@ -151,7 +151,7 @@ describe 'Updating an epic tree' do
it 'returns the error message' do it 'returns the error message' do
post_graphql_mutation(mutation, current_user: current_user) post_graphql_mutation(mutation, current_user: current_user)
expect(mutation_response['errors']).to eq(['Epic issue not found for given params']) expect(mutation_response['errors']).to eq(['Both objects have to belong to the same parent epic.'])
end end
end end
end end
......
...@@ -12,10 +12,10 @@ describe Epics::TreeReorderService do ...@@ -12,10 +12,10 @@ describe Epics::TreeReorderService do
let(:issue2) { create(:issue, project: project) } let(:issue2) { create(:issue, project: project) }
let(:epic1) { create(:epic, group: group, parent: epic, relative_position: 10) } let(:epic1) { create(:epic, group: group, parent: epic, relative_position: 10) }
let(:epic2) { create(:epic, group: group, parent: epic, relative_position: 20) } 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_issue1) { create(:epic_issue, epic: epic, issue: issue1, relative_position: 30) }
let(:epic_issue2) { create(:epic_issue, epic: epic, issue: issue2, relative_position: 20) } let(:epic_issue2) { create(:epic_issue, epic: epic, issue: issue2, relative_position: 40) }
let(:relative_position) { :after } let(:relative_position) { 'after' }
let!(:tree_object_1) { epic1 } let!(:tree_object_1) { epic1 }
let!(:tree_object_2) { epic2 } let!(:tree_object_2) { epic2 }
let(:adjacent_reference_id) { GitlabSchema.id_from_object(tree_object_1) } let(:adjacent_reference_id) { GitlabSchema.id_from_object(tree_object_1) }
...@@ -32,10 +32,8 @@ describe Epics::TreeReorderService do ...@@ -32,10 +32,8 @@ describe Epics::TreeReorderService do
shared_examples 'error for the tree update' do |expected_error| shared_examples 'error for the tree update' do |expected_error|
it 'does not change relative_positions' do it 'does not change relative_positions' do
subject expect { subject }.not_to change { tree_object_1.relative_position }
expect { subject }.not_to change { tree_object_2.relative_position }
expect(tree_object_1.relative_position).to eq(10)
expect(tree_object_2.relative_position).to eq(20)
end end
it 'returns error status' do it 'returns error status' do
...@@ -47,7 +45,7 @@ describe Epics::TreeReorderService do ...@@ -47,7 +45,7 @@ describe Epics::TreeReorderService do
end end
end end
context 'when epics feature is enabled' do context 'when epics feature is not enabled' do
it_behaves_like 'error for the tree update', 'You don\'t have permissions to move the objects.' it_behaves_like 'error for the tree update', 'You don\'t have permissions to move the objects.'
end end
...@@ -65,16 +63,26 @@ describe Epics::TreeReorderService do ...@@ -65,16 +63,26 @@ describe Epics::TreeReorderService do
group.add_developer(user) group.add_developer(user)
end end
context 'when relative_position is not valid' do
let(:relative_position) { 'whatever' }
it_behaves_like 'error for the tree update', 'Relative position is not valid.'
end
context 'when moving EpicIssue' do context 'when moving EpicIssue' do
let!(:tree_object_1) { epic_issue1 } let!(:tree_object_1) { epic_issue1 }
let!(:tree_object_2) { epic_issue2 } 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 context 'when object being moved is not the same type as the switched object' do
let!(:tree_object_3) { epic1 }
let!(:tree_object_4) { epic2 }
let(:adjacent_reference_id) { GitlabSchema.id_from_object(epic2) } let(:adjacent_reference_id) { GitlabSchema.id_from_object(epic2) }
it_behaves_like 'error for the tree update', 'Provided objects are not the same type.' it 'reorders the objects' do
subject
expect(epic2.reload.relative_position).to be > tree_object_2.reload.relative_position
end
end end
context 'when no object to switch is provided' do context 'when no object to switch is provided' do
...@@ -85,8 +93,22 @@ describe Epics::TreeReorderService do ...@@ -85,8 +93,22 @@ describe Epics::TreeReorderService do
end end
end end
context 'when object being moved is from of another epic' do
before do
other_epic = create(:epic, group: group)
epic_issue2.update(epic: other_epic)
end
it_behaves_like 'error for the tree update', 'Both objects have to belong to the same parent epic.'
end
context 'when object being moved is not supported type' do context 'when object being moved is not supported type' do
let(:moving_object_id) { GitlabSchema.id_from_object(issue1) } let(:moving_object_id) { GitlabSchema.id_from_object(issue1) }
it_behaves_like 'error for the tree update', 'Only epics and epic_issues are supported.'
end
context 'when adjacent object is not supported type' do
let(:adjacent_reference_id) { GitlabSchema.id_from_object(issue2) } let(:adjacent_reference_id) { GitlabSchema.id_from_object(issue2) }
it_behaves_like 'error for the tree update', 'Only epics and epic_issues are supported.' it_behaves_like 'error for the tree update', 'Only epics and epic_issues are supported.'
...@@ -117,14 +139,6 @@ describe Epics::TreeReorderService do ...@@ -117,14 +139,6 @@ describe Epics::TreeReorderService do
let!(:tree_object_1) { epic1 } let!(:tree_object_1) { epic1 }
let!(:tree_object_2) { epic2 } 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 context 'when the reordered epics are not subepics of the base epic' do
let(:another_group) { create(:group) } let(:another_group) { create(:group) }
let(:another_epic) { create(:epic, group: another_group) } let(:another_epic) { create(:epic, group: another_group) }
...@@ -137,6 +151,15 @@ describe Epics::TreeReorderService do ...@@ -137,6 +151,15 @@ describe Epics::TreeReorderService do
it_behaves_like 'error for the tree update', 'You don\'t have permissions to move the objects.' it_behaves_like 'error for the tree update', 'You don\'t have permissions to move the objects.'
end end
context 'when object being moved is froms another epic' do
before do
other_epic = create(:epic, group: group)
epic2.update(parent: other_epic)
end
it_behaves_like 'error for the tree update', 'Both objects have to belong to the same parent epic.'
end
context 'when moving is successful' do context 'when moving is successful' do
it 'updates the links relative positions' do it 'updates the links relative positions' do
subject subject
......
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