From 2e1b4b8953de735556cfb94f61168a01ac41a8ef Mon Sep 17 00:00:00 2001
From: Eugenia Grieff <egrieff@gitlab.com>
Date: Tue, 28 Jun 2022 15:41:53 +0000
Subject: [PATCH] Move description widget update to service layer

- Add services for description widget
---
 .../mutations/work_items/update_arguments.rb  |  3 ++
 app/graphql/mutations/work_items/update.rb    | 12 +++++
 .../mutations/work_items/update_widgets.rb    |  1 +
 app/models/work_items/widgets/description.rb  |  4 --
 app/services/work_items/update_service.rb     | 15 +++++-
 .../work_items/widgets/base_service.rb        | 14 +++++
 .../description_service/update_service.rb     | 15 ++++++
 doc/api/graphql/reference/index.md            |  2 +
 .../widgets/description_input_type_spec.rb    |  9 ++++
 .../mutations/work_items/update_spec.rb       | 35 ++++++++++++-
 .../work_items/update_widgets_spec.rb         | 51 +++++++------------
 .../update_service_spec.rb                    | 35 +++++++++++++
 ...date_description_widget_shared_examples.rb | 34 +++++++++++++
 13 files changed, 190 insertions(+), 40 deletions(-)
 create mode 100644 app/services/work_items/widgets/base_service.rb
 create mode 100644 app/services/work_items/widgets/description_service/update_service.rb
 create mode 100644 spec/graphql/types/work_items/widgets/description_input_type_spec.rb
 create mode 100644 spec/services/work_items/widgets/description_service/update_service_spec.rb
 create mode 100644 spec/support/shared_examples/graphql/mutations/work_items/update_description_widget_shared_examples.rb

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 6a91a097a177..7af55d42f2af 100644
--- a/app/graphql/mutations/concerns/mutations/work_items/update_arguments.rb
+++ b/app/graphql/mutations/concerns/mutations/work_items/update_arguments.rb
@@ -15,6 +15,9 @@ module UpdateArguments
         argument :title, GraphQL::Types::String,
                  required: false,
                  description: copy_field_description(Types::WorkItemType, :title)
+        argument :description_widget, ::Types::WorkItems::Widgets::DescriptionInputType,
+                 required: false,
+                 description: 'Input for description widget.'
       end
     end
   end
diff --git a/app/graphql/mutations/work_items/update.rb b/app/graphql/mutations/work_items/update.rb
index c495da00f41e..ff4aba4830f1 100644
--- a/app/graphql/mutations/work_items/update.rb
+++ b/app/graphql/mutations/work_items/update.rb
@@ -24,11 +24,13 @@ def resolve(id:, **attributes)
         end
 
         spam_params = ::Spam::SpamParams.new_from_request(request: context[:request])
+        widget_params = extract_widget_params(work_item, attributes)
 
         ::WorkItems::UpdateService.new(
           project: work_item.project,
           current_user: current_user,
           params: attributes,
+          widget_params: widget_params,
           spam_params: spam_params
         ).execute(work_item)
 
@@ -45,6 +47,16 @@ def resolve(id:, **attributes)
       def find_object(id:)
         GitlabSchema.find_by_gid(id)
       end
+
+      def extract_widget_params(work_item, attributes)
+        # Get the list of widgets for the work item's type to extract only the supported attributes
+        widget_keys = work_item.work_item_type.widgets.map(&:api_symbol)
+        widget_params = attributes.extract!(*widget_keys)
+
+        # Cannot use prepare to use `.to_h` on each input due to
+        # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/87472#note_945199865
+        widget_params.transform_values { |values| values.to_h }
+      end
     end
   end
 end
diff --git a/app/graphql/mutations/work_items/update_widgets.rb b/app/graphql/mutations/work_items/update_widgets.rb
index d19da0abaacb..7037b7e5a2a6 100644
--- a/app/graphql/mutations/work_items/update_widgets.rb
+++ b/app/graphql/mutations/work_items/update_widgets.rb
@@ -2,6 +2,7 @@
 
 module Mutations
   module WorkItems
+    # TODO: Deprecate in favor of using WorkItemUpdate. See https://gitlab.com/gitlab-org/gitlab/-/issues/366300
     class UpdateWidgets < BaseMutation
       graphql_name 'WorkItemUpdateWidgets'
       description "Updates the attributes of a work item's widgets by global ID." \
diff --git a/app/models/work_items/widgets/description.rb b/app/models/work_items/widgets/description.rb
index 35b6d295321c..1e84d172befb 100644
--- a/app/models/work_items/widgets/description.rb
+++ b/app/models/work_items/widgets/description.rb
@@ -4,10 +4,6 @@ module WorkItems
   module Widgets
     class Description < Base
       delegate :description, to: :work_item
-
-      def update(params:)
-        work_item.description = params[:description] if params&.key?(:description)
-      end
     end
   end
 end
diff --git a/app/services/work_items/update_service.rb b/app/services/work_items/update_service.rb
index 0b420881b4bb..7b50040a7162 100644
--- a/app/services/work_items/update_service.rb
+++ b/app/services/work_items/update_service.rb
@@ -6,6 +6,7 @@ def initialize(project:, current_user: nil, params: {}, spam_params: nil, widget
       super(project: project, current_user: current_user, params: params, spam_params: nil)
 
       @widget_params = widget_params
+      @widget_services = {}
     end
 
     private
@@ -24,8 +25,20 @@ def after_update(work_item)
 
     def execute_widgets(work_item:, callback:)
       work_item.widgets.each do |widget|
-        widget.try(callback, params: @widget_params[widget.class.api_symbol])
+        widget_service(widget).try(callback, params: @widget_params[widget.class.api_symbol])
       end
     end
+
+    def widget_service(widget)
+      service_class = begin
+        "WorkItems::Widgets::#{widget.type.capitalize}Service::UpdateService".constantize
+      rescue NameError
+        nil
+      end
+
+      return unless service_class
+
+      @widget_services[widget] ||= service_class.new(widget: widget, current_user: current_user)
+    end
   end
 end
diff --git a/app/services/work_items/widgets/base_service.rb b/app/services/work_items/widgets/base_service.rb
new file mode 100644
index 000000000000..72debc272bd0
--- /dev/null
+++ b/app/services/work_items/widgets/base_service.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module WorkItems
+  module Widgets
+    class BaseService < ::BaseService
+      attr_reader :widget, :current_user
+
+      def initialize(widget:, current_user:)
+        @widget = widget
+        @current_user = current_user
+      end
+    end
+  end
+end
diff --git a/app/services/work_items/widgets/description_service/update_service.rb b/app/services/work_items/widgets/description_service/update_service.rb
new file mode 100644
index 000000000000..e63b6b2ee6c1
--- /dev/null
+++ b/app/services/work_items/widgets/description_service/update_service.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module WorkItems
+  module Widgets
+    module DescriptionService
+      class UpdateService < WorkItems::Widgets::BaseService
+        def update(params: {})
+          return unless params.present? && params[:description]
+
+          widget.work_item.description = params[:description]
+        end
+      end
+    end
+  end
+end
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index af91c842838e..c2d62421425e 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -5608,6 +5608,7 @@ Input type: `WorkItemUpdateInput`
 | Name | Type | Description |
 | ---- | ---- | ----------- |
 | <a id="mutationworkitemupdateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
+| <a id="mutationworkitemupdatedescriptionwidget"></a>`descriptionWidget` | [`WorkItemWidgetDescriptionInput`](#workitemwidgetdescriptioninput) | Input for description widget. |
 | <a id="mutationworkitemupdateid"></a>`id` | [`WorkItemID!`](#workitemid) | Global ID of the work item. |
 | <a id="mutationworkitemupdatestateevent"></a>`stateEvent` | [`WorkItemStateEvent`](#workitemstateevent) | Close or reopen a work item. |
 | <a id="mutationworkitemupdatetitle"></a>`title` | [`String`](#string) | Title of the work item. |
@@ -21925,6 +21926,7 @@ A time-frame defined as a closed inclusive range of two dates.
 
 | Name | Type | Description |
 | ---- | ---- | ----------- |
+| <a id="workitemupdatedtaskinputdescriptionwidget"></a>`descriptionWidget` | [`WorkItemWidgetDescriptionInput`](#workitemwidgetdescriptioninput) | Input for description widget. |
 | <a id="workitemupdatedtaskinputid"></a>`id` | [`WorkItemID!`](#workitemid) | Global ID of the work item. |
 | <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. |
diff --git a/spec/graphql/types/work_items/widgets/description_input_type_spec.rb b/spec/graphql/types/work_items/widgets/description_input_type_spec.rb
new file mode 100644
index 000000000000..81c64bc38ab4
--- /dev/null
+++ b/spec/graphql/types/work_items/widgets/description_input_type_spec.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ::Types::WorkItems::Widgets::DescriptionInputType do
+  it { expect(described_class.graphql_name).to eq('WorkItemWidgetDescriptionInput') }
+
+  it { expect(described_class.arguments.keys).to match_array(%w[description]) }
+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 71b03103115f..7a160819a418 100644
--- a/spec/requests/api/graphql/mutations/work_items/update_spec.rb
+++ b/spec/requests/api/graphql/mutations/work_items/update_spec.rb
@@ -11,8 +11,17 @@
 
   let(:work_item_event) { 'CLOSE' }
   let(:input) { { 'stateEvent' => work_item_event, 'title' => 'updated title' } }
+  let(:fields) do
+    <<~FIELDS
+    workItem {
+      state
+      title
+    }
+    errors
+    FIELDS
+  end
 
-  let(:mutation) { graphql_mutation(:workItemUpdate, input.merge('id' => work_item.to_global_id.to_s)) }
+  let(:mutation) { graphql_mutation(:workItemUpdate, input.merge('id' => work_item.to_global_id.to_s), fields) }
 
   let(:mutation_response) { graphql_mutation_response(:work_item_update) }
 
@@ -80,5 +89,29 @@
         expect(mutation_response['errors']).to contain_exactly('`work_items` feature flag disabled for this project')
       end
     end
+
+    context 'with description widget input' do
+      let(:fields) do
+        <<~FIELDS
+        workItem {
+          description
+          widgets {
+            type
+            ... on WorkItemWidgetDescription {
+                    description
+            }
+          }
+        }
+        errors
+        FIELDS
+      end
+
+      it_behaves_like 'update work item description widget' do
+        let(:new_description) { 'updated description' }
+        let(:input) do
+          { 'descriptionWidget' => { 'description' => new_description } }
+        end
+      end
+    end
   end
 end
diff --git a/spec/requests/api/graphql/mutations/work_items/update_widgets_spec.rb b/spec/requests/api/graphql/mutations/work_items/update_widgets_spec.rb
index 595d8fe97ede..2a5cb937a2f9 100644
--- a/spec/requests/api/graphql/mutations/work_items/update_widgets_spec.rb
+++ b/spec/requests/api/graphql/mutations/work_items/update_widgets_spec.rb
@@ -9,16 +9,23 @@
   let_it_be(:developer) { create(:user).tap { |user| project.add_developer(user) } }
   let_it_be(:work_item, refind: true) { create(:work_item, project: project) }
 
-  let(:input) do
-    {
-      'descriptionWidget' => { 'description' => 'updated description' }
+  let(:input) { { 'descriptionWidget' => { 'description' => 'updated description' } } }
+  let(:mutation_response) { graphql_mutation_response(:work_item_update_widgets) }
+  let(:mutation) do
+    graphql_mutation(:workItemUpdateWidgets, input.merge('id' => work_item.to_global_id.to_s), <<~FIELDS)
+    errors
+    workItem {
+      description
+      widgets {
+        type
+        ... on WorkItemWidgetDescription {
+                description
+        }
+      }
     }
+    FIELDS
   end
 
-  let(:mutation) { graphql_mutation(:workItemUpdateWidgets, input.merge('id' => work_item.to_global_id.to_s)) }
-
-  let(:mutation_response) { graphql_mutation_response(:work_item_update_widgets) }
-
   context 'the user is not allowed to update a work item' do
     let(:current_user) { create(:user) }
 
@@ -28,32 +35,8 @@
   context 'when user has permissions to update a work item', :aggregate_failures do
     let(:current_user) { developer }
 
-    context 'when the updated work item is not valid' do
-      it 'returns validation errors without the work item' do
-        errors = ActiveModel::Errors.new(work_item).tap { |e| e.add(:description, 'error message') }
-
-        allow_next_found_instance_of(::WorkItem) do |instance|
-          allow(instance).to receive(:valid?).and_return(false)
-          allow(instance).to receive(:errors).and_return(errors)
-        end
-
-        post_graphql_mutation(mutation, current_user: current_user)
-
-        expect(mutation_response['workItem']).to be_nil
-        expect(mutation_response['errors']).to match_array(['Description error message'])
-      end
-    end
-
-    it 'updates the work item widgets' do
-      expect do
-        post_graphql_mutation(mutation, current_user: current_user)
-        work_item.reload
-      end.to change(work_item, :description).from(nil).to('updated description')
-
-      expect(response).to have_gitlab_http_status(:success)
-      expect(mutation_response['workItem']).to include(
-        'title' => work_item.title
-      )
+    it_behaves_like 'update work item description widget' do
+      let(:new_description) { 'updated description' }
     end
 
     it_behaves_like 'has spam protection' do
@@ -69,7 +52,7 @@
         expect do
           post_graphql_mutation(mutation, current_user: current_user)
           work_item.reload
-        end.to not_change(work_item, :title)
+        end.to not_change(work_item, :description)
 
         expect(mutation_response['errors']).to contain_exactly('`work_items` feature flag disabled for this project')
       end
diff --git a/spec/services/work_items/widgets/description_service/update_service_spec.rb b/spec/services/work_items/widgets/description_service/update_service_spec.rb
new file mode 100644
index 000000000000..a2eceb97f096
--- /dev/null
+++ b/spec/services/work_items/widgets/description_service/update_service_spec.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe WorkItems::Widgets::DescriptionService::UpdateService do
+  let_it_be(:user) { create(:user) }
+  let_it_be(:project) { create(:project) }
+  let_it_be_with_reload(:work_item) { create(:work_item, project: project, description: 'old description') }
+
+  let(:widget) { work_item.widgets.find {|widget| widget.is_a?(WorkItems::Widgets::Description) } }
+
+  describe '#update' do
+    subject { described_class.new(widget: widget, current_user: user).update(params: params) } # rubocop:disable Rails/SaveBang
+
+    context 'when description param is present' do
+      let(:params) { { description: 'updated description' } }
+
+      it 'correctly sets work item description value' do
+        subject
+
+        expect(work_item.description).to eq('updated description')
+      end
+    end
+
+    context 'when description param is not present' do
+      let(:params) { {} }
+
+      it 'does not change work item description value' do
+        subject
+
+        expect(work_item.description).to eq('old description')
+      end
+    end
+  end
+end
diff --git a/spec/support/shared_examples/graphql/mutations/work_items/update_description_widget_shared_examples.rb b/spec/support/shared_examples/graphql/mutations/work_items/update_description_widget_shared_examples.rb
new file mode 100644
index 000000000000..56c2ca22e15c
--- /dev/null
+++ b/spec/support/shared_examples/graphql/mutations/work_items/update_description_widget_shared_examples.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'update work item description widget' do
+  it 'updates the description widget' do
+    expect do
+      post_graphql_mutation(mutation, current_user: current_user)
+      work_item.reload
+    end.to change(work_item, :description).from(nil).to(new_description)
+
+    expect(response).to have_gitlab_http_status(:success)
+    expect(mutation_response['workItem']['widgets']).to include(
+      {
+        'description' => new_description,
+        'type' => 'DESCRIPTION'
+      }
+    )
+  end
+
+  context 'when the updated work item is not valid' do
+    it 'returns validation errors without the work item' do
+      errors = ActiveModel::Errors.new(work_item).tap { |e| e.add(:description, 'error message') }
+
+      allow_next_found_instance_of(::WorkItem) do |instance|
+        allow(instance).to receive(:valid?).and_return(false)
+        allow(instance).to receive(:errors).and_return(errors)
+      end
+
+      post_graphql_mutation(mutation, current_user: current_user)
+
+      expect(mutation_response['workItem']).to be_nil
+      expect(mutation_response['errors']).to match_array(['Description error message'])
+    end
+  end
+end
-- 
GitLab