diff options
-rw-r--r-- | app/finders/pending_todos_finder.rb | 64 | ||||
-rw-r--r-- | app/finders/todos_finder.rb | 7 | ||||
-rw-r--r-- | app/finders/users_with_pending_todos_finder.rb | 16 | ||||
-rw-r--r-- | app/models/todo.rb | 24 | ||||
-rw-r--r-- | app/models/user.rb | 5 | ||||
-rw-r--r-- | app/services/todo_service.rb | 26 | ||||
-rw-r--r-- | spec/finders/pending_todos_finder_spec.rb | 63 | ||||
-rw-r--r-- | spec/finders/todos_finder_spec.rb | 16 | ||||
-rw-r--r-- | spec/finders/users_with_pending_todos_finder_spec.rb | 19 | ||||
-rw-r--r-- | spec/models/todo_spec.rb | 60 |
10 files changed, 279 insertions, 21 deletions
diff --git a/app/finders/pending_todos_finder.rb b/app/finders/pending_todos_finder.rb new file mode 100644 index 00000000000..c21d90c9182 --- /dev/null +++ b/app/finders/pending_todos_finder.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +# Finder for retrieving the pending todos of a user, optionally filtered using +# various fields. +# +# While this finder is a bit more verbose compared to use +# `where(params.slice(...))`, it allows us to decouple the input parameters from +# the actual column names. For example, if we ever decide to use separate +# columns for target types (e.g. `issue_id`, `merge_request_id`, etc), we no +# longer need to change _everything_ that uses this finder. Instead, we just +# change the various `by_*` methods in this finder, without having to touch +# everything that uses it. +class PendingTodosFinder + attr_reader :current_user, :params + + # current_user - The user to retrieve the todos for. + # params - A Hash containing columns and values to use for filtering todos. + def initialize(current_user, params = {}) + @current_user = current_user + @params = params + end + + def execute + todos = current_user.todos.pending + todos = by_project(todos) + todos = by_target_id(todos) + todos = by_target_type(todos) + todos = by_commit_id(todos) + + todos + end + + def by_project(todos) + if (id = params[:project_id]) + todos.for_project(id) + else + todos + end + end + + def by_target_id(todos) + if (id = params[:target_id]) + todos.for_target(id) + else + todos + end + end + + def by_target_type(todos) + if (type = params[:target_type]) + todos.for_type(type) + else + todos + end + end + + def by_commit_id(todos) + if (id = params[:commit_id]) + todos.for_commit(id) + else + todos + end + end +end diff --git a/app/finders/todos_finder.rb b/app/finders/todos_finder.rb index 0e37f946d35..d001e18fea9 100644 --- a/app/finders/todos_finder.rb +++ b/app/finders/todos_finder.rb @@ -47,6 +47,13 @@ class TodosFinder sort(items) end + # Returns `true` if the current user has any todos for the given target. + # + # target - The value of the `target_type` column, such as `Issue`. + def any_for_target?(target) + current_user.todos.any_for_target?(target) + end + private def action_id? diff --git a/app/finders/users_with_pending_todos_finder.rb b/app/finders/users_with_pending_todos_finder.rb new file mode 100644 index 00000000000..461bd92a366 --- /dev/null +++ b/app/finders/users_with_pending_todos_finder.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +# Finder that given a target (e.g. an issue) finds all the users that have +# pending todos for said target. +class UsersWithPendingTodosFinder + attr_reader :target + + # target - The target, such as an Issue or MergeRequest. + def initialize(target) + @target = target + end + + def execute + User.for_todos(target.todos.pending) + end +end diff --git a/app/models/todo.rb b/app/models/todo.rb index d2890f9ca8e..7b64615f699 100644 --- a/app/models/todo.rb +++ b/app/models/todo.rb @@ -45,6 +45,8 @@ class Todo < ActiveRecord::Base scope :for_project, -> (project) { where(project: project) } scope :for_group, -> (group) { where(group: group) } scope :for_type, -> (type) { where(target_type: type) } + scope :for_target, -> (id) { where(target_id: id) } + scope :for_commit, -> (id) { where(commit_id: id) } state_machine :state, initial: :pending do event :done do @@ -72,6 +74,28 @@ class Todo < ActiveRecord::Base ]) end + # Returns `true` if the current user has any todos for the given target. + # + # target - The value of the `target_type` column, such as `Issue`. + def any_for_target?(target) + exists?(target: target) + end + + # Updates the state of a relation of todos to the new state. + # + # new_state - The new state of the todos. + # + # Returns an `Array` containing the IDs of the updated todos. + def update_state(new_state) + # Only update those that are not really on that state + base = where.not(state: new_state).except(:order) + ids = base.pluck(:id) + + base.update_all(state: new_state) + + ids + end + # Priority sorting isn't displayed in the dropdown, because we don't show # milestones, but still show something if the user has a URL with that # selected. diff --git a/app/models/user.rb b/app/models/user.rb index cd3b1c95b7e..8a7acfb73b1 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -265,6 +265,7 @@ class User < ActiveRecord::Base scope :order_oldest_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('current_sign_in_at', 'ASC')) } scope :confirmed, -> { where.not(confirmed_at: nil) } scope :by_username, -> (usernames) { iwhere(username: usernames) } + scope :for_todos, -> (todos) { where(id: todos.select(:user_id)) } # Limits the users to those that have TODOs, optionally in the given state. # @@ -1366,6 +1367,10 @@ class User < ActiveRecord::Base !consented_usage_stats? && 7.days.ago > self.created_at && !has_current_license? && User.single_user? end + def todos_limited_to(ids) + todos.where(id: ids) + end + # @deprecated alias_method :owned_or_masters_groups, :owned_or_maintainers_groups diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb index 4fe6c1ec986..f357dc37fe7 100644 --- a/app/services/todo_service.rb +++ b/app/services/todo_service.rb @@ -41,15 +41,13 @@ class TodoService # collects the todo users before the todos themselves are deleted, then # updates the todo counts for those users. # - # rubocop: disable CodeReuse/ActiveRecord def destroy_target(target) - todo_users = User.where(id: target.todos.pending.select(:user_id)).to_a + todo_users = UsersWithPendingTodosFinder.new(target).execute.to_a yield target todo_users.each(&:update_todos_count_cache) end - # rubocop: enable CodeReuse/ActiveRecord # When we reassign an issue we should: # @@ -200,30 +198,23 @@ class TodoService create_todos(current_user, attributes) end - # rubocop: disable CodeReuse/ActiveRecord def todo_exist?(issuable, current_user) - TodosFinder.new(current_user).execute.exists?(target: issuable) + TodosFinder.new(current_user).any_for_target?(issuable) end - # rubocop: enable CodeReuse/ActiveRecord private - # rubocop: disable CodeReuse/ActiveRecord def todos_by_ids(ids, current_user) - current_user.todos.where(id: Array(ids)) + current_user.todos_limited_to(Array(ids)) end - # rubocop: enable CodeReuse/ActiveRecord - # rubocop: disable CodeReuse/ActiveRecord def update_todos_state(todos, current_user, state) - # Only update those that are not really on that state - todos = todos.where.not(state: state) - todos_ids = todos.pluck(:id) - todos.unscope(:order).update_all(state: state) + todos_ids = todos.update_state(state) + current_user.update_todos_count_cache + todos_ids end - # rubocop: enable CodeReuse/ActiveRecord def create_todos(users, attributes) Array(users).map do |user| @@ -348,10 +339,7 @@ class TodoService end end - # rubocop: disable CodeReuse/ActiveRecord def pending_todos(user, criteria = {}) - valid_keys = [:project_id, :target_id, :target_type, :commit_id] - user.todos.pending.where(criteria.slice(*valid_keys)) + PendingTodosFinder.new(user, criteria).execute end - # rubocop: enable CodeReuse/ActiveRecord end diff --git a/spec/finders/pending_todos_finder_spec.rb b/spec/finders/pending_todos_finder_spec.rb new file mode 100644 index 00000000000..32fad5e225f --- /dev/null +++ b/spec/finders/pending_todos_finder_spec.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe PendingTodosFinder do + let(:user) { create(:user) } + + describe '#execute' do + it 'returns only pending todos' do + create(:todo, :done, user: user) + + todo = create(:todo, :pending, user: user) + todos = described_class.new(user).execute + + expect(todos).to eq([todo]) + end + + it 'supports retrieving of todos for a specific project' do + project1 = create(:project) + project2 = create(:project) + + create(:todo, :pending, user: user, project: project2) + + todo = create(:todo, :pending, user: user, project: project1) + todos = described_class.new(user, project_id: project1.id).execute + + expect(todos).to eq([todo]) + end + + it 'supports retrieving of todos for a specific todo target' do + issue = create(:issue) + note = create(:note) + todo = create(:todo, :pending, user: user, target: issue) + + create(:todo, :pending, user: user, target: note) + + todos = described_class.new(user, target_id: issue.id).execute + + expect(todos).to eq([todo]) + end + + it 'supports retrieving of todos for a specific target type' do + issue = create(:issue) + note = create(:note) + todo = create(:todo, :pending, user: user, target: issue) + + create(:todo, :pending, user: user, target: note) + + todos = described_class.new(user, target_type: issue.class).execute + + expect(todos).to eq([todo]) + end + + it 'supports retrieving of todos for a specific commit ID' do + create(:todo, :pending, user: user, commit_id: '456') + + todo = create(:todo, :pending, user: user, commit_id: '123') + todos = described_class.new(user, commit_id: '123').execute + + expect(todos).to eq([todo]) + end + end +end diff --git a/spec/finders/todos_finder_spec.rb b/spec/finders/todos_finder_spec.rb index 64289224933..d4ed41d54f0 100644 --- a/spec/finders/todos_finder_spec.rb +++ b/spec/finders/todos_finder_spec.rb @@ -109,4 +109,20 @@ describe TodosFinder do end end end + + describe '#any_for_target?' do + it 'returns true if there are any todos for the given target' do + todo = create(:todo, :pending) + finder = described_class.new(todo.user) + + expect(finder.any_for_target?(todo.target)).to eq(true) + end + + it 'returns false if there are no todos for the given target' do + issue = create(:issue) + finder = described_class.new(issue.author) + + expect(finder.any_for_target?(issue)).to eq(false) + end + end end diff --git a/spec/finders/users_with_pending_todos_finder_spec.rb b/spec/finders/users_with_pending_todos_finder_spec.rb new file mode 100644 index 00000000000..fa15355531c --- /dev/null +++ b/spec/finders/users_with_pending_todos_finder_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe UsersWithPendingTodosFinder do + describe '#execute' do + it 'returns the users for all pending todos of a target' do + issue = create(:issue) + note = create(:note) + todo = create(:todo, :pending, target: issue) + + create(:todo, :pending, target: note) + + users = described_class.new(issue).execute + + expect(users).to eq([todo.user]) + end + end +end diff --git a/spec/models/todo_spec.rb b/spec/models/todo_spec.rb index f1a9d81da08..2c01578aaca 100644 --- a/spec/models/todo_spec.rb +++ b/spec/models/todo_spec.rb @@ -230,6 +230,26 @@ describe Todo do end end + describe '.for_target' do + it 'returns the todos for a given target' do + todo = create(:todo, target: create(:issue)) + + create(:todo, target: create(:merge_request)) + + expect(described_class.for_target(todo.target)).to eq([todo]) + end + end + + describe '.for_commit' do + it 'returns the todos for a commit ID' do + todo = create(:todo, commit_id: '123') + + create(:todo, commit_id: '456') + + expect(described_class.for_commit('123')).to eq([todo]) + end + end + describe '.for_group_and_descendants' do it 'returns the todos for a group and its descendants' do parent_group = create(:group) @@ -237,9 +257,45 @@ describe Todo do todo1 = create(:todo, group: parent_group) todo2 = create(:todo, group: child_group) + todos = described_class.for_group_and_descendants(parent_group) + + expect(todos).to include(todo1) + + # Nested groups only work on PostgreSQL, so on MySQL todo2 won't be + # present. + expect(todos).to include(todo2) if Gitlab::Database.postgresql? + end + end + + describe '.any_for_target?' do + it 'returns true if there are todos for a given target' do + todo = create(:todo) + + expect(described_class.any_for_target?(todo.target)).to eq(true) + end + + it 'returns false if there are no todos for a given target' do + issue = create(:issue) + + expect(described_class.any_for_target?(issue)).to eq(false) + end + end + + describe '.update_state' do + it 'updates the state of todos' do + todo = create(:todo, :pending) + ids = described_class.update_state(:done) + + todo.reload + + expect(ids).to eq([todo.id]) + expect(todo.state).to eq('done') + end + + it 'does not update todos that already have the given state' do + create(:todo, :pending) - expect(described_class.for_group_and_descendants(parent_group)) - .to include(todo1, todo2) + expect(described_class.update_state(:pending)).to be_empty end end end |