Skip to content
Snippets Groups Projects
Verified Commit 780029ce authored by Eugenia Grieff's avatar Eugenia Grieff :two: Committed by GitLab
Browse files

Add work items hierarchy reorder mutation

This mutation allows us to reorder a child in the hierarchy tree,
including moving the item under a new parent

Changelog: added
EE: true
parent c6904789
No related branches found
No related tags found
4 merge requests!162538Backport 17-2: Handle empty ff merge in from train ref strategy,!162537Backport 17-1: Handle empty ff merge in from train ref strategy,!162233Draft: Script to update Topology Service Gem,!161319Add work items hierarchy reorder mutation
# frozen_string_literal: true
module Mutations
module WorkItems
module Hierarchy
class Reorder < BaseMutation
graphql_name 'workItemsHierarchyReorder'
description 'Reorder a work item in the hierarchy tree.'
argument :id, ::Types::GlobalIDType[::WorkItem],
required: true, description: 'Global ID of the work item to be reordered.'
argument :adjacent_work_item_id,
::Types::GlobalIDType[::WorkItem],
required: false,
description: 'ID of the work item to be switched with.'
argument :parent_id, ::Types::GlobalIDType[::WorkItem],
required: false,
description: 'Global ID of the new parent work item.'
argument :relative_position,
Types::RelativePositionTypeEnum,
required: false,
description: 'Type of switch. Valid values are `BEFORE` or `AFTER`.'
field :work_item, Types::WorkItemType,
null: true, description: 'Work item after mutation.'
field :adjacent_work_item, Types::WorkItemType,
null: true, description: 'Adjacent work item after mutation.'
field :parent_work_item, Types::WorkItemType,
null: true, description: "Work item's parent after mutation."
authorize :read_work_item
def ready?(**args)
validate_position_args!(args)
@work_item = authorized_find!(id: args.delete(:id))
@adjacent_item = authorized_find!(id: args.delete(:adjacent_work_item_id)) if args[:adjacent_work_item_id]
new_parent = authorized_find!(id: args.delete(:parent_id)) if args[:parent_id]
@parent = new_parent || work_item.work_item_parent
validate_parent!
super
end
def resolve(**args)
arguments = {
target_issuable: work_item,
adjacent_work_item: adjacent_item,
relative_position: args.delete(:relative_position)
}
service_response = ::WorkItems::ParentLinks::ReorderService.new(parent, current_user, arguments).execute
{
work_item: work_item,
adjacent_work_item: adjacent_item,
parent_work_item: parent,
errors: service_response[:status] == :error ? Array.wrap(service_response[:message]) : []
}
end
private
attr_reader :work_item, :parent, :adjacent_item
def validate_parent!
return unless adjacent_item
return if parent == adjacent_item.work_item_parent
raise Gitlab::Graphql::Errors::ArgumentError,
_("The adjacent work item's parent must match the moving work item's parent.")
end
def validate_position_args!(args)
return unless args.slice(:adjacent_work_item_id, :relative_position).one?
raise Gitlab::Graphql::Errors::ArgumentError,
_('Both adjacentWorkItemId and relativePosition are required.')
end
def find_object(id:)
GitlabSchema.object_from_id(id, expected_type: ::WorkItem).sync
end
end
end
end
end
......@@ -208,6 +208,7 @@ class MutationType < BaseObject
mount_mutation Mutations::WorkItems::LinkedItems::Add, alpha: { milestone: '16.3' }
mount_mutation Mutations::WorkItems::LinkedItems::Remove, alpha: { milestone: '16.3' }
mount_mutation Mutations::WorkItems::AddClosingMergeRequest, alpha: { milestone: '17.1' }
mount_mutation Mutations::WorkItems::Hierarchy::Reorder, alpha: { milestone: '17.3' }
mount_mutation Mutations::Users::SavedReplies::Create
mount_mutation Mutations::Users::SavedReplies::Update
mount_mutation Mutations::Users::SavedReplies::Destroy
......
......@@ -28,8 +28,14 @@ def reorder(link, adjacent_work_item, relative_position)
# overriden in EE
def move_link(link, adjacent_work_item, relative_position)
link.move_before(adjacent_work_item.parent_link) if relative_position == 'BEFORE'
link.move_after(adjacent_work_item.parent_link) if relative_position == 'AFTER'
if relative_position
link.move_before(adjacent_work_item.parent_link) if relative_position == 'BEFORE'
link.move_after(adjacent_work_item.parent_link) if relative_position == 'AFTER'
elsif link.changes.include?(:work_item_parent_id)
# position item at the start of the list if parent changed and relative_position is not provided
link.move_to_start
end
link.save
end
......
......@@ -10496,6 +10496,36 @@ Input type: `WorkItemUpdateInput`
| <a id="mutationworkitemupdateerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
| <a id="mutationworkitemupdateworkitem"></a>`workItem` | [`WorkItem`](#workitem) | Updated work item. |
 
### `Mutation.workItemsHierarchyReorder`
Reorder a work item in the hierarchy tree.
DETAILS:
**Introduced** in GitLab 17.3.
**Status**: Experiment.
Input type: `workItemsHierarchyReorderInput`
#### Arguments
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="mutationworkitemshierarchyreorderadjacentworkitemid"></a>`adjacentWorkItemId` | [`WorkItemID`](#workitemid) | ID of the work item to be switched with. |
| <a id="mutationworkitemshierarchyreorderclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationworkitemshierarchyreorderid"></a>`id` | [`WorkItemID!`](#workitemid) | Global ID of the work item to be reordered. |
| <a id="mutationworkitemshierarchyreorderparentid"></a>`parentId` | [`WorkItemID`](#workitemid) | Global ID of the new parent work item. |
| <a id="mutationworkitemshierarchyreorderrelativeposition"></a>`relativePosition` | [`RelativePositionType`](#relativepositiontype) | Type of switch. Valid values are `BEFORE` or `AFTER`. |
#### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="mutationworkitemshierarchyreorderadjacentworkitem"></a>`adjacentWorkItem` | [`WorkItem`](#workitem) | Adjacent work item after mutation. |
| <a id="mutationworkitemshierarchyreorderclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationworkitemshierarchyreordererrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
| <a id="mutationworkitemshierarchyreorderparentworkitem"></a>`parentWorkItem` | [`WorkItem`](#workitem) | Work item's parent after mutation. |
| <a id="mutationworkitemshierarchyreorderworkitem"></a>`workItem` | [`WorkItem`](#workitem) | Work item after mutation. |
### `Mutation.workspaceCreate`
 
Input type: `WorkspaceCreateInput`
......@@ -19,7 +19,7 @@ def execute
def move_link(link, adjacent_work_item, relative_position)
parent_changed = link.changes.include?(:work_item_parent_id)
create_missing_synced_link!(adjacent_work_item)
return unless adjacent_work_item.parent_link || parent_changed
return unless adjacent_work_item&.parent_link || parent_changed
return super unless sync_to_epic?(link)
ApplicationRecord.transaction do
......@@ -30,6 +30,8 @@ def move_link(link, adjacent_work_item, relative_position)
end
def create_missing_synced_link!(adjacent_work_item)
return unless adjacent_work_item
adjacent_parent_link = adjacent_work_item.parent_link
# if issuable is an epic, we can create the missing parent link between epic work item and adjacent_work_item
return unless adjacent_parent_link.blank? && adjacent_work_item.synced_epic
......@@ -81,6 +83,8 @@ def sync_to_epic?(link)
end
def reorder_synced_object(synced_moving_object, adjacent_work_item, relative_position)
return unless adjacent_work_item
synced_adjacent_object = synced_object_for(adjacent_work_item)
return unless synced_adjacent_object
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Reorder a work item in the hierarchy tree', feature_category: :team_planning do
include GraphqlHelpers
let_it_be(:group) { create(:group, :private) }
let_it_be(:project) { create(:project, group: group) }
let_it_be(:guest) { create(:user, guest_of: group) }
let_it_be(:parent_epic) { create(:work_item, :epic_with_legacy_epic, namespace: group) }
let_it_be(:child_epic1) { create(:work_item, :epic_with_legacy_epic, namespace: group) }
let_it_be(:child_epic2) { create(:work_item, :epic_with_legacy_epic, namespace: group) }
let_it_be(:child_epic1_link) do
create(:parent_link, work_item_parent: parent_epic, work_item: child_epic1, relative_position: 20)
end
let_it_be(:child_epic2_link) do
create(:parent_link, work_item_parent: parent_epic, work_item: child_epic2, relative_position: 30)
end
let(:mutation) do
graphql_mutation(:workItemsHierarchyReorder, input.merge('id' => work_item.to_global_id.to_s), fields)
end
let(:mutation_response) { graphql_mutation_response(:work_items_hierarchy_reorder) }
describe 'reordering' do
let(:fields) do
<<~FIELDS
workItem {
id
}
adjacentWorkItem {
id
}
parentWorkItem {
id
}
errors
FIELDS
end
before do
stub_licensed_features(epics: true, subepics: true)
end
shared_examples 'reorders child work item' do
shared_examples 'reorders item position' do
it 'moves the item to the specified position in relation to the adjacent item' do
post_graphql_mutation(mutation, current_user: current_user)
expect(response).to have_gitlab_http_status(:success)
expect(parent.reload.work_item_children_by_relative_position).to match_array(expected_items_in_order)
expect(mutation_response['workItem']['id']).to eq(work_item.to_gid.to_s)
expect(mutation_response['parentWorkItem']['id']).to eq(parent.to_gid.to_s)
expect(mutation_response['adjacentWorkItem']['id']).to eq(adjacent_item_id)
end
end
context 'when user lacks permissions' do
let(:current_user) { create(:user) }
let(:input) do
{ 'adjacentWorkItemId' => child_epic1.to_gid.to_s, 'relativePosition' => 'AFTER' }
end
it 'returns an error' do
post_graphql_mutation(mutation, current_user: current_user)
expect_graphql_errors_to_include(
"The resource that you are attempting to access does not " \
"exist or you don't have permission to perform this action"
)
end
end
context 'when user has permissions' do
let(:current_user) { guest }
it_behaves_like 'reorders item position' do
let(:input) { { 'adjacentWorkItemId' => child_epic1.to_gid.to_s, 'relativePosition' => 'AFTER' } }
let(:expected_items_in_order) { [child_epic1, work_item, child_epic2] }
let(:adjacent_item_id) { child_epic1.to_gid.to_s }
let(:parent) { parent_epic }
end
it_behaves_like 'reorders item position' do
let(:input) { { 'adjacentWorkItemId' => child_epic1.to_gid.to_s, 'relativePosition' => 'BEFORE' } }
let(:expected_items_in_order) { [work_item, child_epic1, child_epic2] }
let(:adjacent_item_id) { child_epic1.to_gid.to_s }
let(:parent) { parent_epic }
end
context 'when moving under a new parent' do
let_it_be(:subepic1) { create(:work_item, :epic_with_legacy_epic, namespace: group) }
let_it_be(:subepic2) { create(:work_item, :epic_with_legacy_epic, namespace: group) }
let_it_be(:subepic1_link) do
create(:parent_link, work_item_parent: child_epic2, work_item: subepic1, relative_position: 10)
end
let_it_be(:subepic2_link) do
create(:parent_link, work_item_parent: child_epic2, work_item: subepic2, relative_position: 20)
end
context 'when relative position is not present' do
let(:input) { { 'parentId' => child_epic2.to_gid.to_s } }
it 'is positions the item at the top of the list' do
post_graphql_mutation(mutation, current_user: current_user)
expect(response).to have_gitlab_http_status(:success)
expect(child_epic2.reload.work_item_children_by_relative_position)
.to match_array([work_item, subepic1, subepic2])
expect(mutation_response['workItem']['id']).to eq(work_item.to_gid.to_s)
expect(mutation_response['parentWorkItem']['id']).to eq(child_epic2.to_gid.to_s)
expect(mutation_response['adjacentWorkItem']).to be_nil
end
end
context 'when relative position is present' do
it_behaves_like 'reorders item position' do
let(:input) do
{
'parentId' => child_epic2.to_gid.to_s,
'adjacentWorkItemId' => subepic1.to_gid.to_s,
'relativePosition' => 'AFTER'
}
end
let(:expected_items_in_order) { [subepic1, work_item, subepic2] }
let(:parent) { child_epic2 }
let(:adjacent_item_id) { subepic1.to_gid.to_s }
end
it_behaves_like 'reorders item position' do
let(:input) do
{
'parentId' => child_epic2.to_gid.to_s,
'adjacentWorkItemId' => subepic1.to_gid.to_s,
'relativePosition' => 'BEFORE'
}
end
let(:expected_items_in_order) { [work_item, subepic1, subepic2] }
let(:parent) { child_epic2 }
let(:adjacent_item_id) { subepic1.to_gid.to_s }
end
end
end
end
end
context 'when moving a child issue' do
let_it_be_with_reload(:work_item_issue) { create(:work_item, project: project) }
let_it_be(:child_issue_link) do
create(:parent_link, work_item_parent: parent_epic, work_item: work_item_issue, relative_position: 40)
end
it_behaves_like 'reorders child work item' do
let(:work_item) { work_item_issue }
end
end
context 'when moving a child epic' do
let_it_be_with_reload(:work_item_epic) { create(:work_item, :epic_with_legacy_epic, namespace: group) }
let_it_be(:child_epic1_link) do
create(:parent_link, work_item_parent: parent_epic, work_item: work_item_epic, relative_position: 40)
end
it_behaves_like 'reorders child work item' do
let(:work_item) { work_item_epic }
end
end
end
end
......@@ -9136,6 +9136,9 @@ msgstr ""
msgid "Both SSH and HTTP(S)"
msgstr ""
 
msgid "Both adjacentWorkItemId and relativePosition are required."
msgstr ""
msgid "Branch"
msgstr ""
 
......@@ -53160,6 +53163,9 @@ msgstr ""
msgid "The `/merge` quick action requires the SHA of the head of the branch."
msgstr ""
 
msgid "The adjacent work item's parent must match the moving work item's parent."
msgstr ""
msgid "The application will be used where the client secret can be kept confidential. Native mobile apps and Single Page Apps are considered non-confidential."
msgstr ""
 
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Mutations::WorkItems::Hierarchy::Reorder, feature_category: :team_planning do
let_it_be(:project) { create(:project) }
let_it_be(:developer) { create(:user, developer_of: project) }
let_it_be(:current_work_item) { create(:work_item, :task, project: project) }
let_it_be(:parent_work_item) { create(:work_item, project: project) }
subject(:mutation) { described_class.new(object: nil, context: { current_user: current_user }, field: nil) }
describe '#ready?' do
let(:current_user) { developer }
let(:current_gid) { current_work_item.to_gid.to_s }
let(:parent_gid) { parent_work_item.to_gid.to_s }
let(:valid_arguments) { { id: current_gid, parent_id: parent_gid } }
it { is_expected.to be_ready(**valid_arguments) }
context 'when arguments are invalid' do
context 'when a adjacentWorkItemId argument is missing' do
let(:arguments) { { id: current_gid, relative_position: "AFTER" } }
it 'raises error' do
expect { mutation.ready?(**arguments) }
.to raise_error(
Gitlab::Graphql::Errors::ArgumentError,
'Both adjacentWorkItemId and relativePosition are required.'
)
end
end
context "when adjacent item's parent doesn't match the work item's parent" do
let_it_be(:invalid_adjacent) { create(:work_item, :task, project: project) }
let(:arguments) do
{
id: current_gid,
adjacent_work_item_id: invalid_adjacent.to_gid.to_s,
parent_id: parent_gid,
relative_position: "AFTER"
}
end
it 'raises error' do
expect { mutation.ready?(**arguments) }
.to raise_error(
Gitlab::Graphql::Errors::ArgumentError,
"The adjacent work item's parent must match the moving work item's parent."
)
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Reorder a work item in the hierarchy tree', feature_category: :team_planning do
include GraphqlHelpers
let_it_be(:group) { create(:group, :private) }
let_it_be(:project) { create(:project, group: group) }
let_it_be(:guest) { create(:user, guest_of: group) }
let_it_be(:parent_work_item) { create(:work_item, :issue, project: project) }
let_it_be(:child1) { create(:work_item, :task, project: project) }
let_it_be(:child2) { create(:work_item, :task, project: project) }
let_it_be(:child3) { create(:work_item, :task, project: project) }
let_it_be(:child1_link) do
create(:parent_link, work_item_parent: parent_work_item, work_item: child1, relative_position: 20)
end
let_it_be(:child2_link) do
create(:parent_link, work_item_parent: parent_work_item, work_item: child2, relative_position: 30)
end
let_it_be(:child3_link) do
create(:parent_link, work_item_parent: parent_work_item, work_item: child3, relative_position: 40)
end
let(:mutation) do
graphql_mutation(:workItemsHierarchyReorder, input.merge('id' => work_item.to_global_id.to_s), fields)
end
let(:mutation_response) { graphql_mutation_response(:work_items_hierarchy_reorder) }
describe 'reordering' do
let(:work_item) { child3 }
let(:fields) do
<<~FIELDS
workItem {
id
}
adjacentWorkItem {
id
}
parentWorkItem {
id
}
errors
FIELDS
end
context 'when user lacks permissions' do
let(:current_user) { create(:user) }
let(:input) do
{ 'adjacentWorkItemId' => child1.to_gid.to_s, 'relativePosition' => 'AFTER' }
end
it 'returns an error' do
post_graphql_mutation(mutation, current_user: current_user)
expect_graphql_errors_to_include(
"The resource that you are attempting to access does not " \
"exist or you don't have permission to perform this action"
)
end
end
context 'when user has permissions' do |position|
let(:current_user) { guest }
let(:input) do
{ 'adjacentWorkItemId' => child1.to_gid.to_s, 'relativePosition' => position }
end
shared_examples 'reorders item position' do
it 'moves the item to the specified position in relation to the adjacent item' do
post_graphql_mutation(mutation, current_user: current_user)
expect(response).to have_gitlab_http_status(:success)
expect(parent_work_item.reload.work_item_children_by_relative_position).to match_array(reorders_items)
expect(mutation_response['workItem']['id']).to eq(work_item.to_gid.to_s)
expect(mutation_response['parentWorkItem']['id']).to eq(parent_work_item.to_gid.to_s)
expect(mutation_response['adjacentWorkItem']['id']).to eq(child1.to_gid.to_s)
end
end
it_behaves_like 'reorders item position', 'AFTER' do
let(:reorders_items) { [child1, work_item, child2] }
end
it_behaves_like 'reorders item position', 'BEFORE' do
let(:reorders_items) { [work_item, child1, child2] }
end
end
end
end
......@@ -16,7 +16,7 @@
let(:params) { { target_issuable: work_item } }
let(:relative_range) { [top_adjacent, last_adjacent].map(&:parent_link).map(&:relative_position) }
subject { described_class.new(parent, user, params).execute }
subject(:reorder) { described_class.new(parent, user, params).execute }
before do
project.add_guest(guest)
......@@ -166,6 +166,22 @@
end
end
end
context 'when no adjacent item or relative position is provided' do
let(:params) { { target_issuable: work_item } }
it 'returns success status and processed links', :aggregate_failures do
expect(reorder.keys).to match_array([:status, :created_references])
expect(reorder[:status]).to eq(:success)
expect(reorder[:created_references].map(&:work_item_id)).to match_array([work_item.id])
end
it 'places the item at the top of the list' do
reorder
expect(work_item.parent_link.relative_position).to be < top_adjacent.parent_link.relative_position
end
end
end
end
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment