16 Commits

Author SHA1 Message Date
Jens Kraemer
78e47f5b75 start/stop tracking: issue context menu entry 2021-10-05 11:26:51 +08:00
Jens Kraemer
033e1739d2 'Start/Stop tracking' on issues#show 2021-10-05 11:26:51 +08:00
Jens Kraemer
bd6711a59f plugin config - default activity setting
- this can be set to make the new 'Start tracking for issue' function a
  one click operation
2021-10-05 11:26:51 +08:00
Jens Kraemer
d32845cc6e extract controller base class 2021-10-05 11:26:51 +08:00
Jens Kraemer
317aa08365 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
2021-10-05 11:26:51 +08:00
Jens Kraemer
b4af7e0eb3 adds project_id arg to 'log time' link to preselect the current project, if any. 2021-10-05 11:26:34 +08:00
Jens Kraemer
a2096e3208 remove running timer info when the time entry is removed 2021-10-05 10:44:37 +08:00
Jens Kraemer
53d5fbafd2 cleanup test helper 2021-08-31 14:27:50 +08:00
Jens Kraemer
6c51ef795a always show the running timer in the timers list #5 2021-08-31 14:24:24 +08:00
jan-imrich
9802307e5e fix rounding problem
Fixes the rounding problem. Since entry.hours is an getter with integrated round(2), its result should not be used for spent time calculation. Given the update function is called quite often (every 1-2 minutes), especially when the redmine issue is open in browser, it can disrupt the resulting overall logged time by up to 50%.
2020-12-19 08:44:15 +08:00
Jens Krämer
742157a1f1 Merge pull request #2 from vividtone/custom_fields_helper-is-missing
thanks!
2020-08-19 07:45:26 +08:00
MAEDA Go
4c9539c59a Clicking "Log time" causes NoMethodError 2020-08-04 23:14:37 +09:00
Jens Kraemer
c53f1d006c travis 2020-07-24 11:32:42 +08:00
Jens Kraemer
37d47f58cf fix travis(?) 2020-07-24 08:16:13 +08:00
Jens Kraemer
049cbd6c8f travis 2020-07-24 07:59:14 +08:00
Jens Kraemer
1ed6942f0c fix test 2020-07-24 07:59:12 +08:00
37 changed files with 744 additions and 47 deletions

33
.travis.yml Normal file
View File

@@ -0,0 +1,33 @@
sudo: false
language: ruby
rvm:
- 2.4.10
- 2.5.8
- 2.6.6
branches:
only:
- master
addons:
postgresql: "9.6"
env:
- REDMINE_VER=4.0-stable
- REDMINE_VER=4.1-stable
- REDMINE_VER=master
install: "echo skip bundle install"
before_script:
- psql -c 'create database travis_ci_test;' -U postgres
script:
- export TESTSPACE=`pwd`/testspace
- export NAME_OF_PLUGIN=stopwatch
- export PATH_TO_PLUGIN=`pwd`
- export PATH_TO_REDMINE=$TESTSPACE/redmine
- mkdir $TESTSPACE
- cp test/support/* $TESTSPACE/
- bash -x ./travis.sh

View File

@@ -1,4 +1,4 @@
# Stopwatch Plugin for Redmine # Stopwatch Plugin for Redmine [![Build Status](https://travis-ci.org/jkraemer/stopwatch.svg?branch=master)](https://travis-ci.org/jkraemer/stopwatch)
Minimal plugin that aims to make tracking your time with Redmine much easier. Minimal plugin that aims to make tracking your time with Redmine much easier.

View File

@@ -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

View File

@@ -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

View File

@@ -8,10 +8,8 @@
# - same for project, unless we are in a project context # - same for project, unless we are in a project context
# - focus first field that needs an action, depending on above # - focus first field that needs an action, depending on above
# #
class StopwatchTimersController < ApplicationController class StopwatchTimersController < StopwatchController
helper :timelog
before_action :require_login
before_action :find_optional_data, only: %i(new create) before_action :find_optional_data, only: %i(new create)
before_action :authorize_log_time, only: %i(new create start stop current) before_action :authorize_log_time, only: %i(new create start stop current)
before_action :find_time_entry, only: %i(edit update start stop) before_action :find_time_entry, only: %i(edit update start stop)
@@ -62,7 +60,9 @@ class StopwatchTimersController < ApplicationController
def stop def stop
r = Stopwatch::StopTimer.new.call r = Stopwatch::StopTimer.new.call
unless r.success? if r.success?
@stopped_time_entry = @time_entry
else
logger.error "unable to stop timer" logger.error "unable to stop timer"
end end
new unless params[:context] new unless params[:context]
@@ -73,6 +73,17 @@ class StopwatchTimersController < ApplicationController
render json: @timer.to_json render json: @timer.to_json
end 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 private
def find_timer def find_timer
@@ -82,11 +93,12 @@ class StopwatchTimersController < ApplicationController
def find_time_entry def find_time_entry
@time_entry = time_entries.find params[:id] @time_entry = time_entries.find params[:id]
end end
def load_todays_entries def load_todays_entries
@entries = time_entries.where(spent_on: User.current.today).order(created_on: :asc) @entries = time_entries.where(spent_on: User.current.today).or(
time_entries.where(id: User.current.running_time_entry_id)
).order(created_on: :asc)
end end
def time_entries def time_entries

View File

@@ -0,0 +1,4 @@
<p>
<label for="settings_default_activity"><%= t '.label_default_activity' %> </label>
<%= 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'] ) %>
</p>

View File

@@ -1,11 +1,16 @@
<%= javascript_tag do %> <%= javascript_tag do %>
window.stopwatch = window.initStopwatch({ window.stopwatch = window.initStopwatch({
currentTimerUrl: '<%= j current_stopwatch_timers_url %>', 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? %> <% if User.current.logged? %>
window.stopwatch.highlightRunningTimer( window.stopwatch.highlightRunningTimer(
<%= raw Stopwatch::Timer.new(User.current).to_json %> <%= raw Stopwatch::Timer.new(User.current).to_json %>
) );
window.stopwatch.setProjectId('<%= j @project&.id.to_s %>');
<% end %> <% end %>
<% end %> <% end %>

View File

@@ -0,0 +1,20 @@
<h3 class="title"><%= l(:button_log_time) %> - <%= format_date User.current.today %> - <%= "#{@issue.tracker} #{@issue.id}: #{truncate @issue.subject}" %></h3>
<%= 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 %>
<fieldset class="box tabular">
<legend><%= t 'stopwatch_timers.entry_form.legend_new' %></legend>
<p><%= f.select :activity_id, activity_collection_for_select_options(@time_entry), :required => true %></p>
<p><%= f.text_field :comments, :size => 100, :maxlength => 1024, :required => Setting.timelog_required_fields.include?('comments') %></p>
<% @time_entry.custom_field_values.each do |value| %>
<p><%= custom_field_tag_with_label :time_entry, value %></p>
<% end %>
</fieldset>
<p class="buttons">
<%= submit_tag l(:button_create) %>
</p>
<% end %>

View File

@@ -0,0 +1,9 @@
<% if @started_time_entry %>
contextMenuHide();
window.stopwatch.timerStarted(
<%= raw Stopwatch::Timer.new(User.current).to_json %>
);
<% else %>
$('#ajax-modal').html('<%= j render partial: 'new' %>');
showModal('ajax-modal', '700px');
<% end %>

View File

@@ -0,0 +1,6 @@
contextMenuHide();
window.stopwatch.updateStartStopLink(
'#stopwatch_stop_timer_<%= @issue.id %>',
'<%= j Stopwatch::IssueLinks.new(@issue).start_timer %>'
);
window.stopwatch.timerStopped();

View File

@@ -2,16 +2,19 @@
<fieldset class="box tabular"> <fieldset class="box tabular">
<legend><%= t @time_entry.new_record? ? '.legend_new' : '.legend_edit' %></legend> <legend><%= t @time_entry.new_record? ? '.legend_new' : '.legend_edit' %></legend>
<p><%= f.select :activity_id, activity_collection_for_select_options(@time_entry), :required => true %></p>
<p><%= 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 %></p> <p><label for="stopwatch_time_entry_project_id"><%= l :field_project %><span class="required"> *</span></label><%= 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' %></p>
<p><label for="stopwatch_time_entry_issue_id"><%= l :field_issue %>
<p> <% if Setting.timelog_required_fields.include?('issue_id') %>
<%= f.text_field :issue_id, :size => 6, :required => Setting.timelog_required_fields.include?('issue_id') %> <span class="required"> *</span>
<span id="time_entry_issue"> <% end %>
</label>
<%= f.text_field :issue_id, :size => 6, no_label: true, id: 'stopwatch_time_entry_issue_id' %>
<span id="stopwatch_time_entry_issue">
<%= link_to_issue(@time_entry.issue) if @time_entry.issue.try(:visible?) %> <%= link_to_issue(@time_entry.issue) if @time_entry.issue.try(:visible?) %>
</span> </span>
</p> </p>
<p><label for="stopwatch_time_entry_activity_id"><%= l :field_activity %><span class="required"> *</span></label><%= f.select :activity_id, activity_collection_for_select_options(@time_entry), { no_label: true }, id: 'stopwatch_time_entry_activity_id' %></p>
<!-- <!--
<p><%= f.date_field :spent_on, :size => 10, :required => true %><%= calendar_for('time_entry_spent_on') %></p> <p><%= f.date_field :spent_on, :size => 10, :required => true %><%= calendar_for('time_entry_spent_on') %></p>
--> -->
@@ -25,19 +28,19 @@
<%= javascript_tag do %> <%= javascript_tag do %>
$(document).ready(function(){ $(document).ready(function(){
$('#time_entry_project_id').change(function(){ $('#stopwatch_time_entry_project_id').change(function(){
$('#time_entry_issue_id').val(''); $('#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({ $.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', type: 'post',
data: $(this).closest('form').serialize() data: $(this).closest('form').serialize()
}); });
}); });
}); });
observeAutocompleteField('time_entry_issue_id', observeAutocompleteField('stopwatch_time_entry_issue_id',
function(request, callback) { function(request, callback) {
var url = '<%= j auto_complete_issues_path %>'; var url = '<%= j auto_complete_issues_path %>';
var data = { var data = {
@@ -47,7 +50,7 @@
<% if @time_entry.new_record? && @project %> <% if @time_entry.new_record? && @project %>
project_id = '<%= @project.id %>'; project_id = '<%= @project.id %>';
<% else %> <% else %>
project_id = $('#time_entry_project_id').val(); project_id = $('#stopwatch_time_entry_project_id').val();
<% end %> <% end %>
if(project_id){ if(project_id){
data['project_id'] = project_id; data['project_id'] = project_id;
@@ -65,8 +68,8 @@
}, },
{ {
select: function(event, ui) { select: function(event, ui) {
$('#time_entry_issue').text(''); $('#stopwatch_time_entry_issue').text('');
$('#time_entry_issue_id').val(ui.item.value).change(); $('#stopwatch_time_entry_issue_id').val(ui.item.value).change();
} }
} }
); );

View File

@@ -1,10 +1,12 @@
<h3 class="title"><%= l(:button_log_time) %> - <%= format_date User.current.today %></h3> <h3 class="title"><%= l(:button_log_time) %> - <%= format_date User.current.today %></h3>
<%= 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| %> <%= labelled_form_for @time_entry, url: stopwatch_timers_path, method: :post, remote: true do |f| %>
<% @time_entry.hours ||= 0 %> <% @time_entry.hours ||= 0 %>
<%= render partial: 'entry_form', locals: { f: f } %> <%= render partial: 'stopwatch_timers/entry_form', locals: { f: f } %>
<p class="buttons"> <p class="buttons">
<%= submit_tag l(:button_create) %> <%= submit_tag l(:button_create) %>

View File

@@ -1,4 +1,5 @@
hideModal(); hideModal();
window.stopwatch.highlightRunningTimer( window.stopwatch.timerStarted(
<%= raw Stopwatch::Timer.new(User.current).to_json %> <%= raw Stopwatch::Timer.new(User.current).to_json %>
); );

View File

@@ -7,9 +7,10 @@
<% end %> <% end %>
<% if @started_time_entry %> <% if @started_time_entry %>
window.stopwatch.timerStarted('<%= @started_time_entry.id %>', window.stopwatch.timerStarted(
'<%= j format_hours @started_time_entry.hours %>'); <%= raw Stopwatch::Timer.new(User.current).to_json %>
);
<% else %> <% else %>
window.stopwatch.timerStopped(); window.stopwatch.timerStopped();
<% end %> <% end %>

View File

@@ -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) %>');

View File

@@ -1,6 +1,7 @@
window.initStopwatch = function(config){ window.initStopwatch = function(config){
var currentTimerUrl = config.currentTimerUrl; var currentTimerUrl = config.currentTimerUrl;
var hourFormat = config.hourFormat; var hourFormat = config.hourFormat;
var locales = config.locales;
var hoursRe = hourFormat.replace(/0+/g, '\\d+').replace(/\./g, '\\.'); var hoursRe = hourFormat.replace(/0+/g, '\\d+').replace(/\./g, '\\.');
var titleRegexp = new RegExp('^(' + hoursRe + ' - )?(.*)$'); var titleRegexp = new RegExp('^(' + hoursRe + ' - )?(.*)$');
@@ -68,13 +69,48 @@ window.initStopwatch = function(config){
highlightRunningTimer: highlightRunningTimer, highlightRunningTimer: highlightRunningTimer,
timerStopped: function(){ timerStopped: function(){
highlightRunningTimer(); highlightRunningTimer();
}, // fix up any issue timer start/stop links in the UI
timerStarted: function(entryId, spentTime){ // no running timer -> all links will start a timer
highlightRunningTimer({ $('a.stopwatch_issue_timer').each(function(){
running: true, var a = $(this);
time_entry_id: entryId, a.attr('href', a.attr('href').replace(/stop$/, 'start'));
time_spent: spentTime 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);
}
}
});
}
},
updateStartStopLink: function(id, replacement){
$(id).replaceWith(function(){
return $(replacement, { html: $(this).html() });
});
},
setProjectId: function(projectId){
var a = $('a#stopwatch-menu');
a.attr('href', a.attr('href').replace(/\/new.*$/, '/new?project_id='+projectId));
} }
}); });
}; };

View File

@@ -1,6 +1,11 @@
en: en:
label_stopwatch_start: Start tracking label_stopwatch_start: Start tracking
label_stopwatch_stop: Stop 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: stopwatch_timers:
entries_list: entries_list:
button_stop: Stop button_stop: Stop

View File

@@ -1,6 +1,7 @@
resources :stopwatch_timers, only: %i(new create edit update) do resources :stopwatch_timers, only: %i(new create edit update) do
collection do collection do
get :current get :current
post :update_form
end end
member do member do
put :start put :start
@@ -8,3 +9,8 @@ resources :stopwatch_timers, only: %i(new create edit update) do
end end
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

View File

@@ -1,3 +1,4 @@
require_dependency 'stopwatch'
require 'stopwatch/hooks' require 'stopwatch/hooks'
Redmine::Plugin.register :stopwatch do Redmine::Plugin.register :stopwatch do
@@ -5,9 +6,12 @@ Redmine::Plugin.register :stopwatch do
author 'Jens Krämer' author 'Jens Krämer'
author_url 'https://jkraemer.net/' author_url 'https://jkraemer.net/'
description "Start/stop timer and quick access to today's time bookings for Redmine" 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' requires_redmine version_or_higher: '3.4.0'
settings default: {
'default_activity' => 'always_ask',
}, partial: 'stopwatch/settings'
menu :account_menu, :stopwatch, menu :account_menu, :stopwatch,
:new_stopwatch_timer_path, :new_stopwatch_timer_path,
@@ -18,6 +22,9 @@ Redmine::Plugin.register :stopwatch do
end end
Rails.configuration.to_prepare do Rails.configuration.to_prepare do
Stopwatch::ContextMenusControllerPatch.apply
Stopwatch::IssuesControllerPatch.apply
Stopwatch::TimeEntryPatch.apply
Stopwatch::UserPatch.apply Stopwatch::UserPatch.apply
end end

View File

@@ -1,2 +1,36 @@
# frozen_string_literal: true
module Stopwatch 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 end

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -1,7 +1,7 @@
module Stopwatch module Stopwatch
class StartTimer class StartTimer
Result = ImmutableStruct.new(:success?, :error) Result = ImmutableStruct.new(:success?, :error, :started)
def initialize(time_entry, user: User.current) def initialize(time_entry, user: User.current)
@time_entry = time_entry @time_entry = time_entry
@@ -13,7 +13,6 @@ module Stopwatch
return Result.new(error: :unauthorized) return Result.new(error: :unauthorized)
end end
StopTimer.new(user: @user).call
@time_entry.hours = 0 if @time_entry.hours.nil? @time_entry.hours = 0 if @time_entry.hours.nil?
# we want to start tracking time if this is an existing time entry, or a # 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. # new entries with hours > 0 are just saved as is.
start_timer = !@time_entry.new_record? || @time_entry.hours == 0 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 if @time_entry.save
start_new_timer if start_timer start_new_timer if start_timer
return Result.new(success: true) return Result.new(success: true, started: start_timer)
else else
Rails.logger.error("could not save time entry: \n#{@time_entry.errors.inspect}") Rails.logger.error("could not save time entry: \n#{@time_entry.errors.inspect}")
return Result.new(error: :invalid) return Result.new(error: :invalid)

View File

@@ -0,0 +1,18 @@
module Stopwatch
module TimeEntryPatch
def self.apply
TimeEntry.prepend self unless TimeEntry < self
end
def self.prepended(base)
base.class_eval do
before_destroy :stop_timer
end
end
def stop_timer
t = Stopwatch::Timer.new(user)
t.update(stop: true) if t.running?
end
end
end

View File

@@ -45,7 +45,7 @@ module Stopwatch
# timestamp to either nil (stop: true) or current time. # timestamp to either nil (stop: true) or current time.
def update(stop: false) def update(stop: false)
if hours = runtime_hours and entry = self.time_entry if hours = runtime_hours and entry = self.time_entry
time_entry.update_column :hours, entry.hours + hours time_entry.update_column :hours, entry.read_attribute(:hours) + hours
end end
data[:started_at] = stop || !running? ? nil : Time.now.to_i data[:started_at] = stop || !running? ? nil : Time.now.to_i
save save
@@ -65,7 +65,8 @@ module Stopwatch
time_entry_id: time_entry_id, time_entry_id: time_entry_id,
time_spent: formatter.format_hours, time_spent: formatter.format_hours,
html_time_spent: formatter.html_hours, html_time_spent: formatter.html_hours,
running: running? running: running?,
issue_id: time_entry&.issue_id
}.to_json }.to_json
end end

View File

@@ -9,8 +9,18 @@ module Stopwatch
end end
def is_running_timer?(time_entry) def is_running_timer?(time_entry)
id = running_time_entry_id
id.present? and time_entry.id == id
end
def running_time_entry_id
timer = Stopwatch::Timer.new(self) timer = Stopwatch::Timer.new(self)
timer.running? and timer.time_entry_id == time_entry.id 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 end
end end

View File

@@ -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

View File

@@ -0,0 +1,5 @@
# for travis debugging
# config.logger = Logger.new(STDOUT)
# config.logger.level = Logger::INFO
# config.log_level = :info

View File

@@ -0,0 +1,8 @@
test:
adapter: postgresql
encoding: unicode
pool: 5
database: travis_ci_test
user: postgres

View File

@@ -1,5 +1,2 @@
require File.expand_path(File.dirname(__FILE__) + '/../../../test/test_helper') require File.expand_path(File.dirname(__FILE__) + '/../../../test/test_helper')
class ActiveSupport::TestCase
end

View File

@@ -29,7 +29,7 @@ class StartTimerTest < ActiveSupport::TestCase
@time_entry.reload @time_entry.reload
assert_equal hours, @time_entry.hours assert_equal hours, @time_entry.hours
another = TimeEntry.new(@time_entry.attributes) another = TimeEntry.new(@time_entry.attributes.except('id'))
another.user = @user another.user = @user
r = Stopwatch::StartTimer.new(another, user: @user).call r = Stopwatch::StartTimer.new(another, user: @user).call
assert r.success? assert r.success?

View File

@@ -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

View File

@@ -0,0 +1,24 @@
require_relative '../test_helper'
class StartTimerTest < ActiveSupport::TestCase
fixtures :users, :user_preferences, :time_entries, :projects,
:roles, :member_roles, :members, :enumerations, :enabled_modules
setup do
@user = User.find 1
@time_entry = TimeEntry.where(user_id: 1).first
# so we dont have to load all the issue and related fixtures:
@time_entry.update_column :issue_id, nil
end
test "should stop timer before destroy" do
assert r = Stopwatch::StartTimer.new(@time_entry, user: @user).call
assert r.success?, r.inspect
assert User.find(@user.id).timer_running?
@time_entry.destroy
refute User.find(@user.id).timer_running?
end
end

View File

@@ -1,7 +1,7 @@
require_relative '../test_helper' require_relative '../test_helper'
class UserTest < ActiveSupport::TestCase class UserTest < ActiveSupport::TestCase
fixtures :users, :user_preferences fixtures :users, :user_preferences, :issues
setup do setup do
@user = User.find 1 @user = User.find 1
@@ -18,4 +18,13 @@ class UserTest < ActiveSupport::TestCase
assert @user.timer_running? assert @user.timer_running?
end 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 end

75
travis.sh Normal file
View File

@@ -0,0 +1,75 @@
#/bin/bash
set -e
if [[ ! "$TESTSPACE" = /* ]] ||
[[ ! "$PATH_TO_REDMINE" = /* ]] ||
[[ ! "$REDMINE_VER" = * ]] ||
[[ ! "$NAME_OF_PLUGIN" = * ]] ||
[[ ! "$PATH_TO_PLUGIN" = /* ]];
then
echo "You should set"\
" TESTSPACE, PATH_TO_REDMINE, REDMINE_VER"\
" NAME_OF_PLUGIN, PATH_TO_PLUGIN"\
" environment variables"
echo "You set:"\
"$TESTSPACE"\
"$PATH_TO_REDMINE"\
"$REDMINE_VER"\
"$NAME_OF_PLUGIN"\
"$PATH_TO_PLUGIN"
exit 1;
fi
export RAILS_ENV=test
export REDMINE_GIT_REPO=git://github.com/redmine/redmine.git
export REDMINE_GIT_TAG=$REDMINE_VER
export BUNDLE_GEMFILE=$PATH_TO_REDMINE/Gemfile
# checkout redmine
git clone $REDMINE_GIT_REPO $PATH_TO_REDMINE
cd $PATH_TO_REDMINE
if [ ! "$REDMINE_GIT_TAG" = "master" ];
then
git checkout -b $REDMINE_GIT_TAG origin/$REDMINE_GIT_TAG
fi
# create a link to the plugin
ln -sf $PATH_TO_PLUGIN plugins/$NAME_OF_PLUGIN
mv $TESTSPACE/database.yml.travis config/database.yml
mv $TESTSPACE/additional_environment.rb config/
cat << EOF > lib/tasks/00_nowarnings.rake
require 'rake/testtask'
module NoWarnings
def define(*_)
self.warning = false
super
end
end
Rake::TestTask.prepend NoWarnings
EOF
# install gems
bundle install
# run redmine database migrations
bundle exec rake db:migrate
# run plugin database migrations
bundle exec rake redmine:plugins:migrate
# install redmine database
#bundle exec rake redmine:load_default_data REDMINE_LANG=en
bundle exec rake db:structure:dump
# run tests
# bundle exec rake TEST=test/unit/role_test.rb
bundle exec rake redmine:plugins:test NAME=$NAME_OF_PLUGIN