Skip to content

Commit 7d37e67

Browse files
committed
Allow adding the same todo to multiple dates
1 parent 4ffdf00 commit 7d37e67

8 files changed

Lines changed: 162 additions & 10 deletions

File tree

app/assets/javascripts/tracks.js.erb

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,26 @@ var TracksForm = {
9696
}
9797
return false;
9898
});
99+
100+
/* add another date fieldset for multi-date todo creation */
101+
$(document).on("click", "#add_date_fieldset", function(e) {
102+
e.preventDefault();
103+
var $container = $("#date_fieldsets_container");
104+
var $original = $container.find(".date_fieldset").first();
105+
var $clone = $original.clone(false);
106+
$clone.find("input").val("").removeClass("hasDatepicker").removeAttr("id");
107+
// Replace the + button with a − remove button on the clone
108+
$clone.find(".date_fieldset_btn").remove();
109+
$clone.append('<a href="#" class="date_fieldset_btn remove_date_fieldset" title="Remove"><i class="fas fa-minus-circle"></i></a>');
110+
$container.find(".date_fieldset").last().after($clone);
111+
TracksPages.setup_datepicker();
112+
});
113+
114+
/* remove a cloned date fieldset */
115+
$(document).on("click", ".remove_date_fieldset", function(e) {
116+
e.preventDefault();
117+
$(this).closest(".date_fieldset").remove();
118+
});
99119
},
100120
enable_dependency_delete: function() {
101121
$(document).on("click", 'a[class=icon_delete_dep]', function() {

app/assets/stylesheets/include/legacy.scss

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -854,9 +854,44 @@ input#go_to_project, input#context_hide {
854854
.show_from_input, .due_input {
855855
width: 45%;
856856
}
857+
858+
.date_fieldset {
859+
display: flex;
860+
align-items: flex-end;
861+
gap: 6px;
862+
margin-bottom: 4px;
863+
864+
.due_input, .show_from_input {
865+
flex: 1;
866+
float: none;
867+
}
868+
869+
.date_fieldset_btn {
870+
display: inline-flex;
871+
align-items: center;
872+
justify-content: center;
873+
width: 26px;
874+
height: 30px;
875+
flex-shrink: 0;
876+
color: #5cb85c;
877+
font-size: 16px;
878+
text-decoration: none;
879+
opacity: 0.75;
880+
transition: opacity 0.15s;
881+
margin-bottom: 1px;
882+
883+
&:hover {
884+
opacity: 1;
885+
}
886+
}
887+
888+
.remove_date_fieldset {
889+
color: #d9534f;
890+
}
891+
}
857892
}
858893

859-
#todo_new_action_container .show_from_input {
894+
#todo_new_action_container > .show_from_input {
860895
float: right;
861896
}
862897

app/controllers/todos/todo_create_params_helper.rb

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,10 @@ def predecessor_list
7979
end
8080

8181
def parse_dates
82+
return if @attributes['due'].is_a?(Array) && @attributes['due'].count(&:present?) > 1 # multi-date: parsed per-element in controller
83+
# unwrap single-element arrays coming from the multi-date form fields
84+
@attributes['due'] = @attributes['due'].first if @attributes['due'].is_a?(Array)
85+
@attributes['show_from'] = @attributes['show_from'].first if @attributes['show_from'].is_a?(Array)
8286
@attributes['show_from'] = @user.prefs.parse_date(show_from)
8387
@attributes['due'] = @user.prefs.parse_date(due)
8488
@attributes['due'] ||= ''
@@ -129,7 +133,7 @@ def todo_params(params)
129133

130134
filtered = params.require(:todo).permit(
131135
:context_id, :project_id, :description, :notes,
132-
:due, :show_from, :state,
136+
:due, :show_from, :state, due: [], show_from: [],
133137
# XML API
134138
:tags => [:tag => [:name]],
135139
:context => [:name],

app/controllers/todos_controller.rb

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,13 @@ def create
9191
@tag_name = params['_tag_name']
9292

9393
is_multiple = params[:todo] && params[:todo][:multiple_todos] && !params[:todo][:multiple_todos].nil?
94+
is_multiple_dates = params[:todo] &&
95+
params[:todo][:due].is_a?(Array) &&
96+
params[:todo][:due].count(&:present?) > 1
9497
if is_multiple
9598
create_multiple
99+
elsif is_multiple_dates
100+
create_multiple_dates
96101
else
97102
p = Todos::TodoCreateParamsHelper.new(params, current_user)
98103
p.parse_dates unless mobile?
@@ -240,6 +245,67 @@ def create_multiple
240245
end
241246
end
242247

248+
def create_multiple_dates
249+
p = Todos::TodoCreateParamsHelper.new(params, current_user)
250+
# parse_dates is skipped because due/show_from are arrays (guarded in helper)
251+
tag_list = p.tag_list
252+
253+
due_dates = Array(params[:todo][:due]).map { |d| current_user.prefs.parse_date(d) }
254+
show_from_dates = Array(params[:todo][:show_from]).map { |d| current_user.prefs.parse_date(d) }
255+
256+
@todos = []
257+
@build_todos = []
258+
validates = true
259+
260+
due_dates.each_with_index do |due, i|
261+
next if due.blank? && show_from_dates[i].blank?
262+
263+
todo_attrs = p.attributes.merge('due' => due, 'show_from' => show_from_dates[i])
264+
todo = current_user.todos.build
265+
todo.assign_attributes(todo_attrs)
266+
validates &&= todo.valid?
267+
@build_todos << todo
268+
end
269+
270+
if validates && @build_todos.any?
271+
@build_todos.each do |todo|
272+
@saved = todo.save
273+
todo.tag_with(tag_list) if @saved && tag_list.present?
274+
@todos << todo if @saved
275+
end
276+
@saved = @todos.size == @build_todos.size
277+
else
278+
@todos = @build_todos
279+
@saved = false
280+
end
281+
282+
@not_done_todos = @todos if p.new_project_created || p.new_context_created
283+
284+
respond_to do |format|
285+
format.html { redirect_to action: 'index' }
286+
format.js do
287+
determine_down_count if @saved
288+
@contexts = current_user.contexts if p.new_context_created
289+
@projects = current_user.projects if p.new_project_created
290+
@new_project_created = p.new_project_created
291+
@new_context_created = p.new_context_created
292+
@initial_context_name = params['default_context_name']
293+
@initial_project_name = params['default_project_name']
294+
@initial_tags = params['initial_tag_list']
295+
if @saved && @todos.size > 0
296+
@default_tags = @todos[0].project.default_tags unless @todos[0].project.nil?
297+
else
298+
@multiple_error = @todos.size > 0 ? '' : t('todos.next_action_needed')
299+
@saved = false
300+
end
301+
@status_message = @todos.size > 1 ? t('todos.added_new_next_action_plural') : t('todos.added_new_next_action_singular')
302+
@status_message = t('todos.added_new_project') + ' / ' + @status_message if p.new_project_created
303+
@status_message = t('todos.added_new_context') + ' / ' + @status_message if p.new_context_created
304+
render action: 'create_multiple'
305+
end
306+
end
307+
end
308+
243309
def edit
244310
@todo = current_user.todos.find(params['id'])
245311
@source_view = params['_source_view'] || 'todo'

app/helpers/application_helper.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,8 @@ def get_list_of_error_messages_for(model)
205205
model.errors.full_messages.collect { |msg| concat(content_tag(:li, msg)) }
206206
end
207207
end
208+
else
209+
"".html_safe
208210
end
209211
end
210212

app/views/todos/_new_todo_form.html.erb

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,15 +37,19 @@
3737
<%= content_tag("div", "", :id => "tag_list_auto_complete", :class => "auto_complete") %>
3838
</div>
3939

40-
<div class="form-group">
41-
<div class="due_input">
42-
<label for="todo_due"><%= Todo.human_attribute_name('due') %></label>
43-
<%= t.text_field("due", "size" => 12, "class" => "Date form-control input-sm", "autocomplete" => "off") %>
44-
</div>
40+
<div class="form-group" id="date_fieldsets_container">
41+
<div class="date_fieldset">
42+
<div class="due_input">
43+
<label><%= Todo.human_attribute_name('due') %></label>
44+
<%= t.text_field("due", name: "todo[due][]", "size" => 12, "class" => "Date form-control input-sm", "autocomplete" => "off") %>
45+
</div>
46+
47+
<div class="show_from_input">
48+
<label><%= Todo.human_attribute_name('show_from') %></label>
49+
<%= t.text_field("show_from", name: "todo[show_from][]", "size" => 12, "class" => "Date form-control input-sm", "autocomplete" => "off") %>
50+
</div>
4551

46-
<div class="show_from_input">
47-
<label for="todo_show_from"><%= Todo.human_attribute_name('show_from') %></label>
48-
<%= t.text_field("show_from", "size" => 12, "class" => "Date form-control input-sm", "autocomplete" => "off") %>
52+
<a href="#" id="add_date_fieldset" class="date_fieldset_btn" title="<%= t('todos.add_another_date') %>"><i class="fas fa-plus-circle"></i></a>
4953
</div>
5054
</div>
5155

config/locales/en.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1003,6 +1003,7 @@ en:
10031003
tag_deferred_pending: Deferred/pending actions tagged with '%{param}'
10041004
tag_hidden: Hidden actions tagged with '%{param}'
10051005
add_another_dependency: Add another dependency
1006+
add_another_date: Add another date
10061007
add_new_recurring: Add a new recurring action
10071008
added_dependency: Added %{dependency} as dependency.
10081009
added_new_context: Added new context

test/controllers/todos_controller_test.rb

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,26 @@ def test_add_multiple_dependent_todos
323323
assert !@d.predecessors.include?(@c), "c should not be a predecessor of d"
324324
end
325325

326+
def test_create_multiple_date_todos
327+
login_as(:admin_user)
328+
start_count = Todo.count
329+
put :create, xhr: true, params: {
330+
"_source_view" => "todo",
331+
"context_name" => "library",
332+
"project_name" => "Build a working time machine",
333+
"todo" => {
334+
"description" => "Multi-date test",
335+
"notes" => "",
336+
"due" => ["30/11/2026", "01/12/2026"],
337+
"show_from" => ["", ""]
338+
}
339+
}
340+
assert_equal start_count + 2, Todo.count, "two todos should have been created"
341+
todos = Todo.where(description: "Multi-date test").order(:due)
342+
assert_equal Date.new(2026, 11, 30), todos.first.due.to_date
343+
assert_equal Date.new(2026, 12, 1), todos.last.due.to_date
344+
end
345+
326346
#########
327347
# destroy
328348
#########

0 commit comments

Comments
 (0)