From 317aa08365bbb1a4b8c525b015fcd36709abe54e Mon Sep 17 00:00:00 2001 From: Jens Kraemer Date: Tue, 5 Oct 2021 04:30:31 +0800 Subject: [PATCH 1/5] time entry form fixes - issue selection selects the project accordingly - issue / project selection updates available activities - move project / issue selector to top. more logical this way, since making a selection there may change the available activities --- app/controllers/stopwatch_timers_controller.rb | 12 +++++++++++- app/views/stopwatch_timers/_entry_form.html.erb | 9 ++++----- app/views/stopwatch_timers/update_form.js.erb | 5 +++++ config/routes.rb | 1 + 4 files changed, 21 insertions(+), 6 deletions(-) create mode 100644 app/views/stopwatch_timers/update_form.js.erb diff --git a/app/controllers/stopwatch_timers_controller.rb b/app/controllers/stopwatch_timers_controller.rb index 8337cdc..145793e 100644 --- a/app/controllers/stopwatch_timers_controller.rb +++ b/app/controllers/stopwatch_timers_controller.rb @@ -73,6 +73,17 @@ def current render json: @timer.to_json end + def update_form + if id = params[:time_entry_id].presence + @time_entry = TimeEntry.visible.find id + else + @time_entry = TimeEntry.new + end + @time_entry.safe_attributes = params[:time_entry] + rescue ActiveRecord::RecordNotFound + head 404 + end + private def find_timer @@ -82,7 +93,6 @@ def find_timer def find_time_entry @time_entry = time_entries.find params[:id] - end def load_todays_entries diff --git a/app/views/stopwatch_timers/_entry_form.html.erb b/app/views/stopwatch_timers/_entry_form.html.erb index 653fd0c..3fed802 100644 --- a/app/views/stopwatch_timers/_entry_form.html.erb +++ b/app/views/stopwatch_timers/_entry_form.html.erb @@ -2,16 +2,15 @@
<%= t @time_entry.new_record? ? '.legend_new' : '.legend_edit' %> -

<%= f.select :activity_id, activity_collection_for_select_options(@time_entry), :required => true %>

- -

<%= f.select :project_id, project_tree_options_for_select(Project.allowed_to(:log_time).to_a, :selected => @time_entry.project, :include_blank => true), :required => true %>

+

<%= f.select :project_id, project_tree_options_for_select(Project.allowed_to(:log_time).to_a, :selected => @time_entry.project, :include_blank => true), { no_label: true }, id: 'stopwatch_time_entry_project_id' %>

<%= f.text_field :issue_id, :size => 6, :required => Setting.timelog_required_fields.include?('issue_id') %> - + <%= link_to_issue(@time_entry.issue) if @time_entry.issue.try(:visible?) %>

+

<%= f.select :activity_id, activity_collection_for_select_options(@time_entry), { no_label: true }, id: 'stopwatch_time_entry_activity_id' %>

@@ -30,7 +29,7 @@ }); $('#time_entry_project_id, #time_entry_issue_id').change(function(){ $.ajax({ - url: '<%= escape_javascript(@time_entry.new_record? ? new_time_entry_path(format: 'js') : edit_time_entry_path(@time_entry, format: 'js')) %>', + url: '<%= j update_form_stopwatch_timers_path(time_entry_id: @time_entry.id, format: 'js') %>', type: 'post', data: $(this).closest('form').serialize() }); diff --git a/app/views/stopwatch_timers/update_form.js.erb b/app/views/stopwatch_timers/update_form.js.erb new file mode 100644 index 0000000..2cb2749 --- /dev/null +++ b/app/views/stopwatch_timers/update_form.js.erb @@ -0,0 +1,5 @@ +$('#stopwatch_time_entry_activity_id').html('<%= escape_javascript options_for_select(activity_collection_for_select_options(@time_entry), @time_entry.activity_id) %>'); +$('#stopwatch_time_entry_issue').html('<%= escape_javascript link_to_issue(@time_entry.issue) if @time_entry.issue.try(:visible?) %>'); +$('#stopwatch_time_entry_project_id').html('<%= escape_javascript project_tree_options_for_select(Project.allowed_to(:log_time).to_a, :selected => @time_entry.project, :include_blank => true) %>'); + + diff --git a/config/routes.rb b/config/routes.rb index 814cb6f..5aad544 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,6 +1,7 @@ resources :stopwatch_timers, only: %i(new create edit update) do collection do get :current + post :update_form end member do put :start From d32845cc6eec74ff5bf3a6bf65e2e34b4b1813be Mon Sep 17 00:00:00 2001 From: Jens Kraemer Date: Tue, 5 Oct 2021 04:33:47 +0800 Subject: [PATCH 2/5] extract controller base class --- app/controllers/stopwatch_controller.rb | 12 ++++++++++++ app/controllers/stopwatch_timers_controller.rb | 4 +--- 2 files changed, 13 insertions(+), 3 deletions(-) create mode 100644 app/controllers/stopwatch_controller.rb diff --git a/app/controllers/stopwatch_controller.rb b/app/controllers/stopwatch_controller.rb new file mode 100644 index 0000000..7ddf659 --- /dev/null +++ b/app/controllers/stopwatch_controller.rb @@ -0,0 +1,12 @@ +# base class for stopwatch controllers +class StopwatchController < ApplicationController + helper :timelog, :custom_fields + + before_action :require_login + + private + + def authorize_edit_time + @time_entry.editable_by?(User.current) or deny_access + end +end diff --git a/app/controllers/stopwatch_timers_controller.rb b/app/controllers/stopwatch_timers_controller.rb index 145793e..2408620 100644 --- a/app/controllers/stopwatch_timers_controller.rb +++ b/app/controllers/stopwatch_timers_controller.rb @@ -8,10 +8,8 @@ # - same for project, unless we are in a project context # - focus first field that needs an action, depending on above # -class StopwatchTimersController < ApplicationController - helper :timelog, :custom_fields +class StopwatchTimersController < StopwatchController - before_action :require_login before_action :find_optional_data, only: %i(new create) before_action :authorize_log_time, only: %i(new create start stop current) before_action :find_time_entry, only: %i(edit update start stop) From bd6711a59fe8b7f318c547019dbfda2970282762 Mon Sep 17 00:00:00 2001 From: Jens Kraemer Date: Tue, 5 Oct 2021 04:35:34 +0800 Subject: [PATCH 3/5] plugin config - default activity setting - this can be set to make the new 'Start tracking for issue' function a one click operation --- app/views/stopwatch/_settings.html.erb | 4 ++ init.rb | 4 ++ lib/stopwatch.rb | 34 +++++++++++ test/unit/stopwatch_test.rb | 78 ++++++++++++++++++++++++++ 4 files changed, 120 insertions(+) create mode 100644 app/views/stopwatch/_settings.html.erb create mode 100644 test/unit/stopwatch_test.rb diff --git a/app/views/stopwatch/_settings.html.erb b/app/views/stopwatch/_settings.html.erb new file mode 100644 index 0000000..873d442 --- /dev/null +++ b/app/views/stopwatch/_settings.html.erb @@ -0,0 +1,4 @@ +

+ + <%= select_tag 'settings[default_activity]', options_from_collection_for_select( [['always_ask', t('.label_always_ask')], ['system', t('.label_system')]] + TimeEntryActivity.system.active.to_a.pluck(:id, :name), :first, :last, Stopwatch.settings['default_activity'] ) %> +

diff --git a/init.rb b/init.rb index 7b68dd0..c634f6e 100644 --- a/init.rb +++ b/init.rb @@ -1,3 +1,4 @@ +require_dependency 'stopwatch' require 'stopwatch/hooks' Redmine::Plugin.register :stopwatch do @@ -8,6 +9,9 @@ version '0.1.0' requires_redmine version_or_higher: '3.4.0' + settings default: { + 'default_activity' => 'always_ask', + }, partial: 'stopwatch/settings' menu :account_menu, :stopwatch, :new_stopwatch_timer_path, diff --git a/lib/stopwatch.rb b/lib/stopwatch.rb index 0b14ec8..723e8f5 100644 --- a/lib/stopwatch.rb +++ b/lib/stopwatch.rb @@ -1,2 +1,36 @@ +# frozen_string_literal: true + module Stopwatch + def self.settings + Setting.plugin_stopwatch + end + + def self.default_activity + if id = settings['default_activity'].presence + if id.to_s =~ /^\d+$/ + TimeEntryActivity.find_by_id id + else + id + end + end + end + + def self.default_activity_for(time_entry) + default = Stopwatch.default_activity + return nil if default == 'always_ask' + + project = time_entry.project || time_entry.issue&.project + + if project.nil? + activities = TimeEntryActivity.shared.active + else + activities = project.activities + end + + if default == 'system' + activities.detect(&:is_default?) || activities.detect{|a| a.parent&.is_default?} || (activities.one? && activities[0]).presence + else + return activities.detect{ |a| a == default || a.parent == default } + end + end end diff --git a/test/unit/stopwatch_test.rb b/test/unit/stopwatch_test.rb new file mode 100644 index 0000000..78944c3 --- /dev/null +++ b/test/unit/stopwatch_test.rb @@ -0,0 +1,78 @@ +require_relative '../test_helper' + +class StopwatchTest < ActiveSupport::TestCase + fixtures :projects, :enabled_modules, :enumerations + + setup do + @project = Project.find 'ecookbook' + @te = TimeEntry.new project: @project + end + + test "default value for default_activity" do + assert_equal 'always_ask', Stopwatch.default_activity + end + + test "should find default activity" do + with_settings plugin_stopwatch: { 'default_activity' => 9} do + assert_equal 'Design', Stopwatch.default_activity.name + end + with_settings plugin_stopwatch: { 'default_activity' => '11'} do + assert_equal 'QA', Stopwatch.default_activity.name + end + end + + ### 'system' -> ask if there is no sys default / more than one availabale activity + + test "should use system default activity for te" do + with_settings plugin_stopwatch: { 'default_activity' => 'system' } do + assert_equal 'Development', Stopwatch.default_activity_for(@te).name + end + end + + test "should use single active project activity for time entry" do + with_settings plugin_stopwatch: { 'default_activity' => 'system' } do + TimeEntryActivity.create! active: false, project: @project, name: 'Development', parent_id: 10 + TimeEntryActivity.create! active: false, project: @project, name: 'QA', parent_id: 11 + assert_equal 'Design', Stopwatch.default_activity_for(@te).name + end + end + + test "should ask if more than one and no default" do + with_settings plugin_stopwatch: { 'default_activity' => 'system' } do + TimeEntryActivity.update_all is_default: false + assert_nil Stopwatch.default_activity_for(@te) + end + end + + ### always_ask (the default) + + test "should return nil if always ask is set" do + assert_nil Stopwatch.default_activity_for(@te) + + TimeEntryActivity.create! active: false, project: @project, name: 'Development' + assert_nil Stopwatch.default_activity_for(@te) + + TimeEntryActivity.where(name: 'Development').delete_all + assert_nil Stopwatch.default_activity_for(@te) + end + + ### specific activity -> ask only if this is not available in project + + test "should use configured default activity for time entry" do + with_settings plugin_stopwatch: { 'default_activity' => '9' } do + assert_equal 'Design', Stopwatch.default_activity_for(@te).name + end + end + + test "should not have default if unavailable" do + TimeEntryActivity.create! active: false, project: @project, name: 'Design', parent_id: 9 + TimeEntryActivity.create! active: false, project: @project, name: 'Development', parent_id: 11 + assert_equal [10], @project.activities.pluck(:id) + + with_settings plugin_stopwatch: { 'default_activity' => '9'} do + assert_nil Stopwatch.default_activity_for(@te) + end + end + +end + From 033e1739d21b33f8a6b7b36c849ee32217313b62 Mon Sep 17 00:00:00 2001 From: Jens Kraemer Date: Tue, 5 Oct 2021 05:27:35 +0800 Subject: [PATCH 4/5] 'Start/Stop tracking' on issues#show --- .../stopwatch_issue_timers_controller.rb | 48 +++++++ .../stopwatch_timers_controller.rb | 4 +- .../hooks/_layouts_base_body_bottom.html.erb | 6 +- .../stopwatch_issue_timers/_new.html.erb | 20 +++ app/views/stopwatch_issue_timers/start.js.erb | 8 ++ app/views/stopwatch_issue_timers/stop.js.erb | 5 + .../stopwatch_timers/_entry_form.html.erb | 22 ++-- app/views/stopwatch_timers/_new.html.erb | 6 +- app/views/stopwatch_timers/create.js.erb | 3 +- app/views/stopwatch_timers/start.js.erb | 7 +- assets/javascripts/stopwatch.js | 42 ++++++- config/locales/en.yml | 5 + config/routes.rb | 5 + init.rb | 3 +- lib/stopwatch/issue_links.rb | 29 +++++ lib/stopwatch/issue_timer.rb | 18 +++ lib/stopwatch/issues_controller_patch.rb | 26 ++++ lib/stopwatch/start_timer.rb | 9 +- lib/stopwatch/timer.rb | 3 +- lib/stopwatch/user_patch.rb | 5 + test/integration/ticket_timer_test.rb | 118 ++++++++++++++++++ test/unit/user_test.rb | 11 +- 22 files changed, 375 insertions(+), 28 deletions(-) create mode 100644 app/controllers/stopwatch_issue_timers_controller.rb create mode 100644 app/views/stopwatch_issue_timers/_new.html.erb create mode 100644 app/views/stopwatch_issue_timers/start.js.erb create mode 100644 app/views/stopwatch_issue_timers/stop.js.erb create mode 100644 lib/stopwatch/issue_links.rb create mode 100644 lib/stopwatch/issue_timer.rb create mode 100644 lib/stopwatch/issues_controller_patch.rb create mode 100644 test/integration/ticket_timer_test.rb diff --git a/app/controllers/stopwatch_issue_timers_controller.rb b/app/controllers/stopwatch_issue_timers_controller.rb new file mode 100644 index 0000000..60606de --- /dev/null +++ b/app/controllers/stopwatch_issue_timers_controller.rb @@ -0,0 +1,48 @@ +class StopwatchIssueTimersController < StopwatchController + before_action :find_issue + before_action :authorize_log_time + + def start + t = Stopwatch::IssueTimer.new(issue: @issue) + if t.running? + head 422; return + end + + time_entry = User.current.todays_time_entry_for(@issue) + if time_entry.new_record? + sys_default_activity = time_entry.activity + time_entry.activity = Stopwatch.default_activity_for time_entry + end + + r = Stopwatch::StartTimer.new(time_entry).call + if r.success? + @started_time_entry = time_entry + render status: :created + else + @time_entry = time_entry + # in case the setting is 'always ask', still preset the form to the global default + @time_entry.activity ||= sys_default_activity + @time_entry.errors.clear + render status: :ok + end + end + + def stop + r = Stopwatch::StopTimer.new.call + unless r.success? + logger.error "unable to stop timer" + head 422; return + end + end + + private + + def authorize_log_time + User.current.allowed_to?(:log_time, @project) or deny_access + end + + def find_issue + @issue = Issue.find params[:issue_id] + @project = @issue.project + end +end diff --git a/app/controllers/stopwatch_timers_controller.rb b/app/controllers/stopwatch_timers_controller.rb index 2408620..bdfa2a5 100644 --- a/app/controllers/stopwatch_timers_controller.rb +++ b/app/controllers/stopwatch_timers_controller.rb @@ -60,7 +60,9 @@ def start def stop r = Stopwatch::StopTimer.new.call - unless r.success? + if r.success? + @stopped_time_entry = @time_entry + else logger.error "unable to stop timer" end new unless params[:context] diff --git a/app/views/stopwatch/hooks/_layouts_base_body_bottom.html.erb b/app/views/stopwatch/hooks/_layouts_base_body_bottom.html.erb index e9359db..ea4b129 100644 --- a/app/views/stopwatch/hooks/_layouts_base_body_bottom.html.erb +++ b/app/views/stopwatch/hooks/_layouts_base_body_bottom.html.erb @@ -1,7 +1,11 @@ <%= javascript_tag do %> window.stopwatch = window.initStopwatch({ currentTimerUrl: '<%= j current_stopwatch_timers_url %>', - hourFormat: '<%= j format_hours 0.0 %>' + hourFormat: '<%= j format_hours 0.0 %>', + locales: { + startTimer: '<%= j l :label_stopwatch_start %>', + stopTimer: '<%= j l :label_stopwatch_stop %>' + }, }); <% if User.current.logged? %> window.stopwatch.highlightRunningTimer( diff --git a/app/views/stopwatch_issue_timers/_new.html.erb b/app/views/stopwatch_issue_timers/_new.html.erb new file mode 100644 index 0000000..f81c4a9 --- /dev/null +++ b/app/views/stopwatch_issue_timers/_new.html.erb @@ -0,0 +1,20 @@ +

<%= l(:button_log_time) %> - <%= format_date User.current.today %> - <%= "#{@issue.tracker} #{@issue.id}: #{truncate @issue.subject}" %>

+ +<%= labelled_form_for @time_entry, url: stopwatch_timers_path, method: :post, remote: true do |f| %> + <% @time_entry.hours ||= 0 %> + <%= f.hidden_field :issue_id, value: @issue.id %> + +
+ <%= t 'stopwatch_timers.entry_form.legend_new' %> +

<%= f.select :activity_id, activity_collection_for_select_options(@time_entry), :required => true %>

+

<%= f.text_field :comments, :size => 100, :maxlength => 1024, :required => Setting.timelog_required_fields.include?('comments') %>

+ <% @time_entry.custom_field_values.each do |value| %> +

<%= custom_field_tag_with_label :time_entry, value %>

+ <% end %> +
+ +

+ <%= submit_tag l(:button_create) %> +

+<% end %> + diff --git a/app/views/stopwatch_issue_timers/start.js.erb b/app/views/stopwatch_issue_timers/start.js.erb new file mode 100644 index 0000000..58ab882 --- /dev/null +++ b/app/views/stopwatch_issue_timers/start.js.erb @@ -0,0 +1,8 @@ +<% if @started_time_entry %> + window.stopwatch.timerStarted( + <%= raw Stopwatch::Timer.new(User.current).to_json %> + ); +<% else %> + $('#ajax-modal').html('<%= j render partial: 'new' %>'); + showModal('ajax-modal', '700px'); +<% end %> diff --git a/app/views/stopwatch_issue_timers/stop.js.erb b/app/views/stopwatch_issue_timers/stop.js.erb new file mode 100644 index 0000000..19ce178 --- /dev/null +++ b/app/views/stopwatch_issue_timers/stop.js.erb @@ -0,0 +1,5 @@ +window.stopwatch.updateStartStopLink( + '#stopwatch_stop_timer_<%= @issue.id %>', + '<%= j Stopwatch::IssueLinks.new(@issue).start_timer %>' +); +window.stopwatch.timerStopped(); diff --git a/app/views/stopwatch_timers/_entry_form.html.erb b/app/views/stopwatch_timers/_entry_form.html.erb index 3fed802..7c931d7 100644 --- a/app/views/stopwatch_timers/_entry_form.html.erb +++ b/app/views/stopwatch_timers/_entry_form.html.erb @@ -4,8 +4,12 @@ <%= t @time_entry.new_record? ? '.legend_new' : '.legend_edit' %>

<%= f.select :project_id, project_tree_options_for_select(Project.allowed_to(:log_time).to_a, :selected => @time_entry.project, :include_blank => true), { no_label: true }, id: 'stopwatch_time_entry_project_id' %>

-

- <%= f.text_field :issue_id, :size => 6, :required => Setting.timelog_required_fields.include?('issue_id') %> +

+ <%= f.text_field :issue_id, :size => 6, no_label: true, id: 'stopwatch_time_entry_issue_id' %> <%= link_to_issue(@time_entry.issue) if @time_entry.issue.try(:visible?) %> @@ -24,10 +28,10 @@ <%= javascript_tag do %> $(document).ready(function(){ - $('#time_entry_project_id').change(function(){ - $('#time_entry_issue_id').val(''); + $('#stopwatch_time_entry_project_id').change(function(){ + $('#stopwatch_time_entry_issue_id').val(''); }); - $('#time_entry_project_id, #time_entry_issue_id').change(function(){ + $('#stopwatch_time_entry_project_id, #stopwatch_time_entry_issue_id').change(function(){ $.ajax({ url: '<%= j update_form_stopwatch_timers_path(time_entry_id: @time_entry.id, format: 'js') %>', type: 'post', @@ -36,7 +40,7 @@ }); }); - observeAutocompleteField('time_entry_issue_id', + observeAutocompleteField('stopwatch_time_entry_issue_id', function(request, callback) { var url = '<%= j auto_complete_issues_path %>'; var data = { @@ -46,7 +50,7 @@ <% if @time_entry.new_record? && @project %> project_id = '<%= @project.id %>'; <% else %> - project_id = $('#time_entry_project_id').val(); + project_id = $('#stopwatch_time_entry_project_id').val(); <% end %> if(project_id){ data['project_id'] = project_id; @@ -64,8 +68,8 @@ }, { select: function(event, ui) { - $('#time_entry_issue').text(''); - $('#time_entry_issue_id').val(ui.item.value).change(); + $('#stopwatch_time_entry_issue').text(''); + $('#stopwatch_time_entry_issue_id').val(ui.item.value).change(); } } ); diff --git a/app/views/stopwatch_timers/_new.html.erb b/app/views/stopwatch_timers/_new.html.erb index 7fc3853..3e897f6 100644 --- a/app/views/stopwatch_timers/_new.html.erb +++ b/app/views/stopwatch_timers/_new.html.erb @@ -1,10 +1,12 @@

<%= l(:button_log_time) %> - <%= format_date User.current.today %>

-<%= render partial: 'entries_list', locals: { entries: @entries } %> +<% if @entries %> + <%= render partial: 'stopwatch_timers/entries_list', locals: { entries: @entries } %> +<% end %> <%= labelled_form_for @time_entry, url: stopwatch_timers_path, method: :post, remote: true do |f| %> <% @time_entry.hours ||= 0 %> - <%= render partial: 'entry_form', locals: { f: f } %> + <%= render partial: 'stopwatch_timers/entry_form', locals: { f: f } %>

<%= submit_tag l(:button_create) %> diff --git a/app/views/stopwatch_timers/create.js.erb b/app/views/stopwatch_timers/create.js.erb index d494fc6..0634da9 100644 --- a/app/views/stopwatch_timers/create.js.erb +++ b/app/views/stopwatch_timers/create.js.erb @@ -1,4 +1,5 @@ hideModal(); -window.stopwatch.highlightRunningTimer( +window.stopwatch.timerStarted( <%= raw Stopwatch::Timer.new(User.current).to_json %> ); + diff --git a/app/views/stopwatch_timers/start.js.erb b/app/views/stopwatch_timers/start.js.erb index 8014894..1933b95 100644 --- a/app/views/stopwatch_timers/start.js.erb +++ b/app/views/stopwatch_timers/start.js.erb @@ -7,9 +7,10 @@ <% end %> <% if @started_time_entry %> - window.stopwatch.timerStarted('<%= @started_time_entry.id %>', - '<%= j format_hours @started_time_entry.hours %>'); + window.stopwatch.timerStarted( + <%= raw Stopwatch::Timer.new(User.current).to_json %> + ); <% else %> - window.stopwatch.timerStopped(); + window.stopwatch.timerStopped(); <% end %> diff --git a/assets/javascripts/stopwatch.js b/assets/javascripts/stopwatch.js index b0304fd..db4c6ed 100644 --- a/assets/javascripts/stopwatch.js +++ b/assets/javascripts/stopwatch.js @@ -1,6 +1,7 @@ window.initStopwatch = function(config){ var currentTimerUrl = config.currentTimerUrl; var hourFormat = config.hourFormat; + var locales = config.locales; var hoursRe = hourFormat.replace(/0+/g, '\\d+').replace(/\./g, '\\.'); var titleRegexp = new RegExp('^(' + hoursRe + ' - )?(.*)$'); @@ -68,12 +69,43 @@ window.initStopwatch = function(config){ highlightRunningTimer: highlightRunningTimer, timerStopped: function(){ highlightRunningTimer(); + // fix up any issue timer start/stop links in the UI + // no running timer -> all links will start a timer + $('a.stopwatch_issue_timer').each(function(){ + var a = $(this); + a.attr('href', a.attr('href').replace(/stop$/, 'start')); + a.text(locales.startTimer); + }); + }, + timerStarted: function(data){ + highlightRunningTimer(data); + // { + // running: true, + // time_entry_id: entryId, + // time_spent: spentTime + // }); + // fix up any issue timer start/stop links in the UI + // all links will start a timer, except the one for the current issue, + // which has to be turned into a stop link. + if(data.running) { + $('a.stopwatch_issue_timer').each(function(){ + var a = $(this); + var href = a.attr('href'); + if(data.issue_id) { + if(a.data('issueId') == data.issue_id) { + a.attr('href', href.replace(/start$/, 'stop')); + a.text(locales.stopTimer); + } else { + a.attr('href', href.replace(/stop$/, 'start')); + a.text(locales.startTimer); + } + } + }); + } }, - timerStarted: function(entryId, spentTime){ - highlightRunningTimer({ - running: true, - time_entry_id: entryId, - time_spent: spentTime + updateStartStopLink: function(id, replacement){ + $(id).replaceWith(function(){ + return $(replacement, { html: $(this).html() }); }); }, setProjectId: function(projectId){ diff --git a/config/locales/en.yml b/config/locales/en.yml index 773d28a..9ffca4d 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1,6 +1,11 @@ en: label_stopwatch_start: Start tracking label_stopwatch_stop: Stop tracking + stopwatch: + settings: + label_always_ask: 'Always ask' + label_default_activity: 'Default activity for "Start tracking"' + label_system: 'Use system default' stopwatch_timers: entries_list: button_stop: Stop diff --git a/config/routes.rb b/config/routes.rb index 5aad544..d6f8566 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -9,3 +9,8 @@ end end +scope 'issues/:issue_id' do + post 'timer/start', to: 'stopwatch_issue_timers#start', as: :start_issue_timer + post 'timer/stop', to: 'stopwatch_issue_timers#stop', as: :stop_issue_timer +end + diff --git a/init.rb b/init.rb index c634f6e..c592d68 100644 --- a/init.rb +++ b/init.rb @@ -6,7 +6,7 @@ author 'Jens Krämer' author_url 'https://jkraemer.net/' description "Start/stop timer and quick access to today's time bookings for Redmine" - version '0.1.0' + version '0.2.0' requires_redmine version_or_higher: '3.4.0' settings default: { @@ -24,5 +24,6 @@ Rails.configuration.to_prepare do Stopwatch::UserPatch.apply Stopwatch::TimeEntryPatch.apply + Stopwatch::IssuesControllerPatch.apply end diff --git a/lib/stopwatch/issue_links.rb b/lib/stopwatch/issue_links.rb new file mode 100644 index 0000000..4d6a834 --- /dev/null +++ b/lib/stopwatch/issue_links.rb @@ -0,0 +1,29 @@ +module Stopwatch + class IssueLinks < Struct.new(:issue) + include ActionView::Helpers::UrlHelper + include Rails.application.routes.url_helpers + + + def start_timer + link_to(I18n.t(:label_stopwatch_start), + start_issue_timer_path(issue), + class: 'icon icon-time stopwatch_issue_timer', + data: { issue_id: issue.id }, + remote: true, + method: 'post') + end + + def stop_timer + link_to(I18n.t(:label_stopwatch_stop), + stop_issue_timer_path(issue), + class: 'icon icon-time stopwatch_issue_timer', + data: { issue_id: issue.id }, + remote: true, + method: 'post') + + end + + # to make route helpers happy + def controller; nil end + end +end diff --git a/lib/stopwatch/issue_timer.rb b/lib/stopwatch/issue_timer.rb new file mode 100644 index 0000000..53028e5 --- /dev/null +++ b/lib/stopwatch/issue_timer.rb @@ -0,0 +1,18 @@ +module Stopwatch + class IssueTimer + + def initialize(issue:, user: User.current) + @issue = issue + @user = user + end + + def running? + running_time_entry.present? + end + + def running_time_entry + @running_time_entry ||= @issue.time_entries.find_by_id(@user.running_time_entry_id) + end + + end +end diff --git a/lib/stopwatch/issues_controller_patch.rb b/lib/stopwatch/issues_controller_patch.rb new file mode 100644 index 0000000..1f404ff --- /dev/null +++ b/lib/stopwatch/issues_controller_patch.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Stopwatch + module IssuesControllerPatch + module Helper + def watcher_link(issue, user) + link = +'' + if User.current.allowed_to?(:log_time, issue.project) + t = Stopwatch::IssueTimer.new(issue: issue) + if t.running? + link << IssueLinks.new(issue).stop_timer + else + link << IssueLinks.new(issue).start_timer + end + end + link.html_safe + super + end + end + + def self.apply + IssuesController.class_eval do + helper Helper + end + end + end +end diff --git a/lib/stopwatch/start_timer.rb b/lib/stopwatch/start_timer.rb index 852f05f..ad9ad3e 100644 --- a/lib/stopwatch/start_timer.rb +++ b/lib/stopwatch/start_timer.rb @@ -1,7 +1,7 @@ module Stopwatch class StartTimer - Result = ImmutableStruct.new(:success?, :error) + Result = ImmutableStruct.new(:success?, :error, :started) def initialize(time_entry, user: User.current) @time_entry = time_entry @@ -13,7 +13,6 @@ def call return Result.new(error: :unauthorized) end - StopTimer.new(user: @user).call @time_entry.hours = 0 if @time_entry.hours.nil? # we want to start tracking time if this is an existing time entry, or a @@ -21,9 +20,13 @@ def call # new entries with hours > 0 are just saved as is. start_timer = !@time_entry.new_record? || @time_entry.hours == 0 + # stop currently running timer, but only when there is a chance for us + # to succeed creating the new one. + StopTimer.new(user: @user).call if @time_entry.valid? + if @time_entry.save start_new_timer if start_timer - return Result.new(success: true) + return Result.new(success: true, started: start_timer) else Rails.logger.error("could not save time entry: \n#{@time_entry.errors.inspect}") return Result.new(error: :invalid) diff --git a/lib/stopwatch/timer.rb b/lib/stopwatch/timer.rb index 3147956..ccc64f2 100644 --- a/lib/stopwatch/timer.rb +++ b/lib/stopwatch/timer.rb @@ -65,7 +65,8 @@ def to_json time_entry_id: time_entry_id, time_spent: formatter.format_hours, html_time_spent: formatter.html_hours, - running: running? + running: running?, + issue_id: time_entry&.issue_id }.to_json end diff --git a/lib/stopwatch/user_patch.rb b/lib/stopwatch/user_patch.rb index 63deb30..c7c9ff8 100644 --- a/lib/stopwatch/user_patch.rb +++ b/lib/stopwatch/user_patch.rb @@ -17,5 +17,10 @@ def running_time_entry_id timer = Stopwatch::Timer.new(self) timer.time_entry_id if timer.running? end + + def todays_time_entry_for(issue) + TimeEntry.order(created_on: :desc). + find_or_initialize_by(user: self, issue: issue, spent_on: today) + end end end diff --git a/test/integration/ticket_timer_test.rb b/test/integration/ticket_timer_test.rb new file mode 100644 index 0000000..9c18d24 --- /dev/null +++ b/test/integration/ticket_timer_test.rb @@ -0,0 +1,118 @@ +require File.expand_path('../../test_helper', __FILE__) + +class TicketTimerTest < Redmine::IntegrationTest + include ActiveJob::TestHelper + + fixtures :projects, + :users, :email_addresses, + :roles, + :members, + :member_roles, + :trackers, + :projects_trackers, + :enabled_modules, + :issue_statuses, + :issues, + :enumerations, + :custom_fields, + :custom_values, + :custom_fields_trackers, + :attachments + + setup do + @issue = Issue.find 1 + @user = User.find_by_login 'jsmith' + end + + test "should create / stop / resume timer for ticket" do + log_user 'jsmith', 'jsmith' + + assert_not_running + + get "/issues/1" + assert_select "div.contextual a", text: /start tracking/i + assert_no_difference ->{TimeEntry.count} do + post "/issues/1/timer/start", xhr: true + assert_response :success + end + assert_not_running + + assert_difference ->{TimeEntry.count} do + post "/stopwatch_timers", xhr: true, params: { + time_entry: { issue_id: 1, activity_id: 9 } + } + assert_response :success + end + + assert_running + + get "/issues/1" + assert_select "div.contextual a", text: /stop tracking/i + assert_no_difference ->{TimeEntry.count} do + post "/issues/1/timer/stop", xhr: true + end + + assert_not_running + + get "/issues/1" + assert_select "div.contextual a", text: /start tracking/i + assert_no_difference ->{TimeEntry.count} do + post "/issues/1/timer/start", xhr: true + end + assert_response :success + + assert_running + + get "/issues/1" + assert_select "div.contextual a", text: /stop tracking/i + assert_no_difference ->{TimeEntry.count} do + post "/issues/1/timer/stop", xhr: true + end + + assert_not_running + end + + test "should ask by default" do + log_user 'jsmith', 'jsmith' + TimeEntry.delete_all + assert_no_difference ->{TimeEntry.count} do + post "/issues/1/timer/start", xhr: true + assert_response 200 + end + end + + test "should use global default actvity" do + log_user 'jsmith', 'jsmith' + TimeEntry.delete_all + with_settings plugin_stopwatch: { 'default_activity' => 'system'} do + post "/issues/1/timer/start", xhr: true + assert_response 201 + end + assert te = TimeEntry.last + assert_equal 1, te.issue_id + assert_equal 10, te.activity_id + end + + test "should use configured default actvity" do + log_user 'jsmith', 'jsmith' + TimeEntry.delete_all + with_settings plugin_stopwatch: { 'default_activity' => '9'} do + post "/issues/1/timer/start", xhr: true + assert_response 201 + end + assert te = TimeEntry.last + assert_equal 1, te.issue_id + assert_equal 9, te.activity_id + end + + + private + + def assert_not_running + refute Stopwatch::Timer.new(User.find(@user.id)).running? + end + + def assert_running + assert Stopwatch::Timer.new(User.find(@user.id)).running? + end +end diff --git a/test/unit/user_test.rb b/test/unit/user_test.rb index db86daa..97174e8 100644 --- a/test/unit/user_test.rb +++ b/test/unit/user_test.rb @@ -1,7 +1,7 @@ require_relative '../test_helper' class UserTest < ActiveSupport::TestCase - fixtures :users, :user_preferences + fixtures :users, :user_preferences, :issues setup do @user = User.find 1 @@ -18,4 +18,13 @@ class UserTest < ActiveSupport::TestCase assert @user.timer_running? end + test "should build time entry for issue" do + i = Issue.find 1 + te = @user.todays_time_entry_for i + assert te.new_record? + assert_equal @user, te.user + assert_equal i, te.issue + assert_equal @user.today, te.spent_on + end + end From 78e47f5b7574c4f72cc583be409718ca736c9c52 Mon Sep 17 00:00:00 2001 From: Jens Kraemer Date: Tue, 5 Oct 2021 10:27:09 +0800 Subject: [PATCH 5/5] start/stop tracking: issue context menu entry --- app/views/stopwatch_issue_timers/start.js.erb | 1 + app/views/stopwatch_issue_timers/stop.js.erb | 1 + init.rb | 5 ++-- .../context_menus_controller_patch.rb | 29 +++++++++++++++++++ 4 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 lib/stopwatch/context_menus_controller_patch.rb diff --git a/app/views/stopwatch_issue_timers/start.js.erb b/app/views/stopwatch_issue_timers/start.js.erb index 58ab882..83a4531 100644 --- a/app/views/stopwatch_issue_timers/start.js.erb +++ b/app/views/stopwatch_issue_timers/start.js.erb @@ -1,4 +1,5 @@ <% if @started_time_entry %> + contextMenuHide(); window.stopwatch.timerStarted( <%= raw Stopwatch::Timer.new(User.current).to_json %> ); diff --git a/app/views/stopwatch_issue_timers/stop.js.erb b/app/views/stopwatch_issue_timers/stop.js.erb index 19ce178..0df980b 100644 --- a/app/views/stopwatch_issue_timers/stop.js.erb +++ b/app/views/stopwatch_issue_timers/stop.js.erb @@ -1,3 +1,4 @@ +contextMenuHide(); window.stopwatch.updateStartStopLink( '#stopwatch_stop_timer_<%= @issue.id %>', '<%= j Stopwatch::IssueLinks.new(@issue).start_timer %>' diff --git a/init.rb b/init.rb index c592d68..fe6b7d5 100644 --- a/init.rb +++ b/init.rb @@ -22,8 +22,9 @@ end Rails.configuration.to_prepare do - Stopwatch::UserPatch.apply - Stopwatch::TimeEntryPatch.apply + Stopwatch::ContextMenusControllerPatch.apply Stopwatch::IssuesControllerPatch.apply + Stopwatch::TimeEntryPatch.apply + Stopwatch::UserPatch.apply end diff --git a/lib/stopwatch/context_menus_controller_patch.rb b/lib/stopwatch/context_menus_controller_patch.rb new file mode 100644 index 0000000..575e6c7 --- /dev/null +++ b/lib/stopwatch/context_menus_controller_patch.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Stopwatch + module ContextMenusControllerPatch + module Helper + def watcher_link(objects, user) + link = +'' + if params[:action] == 'issues' and + objects.one? and (issue = objects[0]).is_a?(Issue) and + User.current.allowed_to?(:log_time, issue.project) + t = Stopwatch::IssueTimer.new(issue: issue) + if t.running? + link << IssueLinks.new(issue).stop_timer + else + link << IssueLinks.new(issue).start_timer + end + end + super + content_tag(:li, link.html_safe) + end + end + + def self.apply + ContextMenusController.class_eval do + helper Helper + end + end + end +end +