1 Commits

Author SHA1 Message Date
Jens Kraemer
d27bdd8414 screenshot 2020-07-23 17:03:10 +08:00
39 changed files with 66 additions and 666 deletions

View File

@@ -1,32 +0,0 @@
name: Test with Redmine
on:
push:
branches:
- master
pull_request:
branches:
- master
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
redmine:
- '5.0'
- '5.1'
ruby:
- '3.0'
- '3.1'
database:
- postgresql
- mysql
steps:
- uses: eXolnet/action-redmine-plugin@v1
with:
plugin_name: stopwatch
redmine_version: ${{ matrix.redmine }}
ruby_version: ${{ matrix.ruby }}
database: ${{ matrix.database }}

View File

@@ -1,4 +1,4 @@
# Stopwatch Plugin for Redmine [![Test with Redmine](https://github.com/jkraemer/stopwatch/actions/workflows/redmine.yml/badge.svg)](https://github.com/jkraemer/stopwatch/actions/workflows/redmine.yml) # Stopwatch Plugin for Redmine
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

@@ -1,12 +0,0 @@
# 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

@@ -1,48 +0,0 @@
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,8 +8,10 @@
# - 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 < StopwatchController class StopwatchTimersController < ApplicationController
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)
@@ -60,9 +62,7 @@ class StopwatchTimersController < StopwatchController
def stop def stop
r = Stopwatch::StopTimer.new.call r = Stopwatch::StopTimer.new.call
if r.success? unless 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,17 +73,6 @@ class StopwatchTimersController < StopwatchController
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
@@ -93,12 +82,11 @@ class StopwatchTimersController < StopwatchController
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).or( @entries = time_entries.where(spent_on: User.current.today).order(created_on: :asc)
time_entries.where(id: User.current.running_time_entry_id)
).order(created_on: :asc)
end end
def time_entries def time_entries

View File

@@ -1,4 +0,0 @@
<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,16 +1,11 @@
<%= 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

@@ -2,9 +2,9 @@
<% time_entry = @time_entries.first %> <% time_entry = @time_entries.first %>
<li> <li>
<% if User.current.is_running_timer? time_entry %> <% if User.current.is_running_timer? time_entry %>
<%= context_menu_link sprite_icon(:time, l(:label_stopwatch_stop)), stop_stopwatch_timer_path(time_entry, context: '1'), class: 'icon', remote: true, method: :put %> <%= context_menu_link l(:label_stopwatch_stop), stop_stopwatch_timer_path(time_entry, context: '1'), class: 'icon icon-time', remote: true, method: :put %>
<% else %> <% else %>
<%= context_menu_link sprite_icon(:time, l(:label_stopwatch_start)), start_stopwatch_timer_path(time_entry, context: '1'), class: 'icon', remote: true, method: :put %> <%= context_menu_link l(:label_stopwatch_start), start_stopwatch_timer_path(time_entry, context: '1'), class: 'icon icon-time', remote: true, method: :put %>
<% end %> <% end %>
</li> </li>
<% end %> <% end %>

View File

@@ -1,20 +0,0 @@
<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

@@ -1,9 +0,0 @@
<% 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

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

View File

@@ -17,11 +17,11 @@
<td class="hours"><%= html_hours(format_hours(entry.hours)) %></td> <td class="hours"><%= html_hours(format_hours(entry.hours)) %></td>
<td class="buttons"> <td class="buttons">
<% if running %> <% if running %>
<%= link_to sprite_icon(:time, t(:label_stopwatch_stop)), stop_stopwatch_timer_path(entry), remote: true, class: "icon-only", method: :put %> <%= link_to t(:label_stopwatch_stop), stop_stopwatch_timer_path(entry), remote: true, class: "icon-only icon-time", method: :put %>
<% else %> <% else %>
<%= link_to sprite_icon(:time, t(:label_stopwatch_start)), start_stopwatch_timer_path(entry), remote: true, class: "icon-only", method: :put %> <%= link_to t(:label_stopwatch_start), start_stopwatch_timer_path(entry), remote: true, class: "icon-only icon-time", method: :put %>
<% end %> <% end %>
<%= link_to sprite_icon(:edit, l(:button_edit)), edit_stopwatch_timer_path(entry), remote: true, class: "icon-only" if entry.editable_by? User.current %> <%= link_to l(:button_edit), edit_stopwatch_timer_path(entry), remote: true, class: "icon-only icon-edit" if entry.editable_by? User.current %>
</td> </td>
</tr> </tr>
<% end -%> <% end -%>

View File

@@ -2,19 +2,16 @@
<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><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><%= 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_issue_id"><%= l :field_issue %>
<% if Setting.timelog_required_fields.include?('issue_id') %> <p>
<span class="required"> *</span> <%= f.text_field :issue_id, :size => 6, :required => Setting.timelog_required_fields.include?('issue_id') %>
<% end %> <span id="time_entry_issue">
</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>
--> -->
@@ -28,19 +25,19 @@
<%= javascript_tag do %> <%= javascript_tag do %>
$(document).ready(function(){ $(document).ready(function(){
$('#stopwatch_time_entry_project_id').change(function(){ $('#time_entry_project_id').change(function(){
$('#stopwatch_time_entry_issue_id').val(''); $('#time_entry_issue_id').val('');
}); });
$('#stopwatch_time_entry_project_id, #stopwatch_time_entry_issue_id').change(function(){ $('#time_entry_project_id, #time_entry_issue_id').change(function(){
$.ajax({ $.ajax({
url: '<%= j update_form_stopwatch_timers_path(time_entry_id: @time_entry.id, format: 'js') %>', url: '<%= escape_javascript(@time_entry.new_record? ? new_time_entry_path(format: 'js') : edit_time_entry_path(@time_entry, format: 'js')) %>',
type: 'post', type: 'post',
data: $(this).closest('form').serialize() data: $(this).closest('form').serialize()
}); });
}); });
}); });
observeAutocompleteField('stopwatch_time_entry_issue_id', observeAutocompleteField('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 = {
@@ -50,7 +47,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 = $('#stopwatch_time_entry_project_id').val(); project_id = $('#time_entry_project_id').val();
<% end %> <% end %>
if(project_id){ if(project_id){
data['project_id'] = project_id; data['project_id'] = project_id;
@@ -68,8 +65,8 @@
}, },
{ {
select: function(event, ui) { select: function(event, ui) {
$('#stopwatch_time_entry_issue').text(''); $('#time_entry_issue').text('');
$('#stopwatch_time_entry_issue_id').val(ui.item.value).change(); $('#time_entry_issue_id').val(ui.item.value).change();
} }
} }
); );

View File

@@ -1,12 +1,10 @@
<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>
<% if @entries %> <%= render partial: 'entries_list', locals: { entries: @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: 'stopwatch_timers/entry_form', locals: { f: f } %> <%= render partial: 'entry_form', locals: { f: f } %>
<p class="buttons"> <p class="buttons">
<%= submit_tag l(:button_create) %> <%= submit_tag l(:button_create) %>

View File

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

View File

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

View File

@@ -1,5 +0,0 @@
$('#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,7 +1,6 @@
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 + ' - )?(.*)$');
@@ -69,48 +68,13 @@ 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 },
// no running timer -> all links will start a timer timerStarted: function(entryId, spentTime){
$('a.stopwatch_issue_timer').each(function(){ highlightRunningTimer({
var a = $(this); running: true,
a.attr('href', a.attr('href').replace(/stop$/, 'start')); time_entry_id: entryId,
a.find('span').text(locales.startTimer); 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.find('span').text(locales.stopTimer);
} else {
a.attr('href', href.replace(/stop$/, 'start'));
a.find('span').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,11 +1,6 @@
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,7 +1,6 @@
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
@@ -9,8 +8,3 @@ 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

BIN
img/screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

14
init.rb
View File

@@ -1,14 +1,13 @@
require 'stopwatch/hooks'
Redmine::Plugin.register :stopwatch do Redmine::Plugin.register :stopwatch do
name 'Redmine Stopwatch Plugin' name 'Redmine Stopwatch Plugin'
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 '1.1.0' version '0.1.0'
requires_redmine version_or_higher: '6.0.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,4 +17,7 @@ Redmine::Plugin.register :stopwatch do
if: ->(*_){ User.current.logged? and User.current.allowed_to?(:log_time, nil, global: true) } if: ->(*_){ User.current.logged? and User.current.allowed_to?(:log_time, nil, global: true) }
end end
Stopwatch.setup Rails.configuration.to_prepare do
Stopwatch::UserPatch.apply
end

View File

@@ -1,45 +1,2 @@
# frozen_string_literal: true
module Stopwatch module Stopwatch
def self.setup
Stopwatch::ContextMenusControllerPatch.apply
Stopwatch::IssuesControllerPatch.apply
::TimeEntry.prepend Stopwatch::TimeEntryPatch
::User.prepend Stopwatch::UserPatch
Stopwatch::Hooks # just load it
end
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

@@ -1,28 +0,0 @@
# 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, self).stop_timer
else
link << IssueLinks.new(issue, self).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

@@ -1,28 +0,0 @@
module Stopwatch
class IssueLinks < Struct.new(:issue, :context)
def start_timer
context.link_to(
context.sprite_icon(:time, I18n.t(:label_stopwatch_start)),
context.start_issue_timer_path(issue),
class: 'icon stopwatch_issue_timer',
data: { issue_id: issue.id },
remote: true,
method: 'post'
)
end
def stop_timer
context.link_to(
context.sprite_icon(:time, I18n.t(:label_stopwatch_stop)),
context.stop_issue_timer_path(issue),
class: 'icon 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

@@ -1,18 +0,0 @@
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

@@ -1,26 +0,0 @@
# 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, self).stop_timer
else
link << IssueLinks.new(issue, self).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, :started) Result = ImmutableStruct.new(:success?, :error)
def initialize(time_entry, user: User.current) def initialize(time_entry, user: User.current)
@time_entry = time_entry @time_entry = time_entry
@@ -13,6 +13,7 @@ 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
@@ -20,13 +21,9 @@ 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, started: start_timer) return Result.new(success: true)
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

@@ -1,14 +0,0 @@
module Stopwatch
module TimeEntryPatch
extend ActiveSupport::Concern
prepended do
before_destroy :stop_timer
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.read_attribute(:hours) + hours time_entry.update_column :hours, entry.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,8 +65,7 @@ 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

@@ -1,24 +1,16 @@
module Stopwatch module Stopwatch
module UserPatch module UserPatch
extend ActiveSupport::Concern def self.apply
User.prepend self unless User < self
end
def timer_running? def timer_running?
Stopwatch::Timer.new(self).running? Stopwatch::Timer.new(self).running?
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.time_entry_id if timer.running? timer.running? and timer.time_entry_id == time_entry.id
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

@@ -1,118 +0,0 @@
require File.expand_path('../test_helper', __dir__)
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_response :success
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 :ok
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 :created
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 :created
end
assert te = TimeEntry.last
assert_equal 1, te.issue_id
assert_equal 9, te.activity_id
end
private
def assert_not_running
assert_not 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

@@ -1,2 +1,5 @@
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

@@ -1,4 +1,4 @@
require File.expand_path('../../test_helper', __FILE__) require_relative '../test_helper'
class StartTimerTest < ActiveSupport::TestCase class StartTimerTest < ActiveSupport::TestCase
fixtures :users, :user_preferences, :time_entries, :projects, fixtures :users, :user_preferences, :time_entries, :projects,
@@ -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.except('id')) another = TimeEntry.new(@time_entry.attributes)
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

@@ -1,4 +1,4 @@
require File.expand_path('../../test_helper', __FILE__) require_relative '../test_helper'
class StopTimerTest < ActiveSupport::TestCase class StopTimerTest < ActiveSupport::TestCase
fixtures :users, :user_preferences, :time_entries, :projects, fixtures :users, :user_preferences, :time_entries, :projects,

View File

@@ -1,78 +0,0 @@
require File.expand_path('../../test_helper', __FILE__)
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

@@ -1,24 +0,0 @@
require File.expand_path('../../test_helper', __FILE__)
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,4 +1,4 @@
require File.expand_path('../../test_helper', __FILE__) require_relative '../test_helper'
class TimerTest < ActiveSupport::TestCase class TimerTest < ActiveSupport::TestCase
fixtures :users, :user_preferences, :time_entries fixtures :users, :user_preferences, :time_entries

View File

@@ -1,7 +1,7 @@
require File.expand_path('../../test_helper', __FILE__) require_relative '../test_helper'
class UserTest < ActiveSupport::TestCase class UserTest < ActiveSupport::TestCase
fixtures :users, :user_preferences, :issues fixtures :users, :user_preferences
setup do setup do
@user = User.find 1 @user = User.find 1
@@ -18,13 +18,4 @@ 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