diff --git a/app/graphql/mutations/concerns/mutations/work_items/update_arguments.rb b/app/graphql/mutations/concerns/mutations/work_items/update_arguments.rb index 66ec2c45cf8ed6a50a2e0f808973322b69e1c1b8..1f90f3945211418944853c8decc16c6f218f8154 100644 --- a/app/graphql/mutations/concerns/mutations/work_items/update_arguments.rb +++ b/app/graphql/mutations/concerns/mutations/work_items/update_arguments.rb @@ -21,6 +21,9 @@ module UpdateArguments argument :description_widget, ::Types::WorkItems::Widgets::DescriptionInputType, required: false, description: 'Input for description widget.' + argument :assignees_widget, ::Types::WorkItems::Widgets::AssigneesInputType, + required: false, + description: 'Input for assignees widget.' argument :hierarchy_widget, ::Types::WorkItems::Widgets::HierarchyUpdateInputType, required: false, description: 'Input for hierarchy widget.' diff --git a/app/graphql/types/work_items/widgets/assignees_input_type.rb b/app/graphql/types/work_items/widgets/assignees_input_type.rb new file mode 100644 index 0000000000000000000000000000000000000000..ee61bc73054c89b62f52edafb84d8cb6ed02795f --- /dev/null +++ b/app/graphql/types/work_items/widgets/assignees_input_type.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Types + module WorkItems + module Widgets + class AssigneesInputType < BaseInputObject + graphql_name 'WorkItemWidgetAssigneesInput' + + argument :assignee_ids, [::Types::GlobalIDType[::User]], + required: true, + description: 'Global IDs of assignees.', + prepare: ->(ids, _) { ids.map(&:model_id) } + end + end + end +end diff --git a/app/policies/work_item_policy.rb b/app/policies/work_item_policy.rb index 2fd84761a9bd317359315df9e05f9fa4e401ab77..1ccc152bc6bd568623b66aa196a9e6c8984d967f 100644 --- a/app/policies/work_item_policy.rb +++ b/app/policies/work_item_policy.rb @@ -8,6 +8,7 @@ class WorkItemPolicy < IssuePolicy rule { can?(:destroy_issue) | is_member_and_author }.enable :delete_work_item rule { can?(:update_issue) }.enable :update_work_item + rule { can?(:set_issue_metadata) }.enable :set_work_item_metadata rule { can?(:read_issue) }.enable :read_work_item # because IssuePolicy delegates to ProjectPolicy and diff --git a/app/services/work_items/widgets/assignees_service/update_service.rb b/app/services/work_items/widgets/assignees_service/update_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..9176b71c85eafd1e2607fe09b265ccfe84761700 --- /dev/null +++ b/app/services/work_items/widgets/assignees_service/update_service.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module WorkItems + module Widgets + module AssigneesService + class UpdateService < WorkItems::Widgets::BaseService + def before_update_in_transaction(params:) + return unless params.present? && params.has_key?(:assignee_ids) + return unless has_permission?(:set_work_item_metadata) + + assignee_ids = filter_assignees_count(params[:assignee_ids]) + assignee_ids = filter_assignee_permissions(assignee_ids) + + return if assignee_ids.sort == work_item.assignee_ids.sort + + work_item.assignee_ids = assignee_ids + work_item.touch + end + + private + + def filter_assignees_count(assignee_ids) + return assignee_ids if work_item.allows_multiple_assignees? + + assignee_ids.first(1) + end + + def filter_assignee_permissions(assignee_ids) + assignees = User.id_in(assignee_ids) + + assignees.select { |assignee| assignee.can?(:read_work_item, work_item) }.map(&:id) + end + end + end + end +end diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index e50f9170a9d4c10f5e3881208b1e8d0b0bc9c2e9..f535f23912b5faa94fbda77653e8392dcaabf56c 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -5741,6 +5741,7 @@ Input type: `WorkItemUpdateInput` | Name | Type | Description | | ---- | ---- | ----------- | +| <a id="mutationworkitemupdateassigneeswidget"></a>`assigneesWidget` | [`WorkItemWidgetAssigneesInput`](#workitemwidgetassigneesinput) | Input for assignees widget. | | <a id="mutationworkitemupdateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | | <a id="mutationworkitemupdateconfidential"></a>`confidential` | [`Boolean`](#boolean) | Sets the work item confidentiality. | | <a id="mutationworkitemupdatedescriptionwidget"></a>`descriptionWidget` | [`WorkItemWidgetDescriptionInput`](#workitemwidgetdescriptioninput) | Input for description widget. | @@ -22496,6 +22497,7 @@ A time-frame defined as a closed inclusive range of two dates. | Name | Type | Description | | ---- | ---- | ----------- | +| <a id="workitemupdatedtaskinputassigneeswidget"></a>`assigneesWidget` | [`WorkItemWidgetAssigneesInput`](#workitemwidgetassigneesinput) | Input for assignees widget. | | <a id="workitemupdatedtaskinputconfidential"></a>`confidential` | [`Boolean`](#boolean) | Sets the work item confidentiality. | | <a id="workitemupdatedtaskinputdescriptionwidget"></a>`descriptionWidget` | [`WorkItemWidgetDescriptionInput`](#workitemwidgetdescriptioninput) | Input for description widget. | | <a id="workitemupdatedtaskinputhierarchywidget"></a>`hierarchyWidget` | [`WorkItemWidgetHierarchyUpdateInput`](#workitemwidgethierarchyupdateinput) | Input for hierarchy widget. | @@ -22504,6 +22506,14 @@ A time-frame defined as a closed inclusive range of two dates. | <a id="workitemupdatedtaskinputstateevent"></a>`stateEvent` | [`WorkItemStateEvent`](#workitemstateevent) | Close or reopen a work item. | | <a id="workitemupdatedtaskinputtitle"></a>`title` | [`String`](#string) | Title of the work item. | +### `WorkItemWidgetAssigneesInput` + +#### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="workitemwidgetassigneesinputassigneeids"></a>`assigneeIds` | [`[UserID!]!`](#userid) | Global IDs of assignees. | + ### `WorkItemWidgetDescriptionInput` #### Arguments diff --git a/spec/graphql/types/work_items/widgets/assignees_input_type_spec.rb b/spec/graphql/types/work_items/widgets/assignees_input_type_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..2fcda2a43be03c72f1e29797ada24c7b7a8839ee --- /dev/null +++ b/spec/graphql/types/work_items/widgets/assignees_input_type_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ::Types::WorkItems::Widgets::AssigneesInputType do + it { expect(described_class.graphql_name).to eq('WorkItemWidgetAssigneesInput') } + + it { expect(described_class.arguments.keys).to match_array(%w[assigneeIds]) } +end diff --git a/spec/policies/work_item_policy_spec.rb b/spec/policies/work_item_policy_spec.rb index f3e8bd6a08b5e4960ad5b93f64e1e19c57c8d076..ed76ec1eccf408acc3cf27a09257d62693fd28fa 100644 --- a/spec/policies/work_item_policy_spec.rb +++ b/spec/policies/work_item_policy_spec.rb @@ -181,4 +181,24 @@ end end end + + describe 'set_work_item_metadata' do + context 'when user is reporter' do + let(:current_user) { reporter } + + it { is_expected.to be_allowed(:set_work_item_metadata) } + end + + context 'when user is guest' do + let(:current_user) { guest } + + it { is_expected.to be_disallowed(:set_work_item_metadata) } + + context 'when the work item is not persisted yet' do + let(:work_item_subject) { build(:work_item, project: project) } + + it { is_expected.to be_allowed(:set_work_item_metadata) } + end + end + end end diff --git a/spec/requests/api/graphql/mutations/work_items/update_spec.rb b/spec/requests/api/graphql/mutations/work_items/update_spec.rb index d5e1ec25a1383eee6989966104a1028951357779..909d6549fa5aff8f51154460a9e70b8e280938b7 100644 --- a/spec/requests/api/graphql/mutations/work_items/update_spec.rb +++ b/spec/requests/api/graphql/mutations/work_items/update_spec.rb @@ -427,6 +427,50 @@ end end + context 'when updating assignees' do + let(:fields) do + <<~FIELDS + workItem { + widgets { + type + ... on WorkItemWidgetAssignees { + assignees { + nodes { + id + username + } + } + } + } + } + errors + FIELDS + end + + let(:input) do + { 'assigneesWidget' => { 'assigneeIds' => [developer.to_global_id.to_s] } } + end + + it 'updates the work item assignee' do + expect do + post_graphql_mutation(mutation, current_user: current_user) + work_item.reload + end.to change(work_item, :assignee_ids).from([]).to([developer.id]) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response['workItem']['widgets']).to include( + { + 'type' => 'ASSIGNEES', + 'assignees' => { + 'nodes' => [ + { 'id' => developer.to_global_id.to_s, 'username' => developer.username } + ] + } + } + ) + end + end + context 'when unsupported widget input is sent' do let_it_be(:test_case) { create(:work_item_type, :default, :test_case, name: 'some_test_case_name') } let_it_be(:work_item) { create(:work_item, work_item_type: test_case, project: project) } diff --git a/spec/services/work_items/widgets/assignees_service/update_service_spec.rb b/spec/services/work_items/widgets/assignees_service/update_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..48927c4b05b4b9d8ebff73df00f2d83c85751b1d --- /dev/null +++ b/spec/services/work_items/widgets/assignees_service/update_service_spec.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe WorkItems::Widgets::AssigneesService::UpdateService, :freeze_time do + let_it_be(:reporter) { create(:user) } + let_it_be(:project) { create(:project, :private) } + let_it_be(:new_assignee) { create(:user) } + + let(:work_item) do + create(:work_item, project: project, updated_at: 1.day.ago) + end + + let(:widget) { work_item.widgets.find {|widget| widget.is_a?(WorkItems::Widgets::Assignees) } } + let(:current_user) { reporter } + let(:params) { { assignee_ids: [new_assignee.id] } } + + before_all do + project.add_reporter(reporter) + project.add_guest(new_assignee) + end + + describe '#before_update_in_transaction' do + subject do + described_class.new(widget: widget, current_user: current_user) + .before_update_in_transaction(params: params) + end + + it 'updates the assignees and sets updated_at to the current time' do + subject + + expect(work_item.assignee_ids).to contain_exactly(new_assignee.id) + expect(work_item.updated_at).to be_like_time(Time.current) + end + + context 'when passing an empty array' do + let(:params) { { assignee_ids: [] } } + + before do + work_item.assignee_ids = [reporter.id] + end + + it 'removes existing assignees' do + subject + + expect(work_item.assignee_ids).to be_empty + expect(work_item.updated_at).to be_like_time(Time.current) + end + end + + context 'when user does not have access' do + let(:current_user) { create(:user) } + + it 'does not update the assignees' do + subject + + expect(work_item.assignee_ids).to be_empty + expect(work_item.updated_at).to be_like_time(1.day.ago) + end + end + + context 'when multiple assignees are given' do + let(:params) { { assignee_ids: [new_assignee.id, reporter.id] } } + + context 'when work item allows multiple assignees' do + before do + allow(work_item).to receive(:allows_multiple_assignees?).and_return(true) + end + + it 'sets all the given assignees' do + subject + + expect(work_item.assignee_ids).to contain_exactly(new_assignee.id, reporter.id) + expect(work_item.updated_at).to be_like_time(Time.current) + end + end + + context 'when work item does not allow multiple assignees' do + before do + allow(work_item).to receive(:allows_multiple_assignees?).and_return(false) + end + + it 'only sets the first assignee' do + subject + + expect(work_item.assignee_ids).to contain_exactly(new_assignee.id) + expect(work_item.updated_at).to be_like_time(Time.current) + end + end + end + + context 'when assignee does not have access to the work item' do + let(:params) { { assignee_ids: [create(:user).id] } } + + it 'does not set the assignee' do + subject + + expect(work_item.assignee_ids).to be_empty + expect(work_item.updated_at).to be_like_time(1.day.ago) + end + end + + context 'when assignee ids are the same as the existing ones' do + before do + work_item.assignee_ids = [new_assignee.id] + end + + it 'does not touch updated_at' do + subject + + expect(work_item.assignee_ids).to contain_exactly(new_assignee.id) + expect(work_item.updated_at).to be_like_time(1.day.ago) + end + end + end +end