From 79b0b0481167a35c4ff97c38b76b5efdc78bceec Mon Sep 17 00:00:00 2001 From: Eugenia Grieff <egrieff@gitlab.com> Date: Fri, 30 Jun 2023 07:07:41 +0000 Subject: [PATCH] Add todo quick actions to work items Fix target_type todos column for work items Update documentation Changelog: added --- app/graphql/types/current_user_todos.rb | 3 +- app/models/todo.rb | 13 +++- app/models/work_item.rb | 10 +++- app/models/work_items/widgets/base.rb | 4 ++ .../work_items/widgets/current_user_todos.rb | 13 ++++ app/services/todo_service.rb | 2 +- doc/user/project/quick_actions.md | 2 + spec/models/work_item_spec.rb | 59 ++++++++++++++++-- spec/models/work_items/widgets/base_spec.rb | 6 ++ .../widgets/current_user_todos_spec.rb | 60 +++++++++++++++++++ .../quick_actions/interpret_service_spec.rb | 12 ++++ spec/services/todo_service_spec.rb | 51 ++++++++++++++++ .../work_items/update_service_spec.rb | 32 ++++++++++ .../update_service_spec.rb | 2 +- 14 files changed, 258 insertions(+), 11 deletions(-) create mode 100644 spec/models/work_items/widgets/current_user_todos_spec.rb diff --git a/app/graphql/types/current_user_todos.rb b/app/graphql/types/current_user_todos.rb index 4c4cb51697988..d5441ea1d1562 100644 --- a/app/graphql/types/current_user_todos.rb +++ b/app/graphql/types/current_user_todos.rb @@ -17,7 +17,8 @@ module CurrentUserTodos def current_user_todos(state: nil) state ||= %i[done pending] # TodosFinder treats a `nil` state param as `pending` - key = [state, unpresented.class.name] + target_type_name = unpresented.try(:todoable_target_type_name) || unpresented.class.name + key = [state, target_type_name] BatchLoader::GraphQL.for(unpresented).batch(default_value: [], key: key) do |targets, loader, args| state, klass_name = args[:key] diff --git a/app/models/todo.rb b/app/models/todo.rb index 724f97c4812f0..f202e1a266dd6 100644 --- a/app/models/todo.rb +++ b/app/models/todo.rb @@ -117,7 +117,18 @@ def for_group_ids_and_descendants(group_ids) # target - The value of the `target_type` column, such as `Issue`. # state - The value of the `state` column, such as `pending` or `done`. def any_for_target?(target, state = nil) - state.nil? ? exists?(target: target) : exists?(target: target, state: state) + conditions = {} + + if target.respond_to?(:todoable_target_type_name) + conditions[:target_type] = target.todoable_target_type_name + conditions[:target_id] = target.id + else + conditions[:target] = target + end + + conditions[:state] = state unless state.nil? + + exists?(conditions) end # Updates attributes of a relation of todos to the new state. diff --git a/app/models/work_item.rb b/app/models/work_item.rb index 9f28ffbf7b6bd..adf424a1d94cb 100644 --- a/app/models/work_item.rb +++ b/app/models/work_item.rb @@ -65,6 +65,12 @@ def noteable_target_type_name 'issue' end + # Todo: remove method after target_type cleanup + # See https://gitlab.com/gitlab-org/gitlab/-/issues/416009 + def todoable_target_type_name + %w[Issue WorkItem] + end + def widgets strong_memoize(:widgets) do work_item_type.widgets.map do |widget_class| @@ -114,7 +120,9 @@ def transform_quick_action_params(command_params) .filter { |param_name| common_params.key?(param_name) } .each do |param_name| widget_params[widget.api_symbol] ||= {} - widget_params[widget.api_symbol][param_name] = common_params.delete(param_name) + param_value = common_params.delete(param_name) + + widget_params[widget.api_symbol].merge!(widget.process_quick_action_param(param_name, param_value)) end end diff --git a/app/models/work_items/widgets/base.rb b/app/models/work_items/widgets/base.rb index a8b1b3f9a59af..c4e87decdbffc 100644 --- a/app/models/work_items/widgets/base.rb +++ b/app/models/work_items/widgets/base.rb @@ -15,6 +15,10 @@ def self.quick_action_commands [] end + def self.process_quick_action_param(param_name, value) + { param_name => value } + end + def self.callback_class WorkItems::Callbacks.const_get(name.demodulize, false) rescue NameError diff --git a/app/models/work_items/widgets/current_user_todos.rb b/app/models/work_items/widgets/current_user_todos.rb index 61c4fcb453bc0..64297b433dd8b 100644 --- a/app/models/work_items/widgets/current_user_todos.rb +++ b/app/models/work_items/widgets/current_user_todos.rb @@ -3,6 +3,19 @@ module WorkItems module Widgets class CurrentUserTodos < Base + def self.quick_action_commands + [:todo, :done] + end + + def self.quick_action_params + [:todo_event] + end + + def self.process_quick_action_param(param_name, value) + return super unless param_name == :todo_event + + { action: value == 'done' ? 'mark_as_done' : 'add' } + end end end end diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb index c55e1680bfef2..1f6cf2c83c95f 100644 --- a/app/services/todo_service.rb +++ b/app/services/todo_service.rb @@ -377,7 +377,7 @@ def attributes_for_target(target) attributes = { project_id: target&.project&.id, target_id: target.id, - target_type: target.class.name, + target_type: target.class.try(:polymorphic_name) || target.class.name, commit_id: nil } diff --git a/doc/user/project/quick_actions.md b/doc/user/project/quick_actions.md index 3e8ce00974088..219255993a34e 100644 --- a/doc/user/project/quick_actions.md +++ b/doc/user/project/quick_actions.md @@ -165,6 +165,8 @@ The following quick actions can be applied through the description field when ed | `/clear_weight` | **{check-circle}** Yes | **{dotted-circle}** No | **{dotted-circle}** No | Clear weight. | `/type` | **{check-circle}** Yes | **{dotted-circle}** Yes | **{dotted-circle}** Yes | Converts work item to specified type. Available options for `<type>` include `Issue`, `Task`, `Objective` and `Key Result`. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/385227) in GitLab 16.0. | `/promote_to <type>` | **{check-circle}** Yes | **{dotted-circle}** No | **{check-circle}** Yes | Promotes work item to specified type. Available options for `<type>`: `issue` (promote a task) and `objective` (promote a key result). [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/412534) in GitLab 16.1. +| `/todo` | **{check-circle}** Yes | **{check-circle}** Yes | **{check-circle}** Yes | Add a to-do item. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/412277) in GitLab 16.2. +| `/done` | **{check-circle}** Yes | **{check-circle}** Yes | **{check-circle}** Yes | Mark to-do item as done. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/412277) in GitLab 16.2. ## Commit messages diff --git a/spec/models/work_item_spec.rb b/spec/models/work_item_spec.rb index bf43a2032e736..b2c0d757a5c91 100644 --- a/spec/models/work_item_spec.rb +++ b/spec/models/work_item_spec.rb @@ -87,6 +87,14 @@ end end + describe '#todoable_target_type_name' do + it 'returns correct target name' do + work_item = build(:work_item) + + expect(work_item.todoable_target_type_name).to contain_exactly('Issue', 'WorkItem') + end + end + describe '#widgets' do subject { build(:work_item).widgets } @@ -176,17 +184,32 @@ is_expected.not_to include(:due, :remove_due_date) end end + + context 'when work item supports the current user todos widget' do + it 'returns todos related quick action commands' do + is_expected.to include(:todo, :done) + end + end + + context 'when work item does not support current user todos widget' do + let(:work_item) { build(:work_item, :task) } + + before do + WorkItems::Type.default_by_type(:task).widget_definitions + .find_by_widget_type(:current_user_todos).update!(disabled: true) + end + + it 'omits todos related quick action commands' do + is_expected.not_to include(:todo, :done) + end + end end describe 'transform_quick_action_params' do + let(:command_params) { { title: 'bar', assignee_ids: ['foo'] } } let(:work_item) { build(:work_item, :task) } - subject(:transformed_params) do - work_item.transform_quick_action_params({ - title: 'bar', - assignee_ids: ['foo'] - }) - end + subject(:transformed_params) { work_item.transform_quick_action_params(command_params) } it 'correctly separates widget params from regular params' do expect(transformed_params).to eq({ @@ -200,6 +223,30 @@ } }) end + + context 'with current user todos widget' do + let(:command_params) { { title: 'bar', todo_event: param } } + + where(:param, :expected) do + 'done' | 'mark_as_done' + 'add' | 'add' + end + + with_them do + it 'correctly transform todo_event param' do + expect(transformed_params).to eq({ + common: { + title: 'bar' + }, + widgets: { + current_user_todos_widget: { + action: expected + } + } + }) + end + end + end end describe 'callbacks' do diff --git a/spec/models/work_items/widgets/base_spec.rb b/spec/models/work_items/widgets/base_spec.rb index 9b4b4d9e98fae..29b54a706c2f8 100644 --- a/spec/models/work_items/widgets/base_spec.rb +++ b/spec/models/work_items/widgets/base_spec.rb @@ -16,4 +16,10 @@ it { is_expected.to eq(:base) } end + + describe '.process_quick_action_param' do + subject { described_class.process_quick_action_param(:label_ids, [1, 2]) } + + it { is_expected.to eq({ label_ids: [1, 2] }) } + end end diff --git a/spec/models/work_items/widgets/current_user_todos_spec.rb b/spec/models/work_items/widgets/current_user_todos_spec.rb new file mode 100644 index 0000000000000..9fdf28beada85 --- /dev/null +++ b/spec/models/work_items/widgets/current_user_todos_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe WorkItems::Widgets::CurrentUserTodos, feature_category: :team_planning do + let_it_be(:project) { create(:project) } + let_it_be(:milestone) { create(:milestone, project: project) } + let_it_be(:work_item) { create(:work_item, :issue, project: project, milestone: milestone) } + + describe '.type' do + subject { described_class.type } + + it { is_expected.to eq(:current_user_todos) } + end + + describe '#type' do + subject { described_class.new(work_item).type } + + it { is_expected.to eq(:current_user_todos) } + end + + describe '.quick_action_params' do + subject { described_class.quick_action_params } + + it { is_expected.to contain_exactly(:todo_event) } + end + + describe '.quick_action_commands' do + subject { described_class.quick_action_commands } + + it { is_expected.to contain_exactly(:todo, :done) } + end + + describe '.process_quick_action_param' do + subject { described_class.process_quick_action_param(param_name, param_value) } + + context 'when quick action param is todo_event' do + let(:param_name) { :todo_event } + + context 'when param value is `done`' do + let(:param_value) { 'done' } + + it { is_expected.to eq({ action: 'mark_as_done' }) } + end + + context 'when param value is `add`' do + let(:param_value) { 'add' } + + it { is_expected.to eq({ action: 'add' }) } + end + end + + context 'when quick action param is not todo_event' do + let(:param_name) { :foo } + let(:param_value) { 'foo' } + + it { is_expected.to eq({ foo: 'foo' }) } + end + end +end diff --git a/spec/services/quick_actions/interpret_service_spec.rb b/spec/services/quick_actions/interpret_service_spec.rb index bd09dae0a5a74..27a966f0521ba 100644 --- a/spec/services/quick_actions/interpret_service_spec.rb +++ b/spec/services/quick_actions/interpret_service_spec.rb @@ -35,6 +35,7 @@ end describe '#execute' do + let_it_be(:work_item) { create(:work_item, :task, project: project) } let(:merge_request) { create(:merge_request, source_project: project) } shared_examples 'reopen command' do @@ -1369,6 +1370,11 @@ let(:issuable) { merge_request } end + it_behaves_like 'done command' do + let(:content) { '/done' } + let(:issuable) { work_item } + end + it_behaves_like 'subscribe command' do let(:content) { '/subscribe' } let(:issuable) { issue } @@ -1590,6 +1596,12 @@ end end + context 'if issuable is a work item' do + it_behaves_like 'todo command' do + let(:issuable) { work_item } + end + end + context 'if issuable is a MergeRequest' do it_behaves_like 'todo command' do let(:issuable) { merge_request } diff --git a/spec/services/todo_service_spec.rb b/spec/services/todo_service_spec.rb index 1ec6a3250fcc6..32e17df4d693b 100644 --- a/spec/services/todo_service_spec.rb +++ b/spec/services/todo_service_spec.rb @@ -717,6 +717,57 @@ end end + describe 'Work Items' do + let_it_be(:work_item) { create(:work_item, :task, project: project, author: author) } + + describe '#mark_todo' do + it 'creates a todo from a work item' do + service.mark_todo(work_item, author) + + should_create_todo(user: author, target: work_item, action: Todo::MARKED) + end + end + + describe '#todo_exists?' do + it 'returns false when no todo exist for the given work_item' do + expect(service.todo_exist?(work_item, author)).to be_falsy + end + + it 'returns true when a todo exist for the given work_item' do + service.mark_todo(work_item, author) + + expect(service.todo_exist?(work_item, author)).to be_truthy + end + end + + describe '#resolve_todos_for_target' do + it 'marks related pending todos to the target for the user as done' do + first_todo = create(:todo, :assigned, user: john_doe, project: project, target: work_item, author: author) + second_todo = create(:todo, :assigned, user: john_doe, project: project, target: work_item, author: author) + + service.resolve_todos_for_target(work_item, john_doe) + + expect(first_todo.reload).to be_done + expect(second_todo.reload).to be_done + end + + describe 'cached counts' do + it 'updates when todos change' do + create(:todo, :assigned, user: john_doe, project: project, target: work_item, author: author) + + expect(john_doe.todos_done_count).to eq(0) + expect(john_doe.todos_pending_count).to eq(1) + expect(john_doe).to receive(:update_todos_count_cache).and_call_original + + service.resolve_todos_for_target(work_item, john_doe) + + expect(john_doe.todos_done_count).to eq(1) + expect(john_doe.todos_pending_count).to eq(0) + end + end + end + end + describe '#reassigned_assignable' do let(:described_method) { :reassigned_assignable } diff --git a/spec/services/work_items/update_service_spec.rb b/spec/services/work_items/update_service_spec.rb index 30c1645835369..40684dd293698 100644 --- a/spec/services/work_items/update_service_spec.rb +++ b/spec/services/work_items/update_service_spec.rb @@ -383,6 +383,38 @@ end end end + + context 'for current user todos widget' do + let_it_be(:user_todo) { create(:todo, target: work_item, user: developer, project: project, state: :pending) } + let_it_be(:other_todo) { create(:todo, target: work_item, user: create(:user), project: project, state: :pending) } + + context 'when action is mark_as_done' do + let(:widget_params) { { current_user_todos_widget: { action: 'mark_as_done' } } } + + it 'marks current user todo as done' do + expect do + update_work_item + user_todo.reload + other_todo.reload + end.to change(user_todo, :state).from('pending').to('done').and not_change { other_todo.state } + end + + it_behaves_like 'update service that triggers GraphQL work_item_updated subscription' do + subject(:execute_service) { update_work_item } + end + end + + context 'when action is add' do + let(:widget_params) { { current_user_todos_widget: { action: 'add' } } } + + it 'adds a ToDo for the work item' do + expect do + update_work_item + work_item.reload + end.to change(Todo, :count).by(1) + end + end + end end describe 'label updates' do diff --git a/spec/services/work_items/widgets/current_user_todos_service/update_service_spec.rb b/spec/services/work_items/widgets/current_user_todos_service/update_service_spec.rb index 85b7e7a70dfe9..aa7257e9e628d 100644 --- a/spec/services/work_items/widgets/current_user_todos_service/update_service_spec.rb +++ b/spec/services/work_items/widgets/current_user_todos_service/update_service_spec.rb @@ -56,7 +56,7 @@ todo = current_user.todos.last - expect(todo.target).to eq(work_item) + expect(todo.target.id).to eq(work_item.id) expect(todo).to be_pending end end -- GitLab