From 49e977fd5b27c32d31d41d1e631ddfbbf34a0737 Mon Sep 17 00:00:00 2001 From: Jyri-Petteri Paloposki Date: Tue, 24 Mar 2026 18:52:21 +0200 Subject: [PATCH] Allow adding the same todo to multiple dates --- app/assets/javascripts/tracks.js.erb | 20 ++++++ app/assets/stylesheets/include/legacy.scss | 37 +++++++++- .../todos/todo_create_params_helper.rb | 7 +- app/controllers/todos_controller.rb | 71 +++++++++++++++++++ app/helpers/application_helper.rb | 3 + app/views/todos/_new_todo_form.html.erb | 20 +++--- app/views/todos/create_multiple_dates.js.erb | 71 +++++++++++++++++++ config/locales/en.yml | 2 + test/controllers/todos_controller_test.rb | 21 ++++++ 9 files changed, 242 insertions(+), 10 deletions(-) create mode 100644 app/views/todos/create_multiple_dates.js.erb diff --git a/app/assets/javascripts/tracks.js.erb b/app/assets/javascripts/tracks.js.erb index 2461d4966..ea7beab98 100644 --- a/app/assets/javascripts/tracks.js.erb +++ b/app/assets/javascripts/tracks.js.erb @@ -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(''); + $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() { diff --git a/app/assets/stylesheets/include/legacy.scss b/app/assets/stylesheets/include/legacy.scss index bfc3052cb..f28acd7d0 100644 --- a/app/assets/stylesheets/include/legacy.scss +++ b/app/assets/stylesheets/include/legacy.scss @@ -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; } diff --git a/app/controllers/todos/todo_create_params_helper.rb b/app/controllers/todos/todo_create_params_helper.rb index 717bc6a53..f4bc472b9 100644 --- a/app/controllers/todos/todo_create_params_helper.rb +++ b/app/controllers/todos/todo_create_params_helper.rb @@ -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) @attributes['show_from'] = @user.prefs.parse_date(show_from) @attributes['due'] = @user.prefs.parse_date(due) @attributes['due'] ||= '' @@ -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], diff --git a/app/controllers/todos_controller.rb b/app/controllers/todos_controller.rb index 05b7a95a8..9f731e83c 100644 --- a/app/controllers/todos_controller.rb +++ b/app/controllers/todos_controller.rb @@ -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? @@ -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) + 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 + 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 + render action: 'create_multiple_dates' + end + end + end + def edit @todo = current_user.todos.find(params['id']) @source_view = params['_source_view'] || 'todo' diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 0a93e66c6..b645b9af2 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -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 @@ -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 diff --git a/app/views/todos/_new_todo_form.html.erb b/app/views/todos/_new_todo_form.html.erb index 307a8a182..016294c96 100644 --- a/app/views/todos/_new_todo_form.html.erb +++ b/app/views/todos/_new_todo_form.html.erb @@ -37,15 +37,19 @@ <%= content_tag("div", "", :id => "tag_list_auto_complete", :class => "auto_complete") %> -
-
- - <%= t.text_field("due", "size" => 12, "class" => "Date form-control input-sm", "autocomplete" => "off") %> -
+
+
+
+ + <%= t.text_field("due", name: "todo[due][]", "size" => 12, "class" => "Date form-control input-sm", "autocomplete" => "off", "aria-label" => Todo.human_attribute_name('due')) %> +
+ +
+ + <%= 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')) %> +
-
- - <%= t.text_field("show_from", "size" => 12, "class" => "Date form-control input-sm", "autocomplete" => "off") %> +
diff --git a/app/views/todos/create_multiple_dates.js.erb b/app/views/todos/create_multiple_dates.js.erb new file mode 100644 index 000000000..8cfa4533b --- /dev/null +++ b/app/views/todos/create_multiple_dates.js.erb @@ -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)%>"; + } + +<% 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%>'); + $('#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 -%> diff --git a/config/locales/en.yml b/config/locales/en.yml index 102f38632..14680691b 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -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 diff --git a/test/controllers/todos_controller_test.rb b/test/controllers/todos_controller_test.rb index 99ffb70f1..b5551a6ba 100644 --- a/test/controllers/todos_controller_test.rb +++ b/test/controllers/todos_controller_test.rb @@ -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 + ######### # destroy #########