Commit 26160459 authored by Jacopo's avatar Jacopo

Todo done clicking is kind of unusable.

The Done button will change to an Undo button and the line item will be greyed out.
Bold links will be unbolded.
The user can undo the task by clicking the Undo button.
parent 63330af8
/* eslint-disable class-methods-use-this, no-new, func-names, prefer-template, no-unneeded-ternary, object-shorthand, space-before-function-paren, comma-dangle, quote-props, consistent-return, no-else-return, no-param-reassign, max-len */ /* eslint-disable class-methods-use-this, no-new, func-names, no-unneeded-ternary, object-shorthand, quote-props, no-param-reassign, max-len */
/* global UsersSelect */ /* global UsersSelect */
((global) => { ((global) => {
class Todos { class Todos {
constructor({ el } = {}) { constructor() {
this.allDoneClicked = this.allDoneClicked.bind(this);
this.doneClicked = this.doneClicked.bind(this);
this.el = el || $('.js-todos-options');
this.perPage = this.el.data('perPage');
this.clearListeners();
this.initBtnListeners();
this.initFilters(); this.initFilters();
this.bindEvents();
this.cleanupWrapper = this.cleanup.bind(this);
document.addEventListener('beforeunload', this.cleanupWrapper);
} }
clearListeners() { cleanup() {
$('.done-todo').off('click'); this.unbindEvents();
$('.js-todos-mark-all').off('click'); document.removeEventListener('beforeunload', this.cleanupWrapper);
return $('.todo').off('click');
} }
initBtnListeners() { unbindEvents() {
$('.done-todo').on('click', this.doneClicked); $('.js-done-todo, .js-undo-todo').off('click', this.updateStateClickedWrapper);
$('.js-todos-mark-all').on('click', this.allDoneClicked); $('.js-todos-mark-all').off('click', this.allDoneClickedWrapper);
return $('.todo').on('click', this.goToTodoUrl); $('.todo').off('click', this.goToTodoUrl);
}
bindEvents() {
this.updateStateClickedWrapper = this.updateStateClicked.bind(this);
this.allDoneClickedWrapper = this.allDoneClicked.bind(this);
$('.js-done-todo, .js-undo-todo').on('click', this.updateStateClickedWrapper);
$('.js-todos-mark-all').on('click', this.allDoneClickedWrapper);
$('.todo').on('click', this.goToTodoUrl);
} }
initFilters() { initFilters() {
...@@ -33,7 +39,7 @@ ...@@ -33,7 +39,7 @@
$('form.filter-form').on('submit', function (event) { $('form.filter-form').on('submit', function (event) {
event.preventDefault(); event.preventDefault();
gl.utils.visitUrl(this.action + '&' + $(this).serialize()); gl.utils.visitUrl(`${this.action}&${$(this).serialize()}`);
}); });
} }
...@@ -44,105 +50,72 @@ ...@@ -44,105 +50,72 @@
filterable: searchFields ? true : false, filterable: searchFields ? true : false,
search: { fields: searchFields }, search: { fields: searchFields },
data: $dropdown.data('data'), data: $dropdown.data('data'),
clicked: function() { clicked: function () {
return $dropdown.closest('form.filter-form').submit(); return $dropdown.closest('form.filter-form').submit();
} },
}); });
} }
doneClicked(e) { updateStateClicked(e) {
e.preventDefault(); e.preventDefault();
e.stopImmediatePropagation(); const target = e.target;
const $target = $(e.currentTarget); target.setAttribute('disabled', '');
$target.disable(); target.classList.add('disabled');
return $.ajax({ $.ajax({
type: 'POST', type: 'POST',
url: $target.attr('href'), url: target.getAttribute('href'),
dataType: 'json', dataType: 'json',
data: { data: {
'_method': 'delete' '_method': target.getAttribute('data-method'),
}, },
success: (data) => { success: (data) => {
this.redirectIfNeeded(data.count); this.updateState(target);
this.clearDone($target.closest('li')); this.updateBadges(data);
return this.updateBadges(data); },
}
}); });
} }
allDoneClicked(e) { allDoneClicked(e) {
e.preventDefault(); e.preventDefault();
e.stopImmediatePropagation();
const $target = $(e.currentTarget); const $target = $(e.currentTarget);
$target.disable(); $target.disable();
return $.ajax({ $.ajax({
type: 'POST', type: 'POST',
url: $target.attr('href'), url: $target.attr('href'),
dataType: 'json', dataType: 'json',
data: { data: {
'_method': 'delete' '_method': 'delete',
}, },
success: (data) => { success: (data) => {
$target.remove(); $target.remove();
$('.js-todos-all').html('<div class="nothing-here-block">You\'re all done!</div>'); $('.js-todos-all').html('<div class="nothing-here-block">You\'re all done!</div>');
return this.updateBadges(data); this.updateBadges(data);
} },
}); });
} }
clearDone($row) { updateState(target) {
const $ul = $row.closest('ul'); const row = target.closest('li');
$row.remove(); const restoreBtn = row.querySelector('.js-undo-todo');
if (!$ul.find('li').length) { const doneBtn = row.querySelector('.js-done-todo');
return $ul.parents('.panel').remove();
target.removeAttribute('disabled');
target.classList.remove('disabled');
target.classList.add('hidden');
if (target === doneBtn) {
row.classList.add('done-reversible');
restoreBtn.classList.remove('hidden');
} else {
row.classList.remove('done-reversible');
doneBtn.classList.remove('hidden');
} }
} }
updateBadges(data) { updateBadges(data) {
$(document).trigger('todo:toggle', data.count); $(document).trigger('todo:toggle', data.count);
$('.todos-pending .badge').text(data.count); $('.todos-pending .badge').text(data.count);
return $('.todos-done .badge').text(data.done_count); $('.todos-done .badge').text(data.done_count);
}
getTotalPages() {
return this.el.data('totalPages');
}
getCurrentPage() {
return this.el.data('currentPage');
}
getTodosPerPage() {
return this.el.data('perPage');
}
redirectIfNeeded(total) {
const currPages = this.getTotalPages();
const currPage = this.getCurrentPage();
// Refresh if no remaining Todos
if (!total) {
window.location.reload();
return;
}
// Do nothing if no pagination
if (!currPages) {
return;
}
const newPages = Math.ceil(total / this.getTodosPerPage());
let url = location.href;
if (newPages !== currPages) {
// Redirect to previous page if there's one available
if (currPages > 1 && currPage === currPages) {
const pageParams = {
page: currPages - 1
};
url = gl.utils.mergeUrlParams(pageParams, url);
}
return gl.utils.visitUrl(url);
}
} }
goToTodoUrl(e) { goToTodoUrl(e) {
...@@ -159,12 +132,12 @@ ...@@ -159,12 +132,12 @@
if (selected.tagName === 'IMG') { if (selected.tagName === 'IMG') {
const avatarUrl = selected.parentElement.getAttribute('href'); const avatarUrl = selected.parentElement.getAttribute('href');
return window.open(avatarUrl, windowTarget); window.open(avatarUrl, windowTarget);
} else { } else {
return window.open(todoLink, windowTarget); window.open(todoLink, windowTarget);
} }
} else { } else {
return gl.utils.visitUrl(todoLink); gl.utils.visitUrl(todoLink);
} }
} }
} }
......
...@@ -60,6 +60,18 @@ ...@@ -60,6 +60,18 @@
} }
} }
.todos-list > .todo.todo-pending.done-reversible {
background-color: $gray-light;
&:hover {
border-color: $border-color;
}
.title {
font-weight: normal;
}
}
.todo-item { .todo-item {
.todo-title { .todo-title {
@include str-truncated(calc(100% - 174px)); @include str-truncated(calc(100% - 174px));
......
...@@ -29,6 +29,12 @@ class Dashboard::TodosController < Dashboard::ApplicationController ...@@ -29,6 +29,12 @@ class Dashboard::TodosController < Dashboard::ApplicationController
end end
end end
def restore
TodoService.new.mark_todos_as_pending_by_ids([params[:id]], current_user)
render json: todos_counts
end
private private
def find_todos def find_todos
......
...@@ -170,16 +170,20 @@ class TodoService ...@@ -170,16 +170,20 @@ class TodoService
# When user marks some todos as done # When user marks some todos as done
def mark_todos_as_done(todos, current_user) def mark_todos_as_done(todos, current_user)
mark_todos_as_done_by_ids(todos.select(&:id), current_user) update_todos_state_by_ids(todos.select(&:id), current_user, :done)
end end
def mark_todos_as_done_by_ids(ids, current_user) def mark_todos_as_done_by_ids(ids, current_user)
todos = current_user.todos.where(id: ids) update_todos_state_by_ids(ids, current_user, :done)
end
# Only return those that are not really on that state # When user marks some todos as pending
marked_todos = todos.where.not(state: :done).update_all(state: :done) def mark_todos_as_pending(todos, current_user)
current_user.update_todos_count_cache update_todos_state_by_ids(todos.select(&:id), current_user, :pending)
marked_todos end
def mark_todos_as_pending_by_ids(ids, current_user)
update_todos_state_by_ids(ids, current_user, :pending)
end end
# When user marks an issue as todo # When user marks an issue as todo
...@@ -194,6 +198,15 @@ class TodoService ...@@ -194,6 +198,15 @@ class TodoService
private private
def update_todos_state_by_ids(ids, current_user, state)
todos = current_user.todos.where(id: ids)
# Only return those that are not really on that state
marked_todos = todos.where.not(state: state).update_all(state: state)
current_user.update_todos_count_cache
marked_todos
end
def create_todos(users, attributes) def create_todos(users, attributes)
Array(users).map do |user| Array(users).map do |user|
next if pending_todos(user, attributes).exists? next if pending_todos(user, attributes).exists?
......
...@@ -31,6 +31,9 @@ ...@@ -31,6 +31,9 @@
- if todo.pending? - if todo.pending?
.todo-actions .todo-actions
= link_to [:dashboard, todo], method: :delete, class: 'btn btn-loading done-todo' do = link_to [:dashboard, todo], method: :delete, class: 'btn btn-loading js-done-todo' do
Done Done
= icon('spinner spin') = icon('spinner spin')
= link_to restore_dashboard_todo_path(todo), method: :patch, class: 'btn btn-loading js-undo-todo hidden' do
Undo
= icon('spinner spin')
---
title: Todo done clicking is kind of unusable
merge_request: 8691
author: Jacopo Beschi @jacopo-beschi
...@@ -14,6 +14,9 @@ resource :dashboard, controller: 'dashboard', only: [] do ...@@ -14,6 +14,9 @@ resource :dashboard, controller: 'dashboard', only: [] do
collection do collection do
delete :destroy_all delete :destroy_all
end end
member do
patch :restore
end
end end
resources :projects, only: [:index] do resources :projects, only: [:index] do
......
...@@ -47,7 +47,7 @@ class Spinach::Features::DashboardTodos < Spinach::FeatureSteps ...@@ -47,7 +47,7 @@ class Spinach::Features::DashboardTodos < Spinach::FeatureSteps
page.within('.todos-pending-count') { expect(page).to have_content '3' } page.within('.todos-pending-count') { expect(page).to have_content '3' }
expect(page).to have_content 'To do 3' expect(page).to have_content 'To do 3'
expect(page).to have_content 'Done 1' expect(page).to have_content 'Done 1'
should_not_see_todo "John Doe assigned you merge request #{merge_request.to_reference(full: true)}" should_see_todo(1, "John Doe assigned you merge request #{merge_request.to_reference(full: true)}", merge_request.title, state: :done_reversible)
end end
step 'I mark all todos as done' do step 'I mark all todos as done' do
...@@ -71,7 +71,7 @@ class Spinach::Features::DashboardTodos < Spinach::FeatureSteps ...@@ -71,7 +71,7 @@ class Spinach::Features::DashboardTodos < Spinach::FeatureSteps
click_link 'Done 1' click_link 'Done 1'
expect(page).to have_link project.name_with_namespace expect(page).to have_link project.name_with_namespace
should_see_todo(1, "John Doe assigned you merge request #{merge_request.to_reference(full: true)}", merge_request.title, false) should_see_todo(1, "John Doe assigned you merge request #{merge_request.to_reference(full: true)}", merge_request.title, state: :done_irreversible)
end end
step 'I should see all todos marked as done' do step 'I should see all todos marked as done' do
...@@ -81,10 +81,10 @@ class Spinach::Features::DashboardTodos < Spinach::FeatureSteps ...@@ -81,10 +81,10 @@ class Spinach::Features::DashboardTodos < Spinach::FeatureSteps
click_link 'Done 4' click_link 'Done 4'
expect(page).to have_link project.name_with_namespace expect(page).to have_link project.name_with_namespace
should_see_todo(1, "John Doe assigned you merge request #{merge_request_reference}", merge_request.title, false) should_see_todo(1, "John Doe assigned you merge request #{merge_request_reference}", merge_request.title, state: :done_irreversible)
should_see_todo(2, "John Doe mentioned you on issue #{issue_reference}", "#{current_user.to_reference} Wdyt?", false) should_see_todo(2, "John Doe mentioned you on issue #{issue_reference}", "#{current_user.to_reference} Wdyt?", state: :done_irreversible)
should_see_todo(3, "John Doe assigned you issue #{issue_reference}", issue.title, false) should_see_todo(3, "John Doe assigned you issue #{issue_reference}", issue.title, state: :done_irreversible)
should_see_todo(4, "Mary Jane mentioned you on issue #{issue_reference}", issue.title, false) should_see_todo(4, "Mary Jane mentioned you on issue #{issue_reference}", issue.title, state: :done_irreversible)
end end
step 'I filter by "Enterprise"' do step 'I filter by "Enterprise"' do
...@@ -140,15 +140,20 @@ class Spinach::Features::DashboardTodos < Spinach::FeatureSteps ...@@ -140,15 +140,20 @@ class Spinach::Features::DashboardTodos < Spinach::FeatureSteps
page.should have_css('.identifier', text: 'Merge Request !1') page.should have_css('.identifier', text: 'Merge Request !1')
end end
def should_see_todo(position, title, body, pending = true) def should_see_todo(position, title, body, state: :pending)
page.within(".todo:nth-child(#{position})") do page.within(".todo:nth-child(#{position})") do
expect(page).to have_content title expect(page).to have_content title
expect(page).to have_content body expect(page).to have_content body
if pending if state == :pending
expect(page).to have_link 'Done' expect(page).to have_link 'Done'
else elsif state == :done_reversible
expect(page).to have_link 'Undo'
elsif state == :done_irreversible
expect(page).not_to have_link 'Undo'
expect(page).not_to have_link 'Done' expect(page).not_to have_link 'Done'
else
raise 'Invalid state given, valid states: :pending, :done_reversible, :done_irreversible'
end end
end end
end end
......
require 'spec_helper' require 'spec_helper'
describe Dashboard::TodosController do describe Dashboard::TodosController do
include ApiHelpers
let(:user) { create(:user) } let(:user) { create(:user) }
let(:author) { create(:user) }
let(:project) { create(:empty_project) } let(:project) { create(:empty_project) }
let(:todo_service) { TodoService.new } let(:todo_service) { TodoService.new }
describe 'GET #index' do before do
before do sign_in(user)
sign_in(user) project.team << [user, :developer]
project.team << [user, :developer] end
end
describe 'GET #index' do
context 'when using pagination' do context 'when using pagination' do
let(:last_page) { user.todos.page.total_pages } let(:last_page) { user.todos.page.total_pages }
let!(:issues) { create_list(:issue, 2, project: project, assignee: user) } let!(:issues) { create_list(:issue, 2, project: project, assignee: user) }
...@@ -34,4 +37,16 @@ describe Dashboard::TodosController do ...@@ -34,4 +37,16 @@ describe Dashboard::TodosController do
end end
end end
end end
describe 'PATCH #restore' do
let(:todo) { create(:todo, :done, user: user, project: project, author: author) }
it 'restores the todo to pending state' do
patch :restore, id: todo.id
expect(todo.reload).to be_pending
expect(response).to have_http_status(200)
expect(json_response).to eq({ "count" => 1, "done_count" => 0 })
end
end
end end
...@@ -40,6 +40,10 @@ FactoryGirl.define do ...@@ -40,6 +40,10 @@ FactoryGirl.define do
action { Todo::UNMERGEABLE } action { Todo::UNMERGEABLE }
end end
trait :pending do
state :pending
end
trait :done do trait :done do
state :done state :done
end end
......
require 'spec_helper' require 'spec_helper'
describe 'Dashboard Todos', feature: true do describe 'Dashboard Todos', feature: true do
include WaitForAjax
let(:user) { create(:user) } let(:user) { create(:user) }
let(:author) { create(:user) } let(:author) { create(:user) }
let(:project) { create(:project, visibility_level: Gitlab::VisibilityLevel::PUBLIC) } let(:project) { create(:project, visibility_level: Gitlab::VisibilityLevel::PUBLIC) }
...@@ -34,40 +36,65 @@ describe 'Dashboard Todos', feature: true do ...@@ -34,40 +36,65 @@ describe 'Dashboard Todos', feature: true do
end end
end end
describe 'deleting the todo' do shared_examples 'deleting the todo' do
before do before do
first('.done-todo').click first('.js-done-todo').click
end
it 'is marked as done-reversible in the list' do
expect(page).to have_selector('.todos-list .todo.todo-pending.done-reversible')
end end
it 'is removed from the list' do it 'shows Undo button' do
expect(page).not_to have_selector('.todos-list .todo') expect(page).to have_selector('.js-undo-todo', visible: true)
expect(page).to have_selector('.js-done-todo', visible: false)
end end
it 'shows "All done" message' do it 'updates todo count' do
expect(page).to have_selector('.todos-all-done', count: 1) expect(page).to have_content 'To do 0'
expect(page).to have_content 'Done 1'
end
it 'has not "All done" message' do
expect(page).not_to have_selector('.todos-all-done')
end end
end end
context 'todo is stale on the page' do shared_examples 'deleting and restoring the todo' do
before do before do
todos = TodosFinder.new(user, state: :pending).execute first('.js-done-todo').click
TodoService.new.mark_todos_as_done(todos, user) wait_for_ajax
first('.js-undo-todo').click
end end
describe 'deleting the todo' do it 'is marked back as pending in the list' do
before do expect(page).not_to have_selector('.todos-list .todo.todo-pending.done-reversible')
first('.done-todo').click expect(page).to have_selector('.todos-list .todo.todo-pending')
end end
it 'is removed from the list' do it 'shows Done button' do
expect(page).not_to have_selector('.todos-list .todo') expect(page).to have_selector('.js-undo-todo', visible: false)
end expect(page).to have_selector('.js-done-todo', visible: true)
end
it 'shows "All done" message' do it 'updates todo count' do
expect(page).to have_selector('.todos-all-done', count: 1) expect(page).to have_content 'To do 1'
end expect(page).to have_content 'Done 0'
end end
end end
it_behaves_like 'deleting the todo'
it_behaves_like 'deleting and restoring the todo'
context 'todo is stale on the page' do
before do
todos = TodosFinder.new(user, state: :pending).execute
TodoService.new.mark_todos_as_done(todos, user)
end
it_behaves_like 'deleting the todo'
it_behaves_like 'deleting and restoring the todo'
end
end end
context 'User has Todos with labels spanning multiple projects' do context 'User has Todos with labels spanning multiple projects' do
...@@ -113,18 +140,6 @@ describe 'Dashboard Todos', feature: true do ...@@ -113,18 +140,6 @@ describe 'Dashboard Todos', feature: true do
expect(page).to have_selector('.gl-pagination .page', count: 2) expect(page).to have_selector('.gl-pagination .page', count: 2)
end end
describe 'completing last todo from last page', js: true do
it 'redirects to the previous page' do
visit dashboard_todos_path(page: 2)
expect(page).to have_css("#todo_#{Todo.last.id}")
click_link('Done')
expect(current_path).to eq dashboard_todos_path
expect(page).to have_css("#todo_#{Todo.first.id}")
end
end
describe 'mark all as done', js: true do describe 'mark all as done', js: true do
before do before do
visit dashboard_todos_path visit dashboard_todos_path
......
...@@ -287,39 +287,51 @@ describe TodoService, services: true do ...@@ -287,39 +287,51 @@ describe TodoService, services: true do
end end
end end
shared_examples 'marking todos as done' do |meth| shared_examples 'updating todos state' do |meth, state, new_state|
let!(:first_todo) { create(:todo, :assigned, user: john_doe, project: project, target: issue, author: author) } let!(:first_todo) { create(:todo, state, user: john_doe, project: project, target: issue, author: author) }
let!(:second_todo) { create(:todo, :assigned, user: john_doe, project: project, target: issue, author: author) } let!(:second_todo) { create(:todo, state, user: john_doe, project: project, target: issue, author: author) }
it 'marks related todos for the user as done' do it 'updates related todos for the user with the new_state' do
service.send(meth, collection, john_doe) service.send(meth, collection, john_doe)
expect(first_todo.reload).to be_done expect(first_todo.reload.state?(new_state)).to be true
expect(second_todo.reload).to be_done expect(second_todo.reload.state?(new_state)).to be true
end end
describe 'cached counts' do describe 'cached counts' do
it 'updates when todos change' do it 'updates when todos change' do
expect(john_doe.todos_done_count).to eq(0) expect(john_doe.todos.where(state: new_state).count).to eq(0)
expect(john_doe.todos_pending_count).to eq(2) expect(john_doe.todos.where(state: state).count).to eq(2)
expect(john_doe).to receive(:update_todos_count_cache).and_call_original expect(john_doe).to receive(:update_todos_count_cache).and_call_original
service.send(meth, collection, john_doe) service.send(meth, collection, john_doe)
expect(john_doe.todos_done_count).to eq(2) expect(john_doe.todos.where(state: new_state).count).to eq(2)
expect(john_doe.todos_pending_count).to eq(0) expect(john_doe.todos.where(state: state).count).to eq(0)
end end
end end
end end
describe '#mark_todos_as_done' do describe '#mark_todos_as_done' do
it_behaves_like 'marking todos as done', :mark_todos_as_done do it_behaves_like 'updating todos state', :mark_todos_as_done, :pending, :done do
let(:collection) { [first_todo, second_todo] } let(:collection) { [first_todo, second_todo] }
end end
end end
describe '#mark_todos_as_done_by_ids' do describe '#mark_todos_as_done_by_ids' do
it_behaves_like 'marking todos as done', :mark_todos_as_done_by_ids do it_behaves_like 'updating todos state', :mark_todos_as_done_by_ids, :pending, :done do
let(:collection) { [first_todo, second_todo].map(&:id) }
end
end
describe '#mark_todos_as_pending' do
it_behaves_like 'updating todos state', :mark_todos_as_pending, :done, :pending do
let(:collection) { [first_todo, second_todo] }
end
end
describe '#mark_todos_as_pending_by_ids' do
it_behaves_like 'updating todos state', :mark_todos_as_pending_by_ids, :done, :pending do
let(:collection) { [first_todo, second_todo].map(&:id) } let(:collection) { [first_todo, second_todo].map(&:id) }
end end
end end
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment