From d4d761fc83660692d39b03c50b7ab2a18417503c Mon Sep 17 00:00:00 2001 From: Joseph Wambua <jjoshua@gitlab.com> Date: Thu, 15 Feb 2024 19:47:24 +0000 Subject: [PATCH] Add remove_child quick action for work items This quick action removes a work item as a child to the current work item. Changelog: added --- app/models/work_items/widgets/hierarchy.rb | 6 ++- .../widgets/hierarchy_service/base_service.rb | 25 ++++++--- ...27_i_quickactions_remove_child_monthly.yml | 26 +++++++++ ...528_i_quickactions_remove_child_weekly.yml | 26 +++++++++ doc/user/project/quick_actions.md | 1 + lib/gitlab/quick_actions/work_item_actions.rb | 19 ++++++- .../hll_redis_legacy_events.yml | 1 + locale/gitlab.pot | 15 ++++-- .../notes/quick_actions_service_spec.rb | 42 +++++++++++++++ .../quick_actions/interpret_service_spec.rb | 53 +++++++++++++++++++ .../hierarchy_service/update_service_spec.rb | 24 +++++++-- 11 files changed, 223 insertions(+), 15 deletions(-) create mode 100644 config/metrics/counts_28d/20240208152527_i_quickactions_remove_child_monthly.yml create mode 100644 config/metrics/counts_7d/20240208152528_i_quickactions_remove_child_weekly.yml diff --git a/app/models/work_items/widgets/hierarchy.rb b/app/models/work_items/widgets/hierarchy.rb index f184ceefef91e..c994d0964b209 100644 --- a/app/models/work_items/widgets/hierarchy.rb +++ b/app/models/work_items/widgets/hierarchy.rb @@ -16,11 +16,11 @@ def ancestors end def self.quick_action_commands - [:set_parent, :add_child, :remove_parent] + [:set_parent, :add_child, :remove_parent, :remove_child] end def self.quick_action_params - [:set_parent, :add_child, :remove_parent] + [:set_parent, :add_child, :remove_parent, :remove_child] end def self.process_quick_action_param(param_name, value) @@ -28,6 +28,8 @@ def self.process_quick_action_param(param_name, value) if [:set_parent, :remove_parent].include?(param_name) { parent: value.is_a?(WorkItem) ? value : nil } + elsif param_name == :remove_child + { remove_child: value } else { children: value } end diff --git a/app/services/work_items/widgets/hierarchy_service/base_service.rb b/app/services/work_items/widgets/hierarchy_service/base_service.rb index 45393eab58ccf..8f7cf38e8744a 100644 --- a/app/services/work_items/widgets/hierarchy_service/base_service.rb +++ b/app/services/work_items/widgets/hierarchy_service/base_service.rb @@ -7,12 +7,14 @@ class BaseService < WorkItems::Widgets::BaseService private def handle_hierarchy_changes(params) - return incompatible_args_error if incompatible_args?(params) + return incompatible_args_error if params.slice(*mutually_exclusive_args).size > 1 if params.key?(:parent) update_work_item_parent(params.delete(:parent)) elsif params.key?(:children) update_work_item_children(params.delete(:children)) + elsif params.key?(:remove_child) + remove_child(params.delete(:remove_child)) else invalid_args_error(params) end @@ -33,26 +35,37 @@ def set_parent(parent) end # rubocop: disable CodeReuse/ActiveRecord - def remove_parent - link = ::WorkItems::ParentLink.find_by(work_item: work_item) + def remove_parent_link(child) + link = ::WorkItems::ParentLink.find_by(work_item: child) return success unless link.present? ::WorkItems::ParentLinks::DestroyService.new(link, current_user).execute end # rubocop: enable CodeReuse/ActiveRecord + def remove_parent + remove_parent_link(work_item) + end + + def remove_child(child) + remove_parent_link(child) + end + def update_work_item_children(children) ::WorkItems::ParentLinks::CreateService .new(work_item, current_user, { issuable_references: children }) .execute end - def incompatible_args?(params) - params[:children] && params[:parent] + def mutually_exclusive_args + [:children, :parent, :remove_child] end def incompatible_args_error - error(_('A Work Item can be a parent or a child, but not both.')) + error(format( + _("One and only one of %{params} is required"), + params: mutually_exclusive_args.to_sentence(last_word_connector: ' or ') + )) end def invalid_args_error(params) diff --git a/config/metrics/counts_28d/20240208152527_i_quickactions_remove_child_monthly.yml b/config/metrics/counts_28d/20240208152527_i_quickactions_remove_child_monthly.yml new file mode 100644 index 0000000000000..d62c49435ea48 --- /dev/null +++ b/config/metrics/counts_28d/20240208152527_i_quickactions_remove_child_monthly.yml @@ -0,0 +1,26 @@ +--- +key_path: redis_hll_counters.quickactions.i_quickactions_remove_child_monthly +name: quickactions_remove_child_monthly +description: Count of MAU using the `/remove_child` quick action +product_section: dev +product_stage: plan +product_group: product_planning +value_type: number +status: active +milestone: "16.10" +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/141354 +time_frame: 28d +data_source: redis_hll +data_category: optional +instrumentation_class: RedisHLLMetric +options: + events: + - i_quickactions_remove_child +performance_indicator_type: [] +distribution: +- ce +- ee +tier: +- free +- premium +- ultimate diff --git a/config/metrics/counts_7d/20240208152528_i_quickactions_remove_child_weekly.yml b/config/metrics/counts_7d/20240208152528_i_quickactions_remove_child_weekly.yml new file mode 100644 index 0000000000000..b3c11a101d81c --- /dev/null +++ b/config/metrics/counts_7d/20240208152528_i_quickactions_remove_child_weekly.yml @@ -0,0 +1,26 @@ +--- +key_path: redis_hll_counters.quickactions.i_quickactions_remove_child_weekly +name: quickactions_remove_child_weekly +description: Count of WAU using the `/remove_child` quick action +product_section: dev +product_stage: plan +product_group: product_planning +value_type: number +status: active +milestone: "16.10" +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/141354 +time_frame: 7d +data_source: redis_hll +data_category: optional +instrumentation_class: RedisHLLMetric +options: + events: + - i_quickactions_remove_child +performance_indicator_type: [] +distribution: +- ce +- ee +tier: +- free +- premium +- ultimate diff --git a/doc/user/project/quick_actions.md b/doc/user/project/quick_actions.md index 7a55132e1fbd8..ba68a88e78f13 100644 --- a/doc/user/project/quick_actions.md +++ b/doc/user/project/quick_actions.md @@ -168,6 +168,7 @@ To auto-format this table, use the VS Code Markdown Table formatter: `https://do | `/reassign @user1 @user2` | **{check-circle}** Yes | **{check-circle}** Yes | **{check-circle}** Yes | Replace current assignees with those specified. | | `/relabel ~label1 ~label2` | **{check-circle}** Yes | **{check-circle}** Yes | **{check-circle}** Yes | Replace current labels with those specified. | | `/remove_due_date` | **{check-circle}** Yes | **{dotted-circle}** No | **{check-circle}** Yes | Remove due date. | +| `/remove_child <work_item>` | **{dotted-circle}** No | **{check-circle}** Yes | **{dotted-circle}** No | Remove the child `<work_item>`. The `<work_item>` value should be in the format of `#iid`, `group/project#iid`, or a URL to a work item. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/132761) in GitLab 16.10. | | `/remove_parent` | **{check-circle}** Yes | **{dotted-circle}** No | **{check-circle}** Yes | Removes the parent work item. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/434344) in GitLab 16.9. | | `/reopen` | **{check-circle}** Yes | **{check-circle}** Yes | **{check-circle}** Yes | Reopen. | | `/set_parent <work_item>` | **{check-circle}** Yes | **{dotted-circle}** No | **{check-circle}** Yes | Set parent work item to `<work_item>`. The `<work_item>` value should be in the format of `#iid`, `group/project#iid`, or a URL to a work item. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/420798) in GitLab 16.5. | diff --git a/lib/gitlab/quick_actions/work_item_actions.rb b/lib/gitlab/quick_actions/work_item_actions.rb index adb85bd38c203..48f0634079ac6 100644 --- a/lib/gitlab/quick_actions/work_item_actions.rb +++ b/lib/gitlab/quick_actions/work_item_actions.rb @@ -65,6 +65,18 @@ module WorkItemActions @updates[:add_child] = extract_work_items(child_param) @execution_message[:add_child] = success_msg[:add_child] end + + desc { _('Remove child from work item') } + explanation do |child_param| + format(_("Remove the child %{child_ref} from this work item."), child_ref: child_param) + end + types WorkItem + params 'Child #iid, reference or URL' + condition { has_children? && can_admin_link? } + command :remove_child do |child_param| + @updates[:remove_child] = extract_work_items(child_param).first + @execution_message[:remove_child] = success_msg[:remove_child] + end end private @@ -141,7 +153,8 @@ def success_msg promote_to: _("Work item promoted successfully."), set_parent: _('Work item parent set successfully'), remove_parent: _('Work item parent removed successfully'), - add_child: _('Child work item(s) added successfully') + add_child: _('Child work item(s) added successfully'), + remove_child: _('Child work item removed successfully') } end @@ -157,6 +170,10 @@ def supports_children? ::WorkItems::HierarchyRestriction.find_by_parent_type_id(quick_action_target.work_item_type_id).present? end + def has_children? + supports_children? && quick_action_target.work_item_children.present? + end + def can_admin_link? current_user.can?(:admin_issue_link, quick_action_target) end diff --git a/lib/gitlab/usage_data_counters/hll_redis_legacy_events.yml b/lib/gitlab/usage_data_counters/hll_redis_legacy_events.yml index 780d9d3092a17..7220e3c5ad620 100644 --- a/lib/gitlab/usage_data_counters/hll_redis_legacy_events.yml +++ b/lib/gitlab/usage_data_counters/hll_redis_legacy_events.yml @@ -283,6 +283,7 @@ - i_quickactions_remove_estimate - i_quickactions_remove_iteration - i_quickactions_remove_milestone +- i_quickactions_remove_child - i_quickactions_remove_parent - i_quickactions_remove_parent_epic - i_quickactions_remove_time_spent diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 8bdd11c1a8c47..9e2e87b9413e4 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -1775,9 +1775,6 @@ msgstr "" msgid "A Let's Encrypt SSL certificate can not be obtained until your domain is verified." msgstr "" -msgid "A Work Item can be a parent or a child, but not both." -msgstr "" - msgid "A basic folder structure of Astro Starter Kit, to help you get started." msgstr "" @@ -10431,6 +10428,9 @@ msgstr "" msgid "Child issues and epics" msgstr "" +msgid "Child work item removed successfully" +msgstr "" + msgid "Child work item(s) added successfully" msgstr "" @@ -34275,6 +34275,9 @@ msgstr "" msgid "Once imported, repositories can be mirrored over SSH. Read more %{link_start}here%{link_end}." msgstr "" +msgid "One and only one of %{params} is required" +msgstr "" + msgid "One more item" msgid_plural "%d more items" msgstr[0] "" @@ -41090,6 +41093,9 @@ msgstr "" msgid "Remove child epic from an epic" msgstr "" +msgid "Remove child from work item" +msgstr "" + msgid "Remove customer relation contact(s)." msgstr "" @@ -41195,6 +41201,9 @@ msgstr "" msgid "Remove summary" msgstr "" +msgid "Remove the child %{child_ref} from this work item." +msgstr "" + msgid "Remove time estimate" msgstr "" diff --git a/spec/services/notes/quick_actions_service_spec.rb b/spec/services/notes/quick_actions_service_spec.rb index 091c47aa793b2..8e2ae1e0a8443 100644 --- a/spec/services/notes/quick_actions_service_spec.rb +++ b/spec/services/notes/quick_actions_service_spec.rb @@ -387,6 +387,48 @@ end end + describe '/remove_child' do + let_it_be_with_reload(:noteable) { create(:work_item, :objective, project: project) } + let_it_be_with_reload(:child) { create(:work_item, :objective, project: project) } + let_it_be(:note_text) { "/remove_child #{child.to_reference}" } + let_it_be(:note) { create(:note, noteable: noteable, project: project, note: note_text) } + + before do + create(:parent_link, work_item_parent: noteable, work_item: child) + end + + shared_examples 'removes child work item' do + it 'leaves the note empty' do + expect(execute(note)).to be_empty + end + + it 'removes child work item' do + expect { execute(note) }.to change { WorkItems::ParentLink.count }.by(-1) + + expect(noteable.valid?).to be_truthy + expect(noteable.work_item_children).to be_empty + end + end + + context 'when using work item reference' do + let_it_be(:note_text) { "/remove_child #{child.to_reference(full: true)}" } + + it_behaves_like 'removes child work item' + end + + context 'when using work item iid' do + it_behaves_like 'removes child work item' + end + + context 'when using work item URL' do + let_it_be(:project_path) { "#{Gitlab.config.gitlab.url}/#{project.full_path}" } + let_it_be(:url) { "#{project_path}/work_items/#{child.iid}" } + let_it_be(:note_text) { "/remove_child #{url}" } + + it_behaves_like 'removes child work item' + end + end + describe '/set_parent' do let_it_be_with_reload(:noteable) { create(:work_item, :objective, project: project) } let_it_be_with_reload(:parent) { create(:work_item, :objective, project: project) } diff --git a/spec/services/quick_actions/interpret_service_spec.rb b/spec/services/quick_actions/interpret_service_spec.rb index 4ea3467e33cba..8664f13ab167c 100644 --- a/spec/services/quick_actions/interpret_service_spec.rb +++ b/spec/services/quick_actions/interpret_service_spec.rb @@ -3531,6 +3531,59 @@ it_behaves_like 'command is not available' end end + + describe '/remove child command' do + let_it_be(:child) { create(:work_item, :objective, project: project) } + let_it_be(:work_item) { create(:work_item, :objective, project: project) } + let_it_be(:child_ref) { child.to_reference(project) } + + let(:command) { "/remove_child #{child_ref}" } + + shared_examples 'command is available' do + before do + create(:parent_link, work_item_parent: work_item, work_item: child) + end + + it 'explanation contains correct message' do + _, explanations = service.explain(command, work_item) + + expect(explanations) + .to contain_exactly("Remove the child #{child_ref} from this work item.") + end + + it 'contains command' do + expect(service.available_commands(work_item)).to include(a_hash_including(name: :remove_child)) + end + end + + shared_examples 'command is not available' do + it 'explanation is empty' do + _, explanations = service.explain(command, work_item) + + expect(explanations).to eq([]) + end + + it 'does not contain command' do + expect(service.available_commands(work_item)).not_to include(a_hash_including(name: :remove_child)) + end + end + + context 'when user can admin link' do + it_behaves_like 'command is available' + end + + context 'when user cannot admin link' do + subject(:service) { described_class.new(project, create(:user)) } + + it_behaves_like 'command is not available' + end + + context 'when work item does not support children' do + let_it_be(:work_item) { create(:work_item, :key_result, project: project) } + + it_behaves_like 'command is not available' + end + end end describe '#available_commands' do diff --git a/spec/services/work_items/widgets/hierarchy_service/update_service_spec.rb b/spec/services/work_items/widgets/hierarchy_service/update_service_spec.rb index 229ba81d676de..30e20d425184f 100644 --- a/spec/services/work_items/widgets/hierarchy_service/update_service_spec.rb +++ b/spec/services/work_items/widgets/hierarchy_service/update_service_spec.rb @@ -27,10 +27,18 @@ describe '#update' do subject { described_class.new(widget: widget, current_user: user).before_update_in_transaction(params: params) } - context 'when parent and children params are present' do - let(:params) { { parent: parent_work_item, children: [child_work_item] } } + context 'when multiple params are present' do + it_behaves_like 'raises a WidgetError', 'One and only one of children, parent or remove_child is required' do + let(:params) { { parent: parent_work_item, children: [child_work_item] } } + end + + it_behaves_like 'raises a WidgetError', 'One and only one of children, parent or remove_child is required' do + let(:params) { { parent: parent_work_item, remove_child: child_work_item } } + end - it_behaves_like 'raises a WidgetError', 'A Work Item can be a parent or a child, but not both.' + it_behaves_like 'raises a WidgetError', 'One and only one of children, parent or remove_child is required' do + let(:params) { { remove_child: child_work_item, children: [child_work_item] } } + end end context 'when invalid params are present' do @@ -104,6 +112,16 @@ end end + context 'with remove_child param' do + let(:params) { { remove_child: [child_work_item] } } + + it 'correctly removes the work item child' do + expect { subject }.to change { WorkItems::ParentLink.count }.by(-1) + + expect(work_item.reload.work_item_children).to be_empty + end + end + context 'when child is already assigned' do let(:params) { { children: [child_work_item] } } -- GitLab