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 @@ class StopwatchTimersController < StopwatchController
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 %>
+
+
+
+
+ <%= 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 @@
<%= 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(entryId, spentTime){
- highlightRunningTimer({
- running: true,
- time_entry_id: entryId,
- time_spent: spentTime
+ 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);
+ }
+ }
+ });
+ }
+ },
+ 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 @@ resources :stopwatch_timers, only: %i(new create edit update) do
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 @@ Redmine::Plugin.register :stopwatch do
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 @@ end
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 @@ module Stopwatch
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 @@ module Stopwatch
# 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 @@ module Stopwatch
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 @@ module Stopwatch
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