Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions app/assets/javascripts/tracks.js.erb
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,26 @@ var TracksForm = {
}
return false;
});

/* add another date fieldset for multi-date todo creation */
$(document).on("click", "#add_date_fieldset", function(e) {
e.preventDefault();
var $container = $("#date_fieldsets_container");
var $original = $container.find(".date_fieldset").first();
var $clone = $original.clone(false);
$clone.find("input").val("").removeClass("hasDatepicker").removeAttr("id");
// Replace the + button with a − remove button on the clone
$clone.find(".date_fieldset_btn").remove();
$clone.append('<a href="#" class="date_fieldset_btn remove_date_fieldset" title="' + i18n['todos.remove_date'] + '" aria-label="' + i18n['todos.remove_date'] + '"><i class="fas fa-minus-circle"></i></a>');
$container.find(".date_fieldset").last().after($clone);
TracksPages.setup_datepicker();
});

/* remove a cloned date fieldset */
$(document).on("click", ".remove_date_fieldset", function(e) {
e.preventDefault();
$(this).closest(".date_fieldset").remove();
});
},
enable_dependency_delete: function() {
$(document).on("click", 'a[class=icon_delete_dep]', function() {
Expand Down
37 changes: 36 additions & 1 deletion app/assets/stylesheets/include/legacy.scss
Original file line number Diff line number Diff line change
Expand Up @@ -854,9 +854,44 @@ input#go_to_project, input#context_hide {
.show_from_input, .due_input {
width: 45%;
}

.date_fieldset {
display: flex;
align-items: flex-end;
gap: 6px;
margin-bottom: 4px;

.due_input, .show_from_input {
flex: 1;
float: none;
}

.date_fieldset_btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 30px;
flex-shrink: 0;
color: #5cb85c;
font-size: 16px;
text-decoration: none;
opacity: 0.75;
transition: opacity 0.15s;
margin-bottom: 1px;

&:hover {
opacity: 1;
}
}

.remove_date_fieldset {
color: #d9534f;
}
}
}

#todo_new_action_container .show_from_input {
#todo_new_action_container > .show_from_input {
float: right;
}

Expand Down
7 changes: 6 additions & 1 deletion app/controllers/todos/todo_create_params_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,11 @@ def predecessor_list
end

def parse_dates
return if (@attributes['due'].is_a?(Array) && @attributes['due'].count(&:present?) > 1) ||
(@attributes['show_from'].is_a?(Array) && @attributes['show_from'].count(&:present?) > 1) # multi-date: parsed per-element in controller
# unwrap single-element arrays coming from the multi-date form fields
@attributes['due'] = @attributes['due'].first if @attributes['due'].is_a?(Array)
@attributes['show_from'] = @attributes['show_from'].first if @attributes['show_from'].is_a?(Array)
Comment on lines +84 to +86
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parse_dates unwraps single-element array values using .first. With the multi-date form it’s possible to have an array where only one element is present but it isn’t the first (e.g., user leaves the first date blank and fills the second). In that case this will drop the entered date and parse an empty string instead. Consider selecting the single present value (e.g., first/only non-blank element) when the array contains <= 1 present entry, rather than always taking .first.

Suggested change
# unwrap single-element arrays coming from the multi-date form fields
@attributes['due'] = @attributes['due'].first if @attributes['due'].is_a?(Array)
@attributes['show_from'] = @attributes['show_from'].first if @attributes['show_from'].is_a?(Array)
# unwrap single-element arrays coming from the multi-date form fields,
# selecting the first/only non-blank element when there is at most one
if @attributes['due'].is_a?(Array)
due_values = @attributes['due']
if due_values.count(&:present?) <= 1
@attributes['due'] = due_values.find(&:present?) || due_values.first
end
end
if @attributes['show_from'].is_a?(Array)
show_from_values = @attributes['show_from']
if show_from_values.count(&:present?) <= 1
@attributes['show_from'] = show_from_values.find(&:present?) || show_from_values.first
end
end

Copilot uses AI. Check for mistakes.
@attributes['show_from'] = @user.prefs.parse_date(show_from)
@attributes['due'] = @user.prefs.parse_date(due)
@attributes['due'] ||= ''
Comment thread
ZeiP marked this conversation as resolved.
Expand Down Expand Up @@ -129,7 +134,7 @@ def todo_params(params)

filtered = params.require(:todo).permit(
:context_id, :project_id, :description, :notes,
:due, :show_from, :state,
:due, :show_from, :state, due: [], show_from: [],
# XML API
:tags => [:tag => [:name]],
:context => [:name],
Expand Down
71 changes: 71 additions & 0 deletions app/controllers/todos_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,13 @@ def create
@tag_name = params['_tag_name']

is_multiple = params[:todo] && params[:todo][:multiple_todos] && !params[:todo][:multiple_todos].nil?
is_multiple_dates = params[:todo] &&
((params[:todo][:due].is_a?(Array) && params[:todo][:due].count(&:present?) > 1) ||
(params[:todo][:show_from].is_a?(Array) && params[:todo][:show_from].count(&:present?) > 1))
if is_multiple
create_multiple
elsif is_multiple_dates
create_multiple_dates
else
p = Todos::TodoCreateParamsHelper.new(params, current_user)
p.parse_dates unless mobile?
Expand Down Expand Up @@ -240,6 +245,72 @@ def create_multiple
end
end

def create_multiple_dates
p = Todos::TodoCreateParamsHelper.new(params, current_user)
# parse_dates is skipped because due/show_from are arrays (guarded in helper)
tag_list = p.tag_list

due_dates = Array(params[:todo][:due]).map { |d| current_user.prefs.parse_date(d) }
show_from_dates = Array(params[:todo][:show_from]).map { |d| current_user.prefs.parse_date(d) }

@todos = []
@build_todos = []
validates = true

due_dates.each_with_index do |due, i|
next if due.blank? && show_from_dates[i].blank?

todo_attrs = p.attributes.merge('due' => due, 'show_from' => show_from_dates[i])
todo = current_user.todos.build
todo.assign_attributes(todo_attrs)
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

create_multiple_dates builds/validates todos without applying helper-level errors from Todos::TodoCreateParamsHelper (e.g., invalid context_id/project_id handled via p.add_errors). In the normal create path you call p.add_errors(@todo) before deciding whether to save; the multi-date path should do the same for each built todo (before valid?) so these errors are surfaced and prevent saving.

Suggested change
todo.assign_attributes(todo_attrs)
todo.assign_attributes(todo_attrs)
p.add_errors(todo)

Copilot uses AI. Check for mistakes.
validates &&= todo.valid?
@build_todos << todo
end

if validates && @build_todos.any?
@build_todos.each do |todo|
@saved = todo.save
if @saved
todo.tag_with(tag_list) if tag_list.present?
todo.add_predecessor_list(p.predecessor_list) if p.predecessor_list.present?
todo.block! if todo.uncompleted_predecessors?
@todos << todo
end
end
@saved = @todos.size == @build_todos.size
Comment on lines +271 to +280
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When saving in create_multiple_dates, failed saves are not added to @todos (only successful ones are pushed). If any save fails after the pre-validation step, the JS template won’t have access to the errored records to render their validation messages, making debugging difficult. Consider always pushing each attempted todo into @todos (like create_multiple does) so errors can be displayed, and use a separate flag to track overall success.

Suggested change
@build_todos.each do |todo|
@saved = todo.save
if @saved
todo.tag_with(tag_list) if tag_list.present?
todo.add_predecessor_list(p.predecessor_list) if p.predecessor_list.present?
todo.block! if todo.uncompleted_predecessors?
@todos << todo
end
end
@saved = @todos.size == @build_todos.size
@saved = true
@build_todos.each do |todo|
saved = todo.save
if saved
todo.tag_with(tag_list) if tag_list.present?
todo.add_predecessor_list(p.predecessor_list) if p.predecessor_list.present?
todo.block! if todo.uncompleted_predecessors?
end
@saved &&= saved
@todos << todo
end

Copilot uses AI. Check for mistakes.
else
@todos = @build_todos
@saved = false
end

@todo = @todos.last if @todos.present?
@not_done_todos = @todos if p.new_project_created || p.new_context_created

respond_to do |format|
format.html { redirect_to action: 'index' }
format.js do
determine_down_count if @saved
@contexts = current_user.contexts if p.new_context_created
@projects = current_user.projects if p.new_project_created
@new_project_created = p.new_project_created
@new_context_created = p.new_context_created
@initial_context_name = params['default_context_name']
@initial_project_name = params['default_project_name']
@initial_tags = params['initial_tag_list']
if @saved && @todos.size > 0
@default_tags = @todos[0].project.default_tags unless @todos[0].project.nil?
else
@multiple_error = @todos.size > 0 ? '' : t('todos.next_action_needed')
@saved = false
end
@status_message = @todos.size > 1 ? t('todos.added_new_next_action_plural') : t('todos.added_new_next_action_singular')
@status_message = t('todos.added_new_project') + ' / ' + @status_message if p.new_project_created
@status_message = t('todos.added_new_context') + ' / ' + @status_message if p.new_context_created
Comment thread
ZeiP marked this conversation as resolved.
render action: 'create_multiple_dates'
end
end
end

def edit
@todo = current_user.todos.find(params['id'])
@source_view = params['_source_view'] || 'todo'
Expand Down
3 changes: 3 additions & 0 deletions app/helpers/application_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ def generate_i18n_strings
common.cancel common.ok
common.update common.create
common.ajaxError todos.unresolved_dependency
todos.remove_date
}.each do |s|
js << "i18n['#{s}'] = '#{t(s).gsub(/'/, "\\\\'")}';\n"
end
Expand Down Expand Up @@ -205,6 +206,8 @@ def get_list_of_error_messages_for(model)
model.errors.full_messages.collect { |msg| concat(content_tag(:li, msg)) }
end
end
else
"".html_safe
end
end

Expand Down
20 changes: 12 additions & 8 deletions app/views/todos/_new_todo_form.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,19 @@
<%= content_tag("div", "", :id => "tag_list_auto_complete", :class => "auto_complete") %>
</div>

<div class="form-group">
<div class="due_input">
<label for="todo_due"><%= Todo.human_attribute_name('due') %></label>
<%= t.text_field("due", "size" => 12, "class" => "Date form-control input-sm", "autocomplete" => "off") %>
</div>
<div class="form-group" id="date_fieldsets_container">
<div class="date_fieldset">
<div class="due_input">
<label><%= Todo.human_attribute_name('due') %></label>
<%= t.text_field("due", name: "todo[due][]", "size" => 12, "class" => "Date form-control input-sm", "autocomplete" => "off", "aria-label" => Todo.human_attribute_name('due')) %>
</div>

<div class="show_from_input">
<label><%= Todo.human_attribute_name('show_from') %></label>
<%= t.text_field("show_from", name: "todo[show_from][]", "size" => 12, "class" => "Date form-control input-sm", "autocomplete" => "off", "aria-label" => Todo.human_attribute_name('show_from')) %>
Comment on lines +43 to +49
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The due/show_from <label> elements no longer have for attributes tied to the corresponding inputs. This breaks click-to-focus behavior and weakens accessible labeling (even though aria-label is set). Consider restoring proper for/id associations (and generating unique IDs for cloned fieldsets) rather than relying on aria-label alone.

Suggested change
<label><%= Todo.human_attribute_name('due') %></label>
<%= t.text_field("due", name: "todo[due][]", "size" => 12, "class" => "Date form-control input-sm", "autocomplete" => "off", "aria-label" => Todo.human_attribute_name('due')) %>
</div>
<div class="show_from_input">
<label><%= Todo.human_attribute_name('show_from') %></label>
<%= t.text_field("show_from", name: "todo[show_from][]", "size" => 12, "class" => "Date form-control input-sm", "autocomplete" => "off", "aria-label" => Todo.human_attribute_name('show_from')) %>
<label for="todo_due_0"><%= Todo.human_attribute_name('due') %></label>
<%= t.text_field("due", name: "todo[due][]", id: "todo_due_0", "size" => 12, "class" => "Date form-control input-sm", "autocomplete" => "off", "aria-label" => Todo.human_attribute_name('due')) %>
</div>
<div class="show_from_input">
<label for="todo_show_from_0"><%= Todo.human_attribute_name('show_from') %></label>
<%= t.text_field("show_from", name: "todo[show_from][]", id: "todo_show_from_0", "size" => 12, "class" => "Date form-control input-sm", "autocomplete" => "off", "aria-label" => Todo.human_attribute_name('show_from')) %>

Copilot uses AI. Check for mistakes.
</div>

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

Expand Down
71 changes: 71 additions & 0 deletions app/views/todos/create_multiple_dates.js.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<% unless @saved -%>
TracksPages.show_errors(html_for_error_messages());

function html_for_error_messages() {
<%
@multiple_error = content_tag(:div, content_tag(:p, @multiple_error), {:class => 'errorExplanation', :id => 'errorExplanation'}) if @multiple_error.present?
error_messages = @multiple_error || ""
@todos.each do |todo|
error_messages += get_list_of_error_messages_for(todo)
end
-%>
return "<%= escape_javascript(error_messages.html_safe)%>";
Comment on lines +6 to +12
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the error branch, error_messages concatenates get_list_of_error_messages_for(todo) for each todo. That helper renders a <div id="errorExplanation">..., so multiple invalid todos will produce duplicate id="errorExplanation" elements inside #error_status, which is invalid HTML and can confuse DOM selectors/CSS. Consider rendering a single wrapper (no duplicated IDs) and aggregating the <li> messages instead.

Suggested change
@multiple_error = content_tag(:div, content_tag(:p, @multiple_error), {:class => 'errorExplanation', :id => 'errorExplanation'}) if @multiple_error.present?
error_messages = @multiple_error || ""
@todos.each do |todo|
error_messages += get_list_of_error_messages_for(todo)
end
-%>
return "<%= escape_javascript(error_messages.html_safe)%>";
# Collect all individual error messages from the todos
error_items = []
@todos.each do |todo|
next unless todo.respond_to?(:errors) && todo.errors.respond_to?(:full_messages)
todo.errors.full_messages.each do |msg|
error_items << msg
end
end
# Build a single errorExplanation wrapper with optional header and list of messages
error_html = "".html_safe
if @multiple_error.present? || error_items.any?
inner_html = "".html_safe
inner_html << content_tag(:p, @multiple_error) if @multiple_error.present?
if error_items.any?
inner_html << content_tag(:ul) do
error_items.map { |msg| content_tag(:li, msg) }.join.html_safe
end
end
error_html = content_tag(:div, inner_html, :class => 'errorExplanation', :id => 'errorExplanation')
end
-%>
return "<%= escape_javascript(error_html) %>";

Copilot uses AI. Check for mistakes.
}

<% else -%>
TracksPages.page_inform("<%=escape_javascript @status_message%>");
hide_empty_message();
TracksPages.hide_errors();
TracksPages.set_page_badge(<%= @down_count %>);
<% if should_show_new_item -%>
<% if @new_context_created -%>
insert_new_context_with_new_todo();
<% else -%>
add_todo_to_existing_context();
<% end -%>
<% end -%>
clear_form();

function clear_form() {
$('#todo-form-new-action').clearForm();
$('#todo-form-new-action').clearDeps();
/* Remove any cloned date fieldsets, keeping only the first */
$('#date_fieldsets_container .date_fieldset:not(:first)').remove();
TracksForm.set_context_name('<%=escape_javascript @initial_context_name%>');
TracksForm.set_project_name_and_default_project_name('<%=escape_javascript @initial_project_name%>');
TracksForm.set_tag_list_and_default_tag_list('<%=escape_javascript @initial_tags%>');
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

clear_form() here resets the form fields but does not reset the starred toggle (#new_todo_starred / .todo_star). In the normal single-todo create flow (create.js.erb) the starred state is explicitly cleared; without doing the same here the next todo can be unintentionally created as starred/unstarred based on prior state.

Suggested change
TracksForm.set_tag_list_and_default_tag_list('<%=escape_javascript @initial_tags%>');
TracksForm.set_tag_list_and_default_tag_list('<%=escape_javascript @initial_tags%>');
$('#new_todo_starred').val('false');
$('.todo_star').removeClass('starred');

Copilot uses AI. Check for mistakes.
$('#todo-form-new-action input:text:first').focus();
}

function insert_new_context_with_new_todo() {
$('#display_box').prepend(html_for_new_context());
}

function hide_empty_message() {
<% if (source_view_is :project and @todo.pending?) or (source_view_is :deferred) -%>
$('#deferred_pending_container-empty-d').hide();
<% else -%>
$('#no_todos_in_view').hide();
<% end -%>
}

function add_todo_to_existing_context() {
<%
@todos.each do |todo|
if should_show_new_item(todo)
html = js_render(todo, { :parent_container_type => parent_container_type, :source_view => @source_view })
-%>
$('#<%= empty_container_msg_div_id(todo) %>').hide();
$('#<%= item_container_id(todo) %>').append('<%= html %>');
$('#<%= item_container_id(todo) %>').fadeIn(500, function() {
$('#<%= dom_id(todo) %>').effect('highlight', {}, 2000 );
});
<% end %>
<% end %>
}

function html_for_new_context() {
return "<%= @new_context_created ? js_render(@todo.context, { :settings => {:collapsible => true} }) : "" %>";
}

<% end -%>
2 changes: 2 additions & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1003,6 +1003,8 @@ en:
tag_deferred_pending: Deferred/pending actions tagged with '%{param}'
tag_hidden: Hidden actions tagged with '%{param}'
add_another_dependency: Add another dependency
add_another_date: Add another date
remove_date: Remove date
add_new_recurring: Add a new recurring action
added_dependency: Added %{dependency} as dependency.
added_new_context: Added new context
Expand Down
21 changes: 21 additions & 0 deletions test/controllers/todos_controller_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,27 @@ def test_add_multiple_dependent_todos
assert !@d.predecessors.include?(@c), "c should not be a predecessor of d"
end

def test_create_multiple_date_todos
login_as(:admin_user)
start_count = Todo.count
put :create, xhr: true, params: {
"_source_view" => "todo",
"context_name" => "library",
"project_name" => "Build a working time machine",
"todo" => {
"description" => "Multi-date test",
"notes" => "",
"due" => ["30/11/2026", "01/12/2026"],
"show_from" => ["", ""]
}
}
assert_response :success
assert_equal start_count + 2, Todo.count, "two todos should have been created"
todos = Todo.where(description: "Multi-date test").order(:due)
assert_equal Date.new(2026, 11, 30), todos.first.due.to_date
assert_equal Date.new(2026, 12, 1), todos.last.due.to_date
end
Comment thread
ZeiP marked this conversation as resolved.

#########
# destroy
#########
Expand Down
Loading