diff --git a/.github/workflows/5-0-stable.yml b/.github/workflows/6-1-stable.yml
similarity index 89%
rename from .github/workflows/5-0-stable.yml
rename to .github/workflows/6-1-stable.yml
index ae1cfc0..6a383f8 100644
--- a/.github/workflows/5-0-stable.yml
+++ b/.github/workflows/6-1-stable.yml
@@ -1,5 +1,8 @@
# This configuration is taken from Redmine DMSF plugin
# and adapted to the needs of Redmine Workload plugin.
+#
+# Targets Redmine 6.1-stable (Rails 7.2). Plugin versions 4.x require
+# Redmine 6; for Redmine 5 use the 3.x line.
#
# Copyright © 2023 Liane Hampe
# Copyright © 2022-23 Karel Pičman
@@ -68,16 +71,16 @@ jobs:
run: sudo apt-get update && sudo apt-get install -y subversion
- name: Clone Redmine
# Get the latest stable Redmine
- run: svn export http://svn.redmine.org/redmine/branches/5.0-stable/ redmine
+ run: svn export http://svn.redmine.org/redmine/branches/6.1-stable/ redmine
- name: Checkout plugin
- uses: actions/checkout@v3
+ uses: actions/checkout@v4
with:
path: redmine/plugins/${{ env.NAME }}
- name: Install Ruby and gems
uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- ruby-version: '3.1'
+ ruby-version: '3.2'
- name: Setup database
# Create the database
run: |
@@ -100,6 +103,7 @@ jobs:
bundle exec rake db:migrate
bundle exec rake redmine:plugins:migrate
bundle exec rake redmine:load_default_data
+ bundle exec rake assets:precompile
env:
REDMINE_LANG: en
working-directory: redmine
@@ -110,6 +114,14 @@ jobs:
bundle exec rake redmine:plugins:test:units
bundle exec rake redmine:plugins:test:functionals
bundle exec rake redmine:plugins:test:integration
+ - name: Archive test.log
+ # Keep the log so failing runs can be diagnosed
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: "test_${{matrix.engine}}.log"
+ path: redmine/log/test.log
+ if-no-files-found: ignore
- name: Cleanup
# Rollback plugin's changes to the database
# Stop the database engine
diff --git a/.rubocop.yml b/.rubocop.yml
index 9e9a678..abafd7c 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -4,7 +4,7 @@ AllCops:
NewCops: enable
DisplayCopNames: true
DisplayStyleGuide: true
- TargetRubyVersion: 2.7
+ TargetRubyVersion: 3.2
Exclude:
- '**/vendor/**/*'
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9094923..c49541d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,12 +9,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
-* Updated for Redmine 6.0.x / Rails 7.2 compatibility
+* Updated for Redmine 6.1.x / Rails 7.2 compatibility
* Fixed Rails version comparisons to use Gem::Version
* Updated database adapter detection for Rails 7.2
* Updated ActiveRecord migrations to version 7.2
* Removed deprecated `unloadable` from controllers and models
* Fixed Ruby version comparison for PostgreSQL requirement
+* Declared `requires_redmine version_or_higher: '6.1'`
+* CI now runs against Redmine 6.1-stable on Ruby 3.2 instead of 5.0-stable
+
+### Fixed
+
+* HTTP 500 when an invalid date such as `2026-01-32` was entered in a workload
+ filter; invalid input now falls back to the default value (#41)
+* HTTP 500 when the 'Use as today' date was set beyond the last day of the
+ displayed time span; the date is capped and a flash warning is shown (#40)
+* `test/test_helper.rb` no longer uses `Rails.root` at load time. Rails 7.2 runs
+ plugin tests in a separate process that requires the test files before the
+ environment is loaded, where `Rails.root` does not exist
+* `WlUserSelectionTest` accounts for Redmine 6's `fixtures :all`, which makes
+ `users(:users_008)` a member of two groups
+
+### Removed
+
+* Dead `Rails.version < '6'` branches in `init.rb` and `user_patch.rb`
+* Ruby version guard for PostgreSQL and `RedmineWorkload.postgresql?`,
+ unreachable since Redmine 6.1 requires Ruby >= 3.2
## 3.0.2 - 2023-07-24
diff --git a/README.md b/README.md
index 99c64be..016ccd1 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# Workload Plugin for Redmine
-    
+    
A complete rewrite of the original workload-plugin from Rafael Calleja.
The plugin calculates how much work each user would have to do per day in order to hit the deadlines for all his issues.
@@ -33,7 +33,19 @@ The encoding options are the same as on the page itself and might depend on your
Workday settings are fixed now (see [#27](https://github.com/xmera-circle/redmine_workload/issues/27)) but lead to restrictions for PostgreSQL user.
- :warning: **With PostgreSQL installed you need to run Ruby 3.1.z!**
+ :warning: **PostgreSQL requires Ruby 3.1 or newer.** Redmine 6.1 requires Ruby 3.2
+or newer anyway, so this is no longer a separate constraint for the 4.x line.
+
+## New Features in Version 4.0.0
+
+### support of Redmine 6
+
+Version 4.0.0 supports Redmine 6.1.z and is **not** backward compatible: the
+database migrations are on `ActiveRecord::Migration[7.2]`, which Redmine 5
+(Rails 6.1) cannot load. Installations on Redmine 5 stay on the 3.x line.
+
+Redmine 6.0.z ships the same Rails 7.2 and is likely to work, but it is not
+covered by the test workflow and therefore not claimed as supported.
## New Features in Version 2.2.0
diff --git a/app/controllers/workloads_controller.rb b/app/controllers/workloads_controller.rb
index d6ff120..4d83a10 100644
--- a/app/controllers/workloads_controller.rb
+++ b/app/controllers/workloads_controller.rb
@@ -30,6 +30,12 @@ def index
# Make sure that last_day is at most 12 months after first_day to prevent
# long running times
@last_day = [(@first_day >> 12) - 1, @last_day].min
+
+ # Make sure that today is not after last_day to prevent a crash in the
+ # workload calculation (today would be outside the time span)
+ @today_capped = @today > @last_day
+ @today = [@today, @last_day].min
+
@time_span_to_display = @first_day..@last_day
if @date_check
@@ -54,6 +60,7 @@ def index
respond_to do |format|
format.html do
flash.now[:error] = l(:error_date_setting) unless @date_check
+ flash.now[:warning] = l(:warning_today_capped_to_last_day) if @today_capped
render action: :index
end
@@ -108,10 +115,10 @@ def assignee_ids
end
def sanitizeDateParameter(parameter, default)
- if parameter.respond_to?(:to_date)
- parameter.to_date
- else
- default
- end
+ return default unless parameter.respond_to?(:to_date)
+
+ parameter.to_date
+ rescue Date::Error
+ default
end
end
diff --git a/app/views/settings/_workload_settings.erb b/app/views/settings/_workload_settings.erb
index 6b1d329..950c060 100644
--- a/app/views/settings/_workload_settings.erb
+++ b/app/views/settings/_workload_settings.erb
@@ -140,6 +140,6 @@
<%= "checked" if settings['workload_of_parent_issues'] != '' %>
>
<%= l(:info_include_parent_tasks) %>
- <%= l(:warning_include_parent_tasks) %>
+ <%= sprite_icon('warning', l(:warning_include_parent_tasks)) %>
\ No newline at end of file
diff --git a/app/views/wl_national_holiday/index.html.erb b/app/views/wl_national_holiday/index.html.erb
index d98d61d..e6ef126 100644
--- a/app/views/wl_national_holiday/index.html.erb
+++ b/app/views/wl_national_holiday/index.html.erb
@@ -5,7 +5,7 @@
<%= l(:workload_holiday_title)%>
-<%= link_to l(:label_new), {controller: "wl_national_holiday", action: "new"}, class: "icon icon-add" if @is_allowed%>
+<%= link_to sprite_icon('add', l(:label_new)), {controller: "wl_national_holiday", action: "new"}, class: "icon icon-add" if @is_allowed%>
<%= link_to "<<", :controller => 'wl_national_holiday', :action => "index", :year => @this_year-1 %>
diff --git a/app/views/wl_user_vacations/index.html.erb b/app/views/wl_user_vacations/index.html.erb
index e259f5c..d2d1533 100644
--- a/app/views/wl_user_vacations/index.html.erb
+++ b/app/views/wl_user_vacations/index.html.erb
@@ -4,7 +4,7 @@
<%= l(:workload_user_vacation_site_title) %> » <%= User.current.name %>
-<%= link_to l(:label_new), new_wl_user_vacation_path, :class => 'icon icon-add' if @is_allowed %>
+<%= link_to sprite_icon('add', l(:label_new)), new_wl_user_vacation_path, :class => 'icon icon-add' if @is_allowed %>
<% unless @wl_user_vacations.empty?%>
<%= render(partial: "show_list", locals: {wl_user_vacations: @wl_user_vacations, is_allowed: @is_allowed}) %>
diff --git a/app/views/workloads/_filters.erb b/app/views/workloads/_filters.erb
index 4cd1e81..fba6033 100644
--- a/app/views/workloads/_filters.erb
+++ b/app/views/workloads/_filters.erb
@@ -38,6 +38,6 @@
<%= label_tag :workload_groups, l(:workload_show_filter_group) %>
<%= select_tag :workload_groups, group_options_for_select(@groups&.allowed_to_display, @groups&.selected), :name => 'workload[groups][]', :multiple => true, :onchange => "this.form.workload_users.selectedIndex=-1;" %>
- <%= link_to_function l(:button_apply), 'jQuery("#filter_form").submit()', :class => 'apply icon icon-checked' %>
+ <%= link_to_function sprite_icon('checked', l(:button_apply)), 'jQuery("#filter_form").submit()', :class => 'apply icon icon-checked' %>
<% end %>
diff --git a/app/views/workloads/_trigger.erb b/app/views/workloads/_trigger.erb
index 8916689..4ab231c 100644
--- a/app/views/workloads/_trigger.erb
+++ b/app/views/workloads/_trigger.erb
@@ -3,7 +3,9 @@
# Parameters:
# trigger_for: set as "data-for"-attribute
#
-# ▶ is a right-pointing filled triangle.
+# The icon is rendered once as 'angle-right'. The opened state is expressed by
+# the 'opened' css class, which rotates it by 90 degrees. Do not swap the icon
+# in JavaScript; the class is the single source of truth for the state.
%>
-▶
+<%= sprite_icon('angle-right') %>
diff --git a/assets/javascripts/slides.js b/assets/javascripts/slides.js
index 9fbe50e..c922ed9 100644
--- a/assets/javascripts/slides.js
+++ b/assets/javascripts/slides.js
@@ -6,8 +6,6 @@
$(document).ready(function() {
$('.trigger').click(function() {
- var OPENED = '▼'
- var CLOSED = '▶'
$(this).toggleClass('closed opened');
identifier = $(this).attr('data-for');
@@ -44,7 +42,6 @@ $(document).ready(function() {
$(this).show(); // but keep its 'children' closed if any
$(this).siblings('.invisible-issues-summary.' + identifierClasses).show();
});
- $(this).html(OPENED);
}
else {
lowerHierarchieLevelClasses = bottomUpHierarchieChain.get(currentHierarchieLevel);
@@ -54,11 +51,10 @@ $(document).ready(function() {
$(css).hide();
$(css).siblings('.invisible-issues-summary.' + identifierClasses).hide();
currentHierarchieLevel = $(css).find('span.trigger.opened');
- currentHierarchieLevel.html(CLOSED);
+ currentHierarchieLevel.removeClass('opened').addClass('closed');
currentHierarchieLevel.siblings('dl').hide();
})
$(this).siblings().hide();
- $(this).html(CLOSED);
}
});
});
diff --git a/assets/stylesheets/style.css b/assets/stylesheets/style.css
index fc8b40b..4b7231e 100644
--- a/assets/stylesheets/style.css
+++ b/assets/stylesheets/style.css
@@ -36,7 +36,7 @@
display: inline-block;
}
-legend {
+.controller-workloads legend {
color: var(--anthracite);
}
@@ -146,6 +146,15 @@ legend {
margin-right: 4px;
}
+.controller-workloads .data .trigger svg {
+ vertical-align: middle;
+ transition: transform 0.15s ease-in-out;
+}
+
+.controller-workloads .data .trigger.opened svg {
+ transform: rotate(90deg);
+}
+
.controller-workloads table dt.mt-5,
.controller-workloads table dd.mt-5 {
margin-top: 5px;
diff --git a/config/locales/de.yml b/config/locales/de.yml
index 0aee4b8..5a28ec0 100755
--- a/config/locales/de.yml
+++ b/config/locales/de.yml
@@ -100,6 +100,7 @@ de:
workload_unscheduled_issues_num: 'Anzahl ungeplanter Tickets:'
workload_unscheduled_issues_hours: 'Stunden aus ungeplanten Tickets:'
error_date_setting: 'Überprüfen Sie Ihre Eingabe! Das Enddatum (bis) liegt vor dem Startdatum (von).'
+ warning_today_capped_to_last_day: 'Das "Als Heute verwenden"-Datum lag nach dem Enddatum und wurde auf das Enddatum gesetzt.'
error_encoding_setting: 'Zeichenkodierung nicht erlaubt. Gültige Werte: %{value}.'
label_workload_calculation: Workloadberechnung
label_include_parent_tasks: Hauptaufgaben einbeziehen
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 670b832..b111ff9 100755
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -100,6 +100,7 @@ en:
workload_unscheduled_issues_num: 'Number of unscheduled issues:'
workload_unscheduled_issues_hours: 'Hours of unscheduled issues:'
error_date_setting: 'Please check your data! The end date is before the start date.'
+ warning_today_capped_to_last_day: 'The "Use as today" date was after the end date and has been set to the end date.'
error_encoding_setting: 'Character encoding not allowed. Valid values: %{value}.'
label_workload_calculation: Workload calculation
label_include_parent_tasks: Include parent tasks
diff --git a/init.rb b/init.rb
index 2d68e56..6958936 100755
--- a/init.rb
+++ b/init.rb
@@ -11,11 +11,7 @@
'all their assigned issus on time.'
version '4.0.0'
url 'https://github.com/xmera-circle/redmine_workload'
-
- if RedmineWorkload.postgresql? && Gem::Version.new(RUBY_VERSION) < Gem::Version.new('3.1.0')
- msg = "#{name} requires at least Ruby 3.1.0 when using postgresql database."
- raise Redmine::PluginRequirementError, msg
- end
+ requires_redmine version_or_higher: '6.1'
menu :top_menu,
:WorkLoad,
@@ -49,17 +45,28 @@
permission :edit_user_data, wl_user_datas: :update
end
-# Rails 6+ handles autoloading differently with Zeitwerk
-if Gem::Version.new(Rails.version) < Gem::Version.new('6.0')
- plugin = Redmine::Plugin.find(:redmine_workload)
- Rails.application.configure do
- config.autoload_paths << "#{plugin.directory}/app/presenters"
- end
-end
-
class RedmineToolbarHookListener < Redmine::Hook::ViewListener
- def view_layouts_base_html_head(_context)
+ # Controllers whose views need the plugin's assets.
+ WORKLOAD_CONTROLLERS = %w[
+ workloads
+ wl_user_datas
+ wl_user_vacations
+ wl_national_holiday
+ ].freeze
+
+ def view_layouts_base_html_head(context = {})
+ return '' unless workload_page?(context)
+
javascript_include_tag('slides', plugin: :redmine_workload) +
stylesheet_link_tag('style', plugin: :redmine_workload)
end
+
+ private
+
+ def workload_page?(context)
+ controller = context[:controller]
+ return false unless controller
+
+ WORKLOAD_CONTROLLERS.include?(controller.params[:controller].to_s)
+ end
end
diff --git a/lib/redmine_workload.rb b/lib/redmine_workload.rb
index f910766..9f8265d 100644
--- a/lib/redmine_workload.rb
+++ b/lib/redmine_workload.rb
@@ -11,17 +11,3 @@
require File.expand_path('redmine_workload/wl_issue_state', __dir__)
require File.expand_path('redmine_workload/wl_user_data_finder', __dir__)
require File.expand_path('redmine_workload/wl_user_data_defaults', __dir__)
-
-# Simple Rails related methods
-module RedmineWorkload
- # Check whether Redmine is running postgresql database
- def self.postgresql?
- db_config = if ActiveRecord::Base.configurations.respond_to?(:configs_for)
- ActiveRecord::Base.configurations.configs_for(env_name: Rails.env).first
- else
- ActiveRecord::Base.configurations[Rails.env]
- end
- adapter = db_config.respond_to?(:adapter) ? db_config.adapter : db_config['adapter']
- adapter == 'postgresql'
- end
-end
diff --git a/lib/redmine_workload/extensions/user_patch.rb b/lib/redmine_workload/extensions/user_patch.rb
index 03b84c6..568b240 100644
--- a/lib/redmine_workload/extensions/user_patch.rb
+++ b/lib/redmine_workload/extensions/user_patch.rb
@@ -27,12 +27,3 @@ def main_group_id
end
end
end
-
-# Rails 6+ uses after_plugins_loaded hook instead
-if Gem::Version.new(Rails.version) < Gem::Version.new('6.0')
- Rails.configuration.to_prepare do
- unless User.included_modules.include?(RedmineWorkload::Extensions::UserPatch)
- User.prepend RedmineWorkload::Extensions::UserPatch
- end
- end
-end
diff --git a/lib/redmine_workload/hooks/after_plugins_loaded_hook.rb b/lib/redmine_workload/hooks/after_plugins_loaded_hook.rb
index fca7dc3..a7172db 100644
--- a/lib/redmine_workload/hooks/after_plugins_loaded_hook.rb
+++ b/lib/redmine_workload/hooks/after_plugins_loaded_hook.rb
@@ -4,8 +4,6 @@ module RedmineWorkload
module Hooks
class AfterPluginsLoadedHook < Redmine::Hook::Listener
def after_plugins_loaded(_context = {})
- return unless Gem::Version.new(Rails.version) >= Gem::Version.new('6.0')
-
patch = RedmineWorkload::Extensions::UserPatch
klass = User
klass.prepend patch unless klass.included_modules.include?(patch)
diff --git a/test/functional/workloads_controller_test.rb b/test/functional/workloads_controller_test.rb
index ec894b0..c039a85 100644
--- a/test/functional/workloads_controller_test.rb
+++ b/test/functional/workloads_controller_test.rb
@@ -43,5 +43,58 @@ class WorkloadsControllerTest < ActionDispatch::IntegrationTest
assert flash[:error].match(/Character encoding not allowed./)
end
+
+ test 'should get index with invalid first_day date without raising an error' do
+ manager = roles :roles_001
+ manager.add_permission! :view_all_workloads
+ log_user('jsmith', 'jsmith')
+
+ get workloads_path(workload: { first_day: '2026-01-32' })
+ assert_response :success
+ end
+
+ test 'should get index with invalid last_day date without raising an error' do
+ manager = roles :roles_001
+ manager.add_permission! :view_all_workloads
+ log_user('jsmith', 'jsmith')
+
+ get workloads_path(workload: { last_day: '2026-13-01' })
+ assert_response :success
+ end
+
+ test 'should get index with invalid start_date without raising an error' do
+ manager = roles :roles_001
+ manager.add_permission! :view_all_workloads
+ log_user('jsmith', 'jsmith')
+
+ get workloads_path(workload: { start_date: 'not-a-date' })
+ assert_response :success
+ end
+
+ test 'should get index without error when start_date is after last_day' do
+ manager = roles :roles_001
+ manager.add_permission! :view_all_workloads
+ log_user('jsmith', 'jsmith')
+
+ get workloads_path(workload: {
+ first_day: '2026-01-01',
+ last_day: '2026-01-31',
+ start_date: '2026-06-01'
+ })
+ assert_response :success
+ end
+
+ test 'should show warning when start_date is after last_day' do
+ manager = roles :roles_001
+ manager.add_permission! :view_all_workloads
+ log_user('jsmith', 'jsmith')
+
+ get workloads_path(workload: {
+ first_day: '2026-01-01',
+ last_day: '2026-01-31',
+ start_date: '2026-06-01'
+ })
+ assert flash[:warning].present?
+ end
end
end
diff --git a/test/test_helper.rb b/test/test_helper.rb
index 3c00634..ea5ed43 100644
--- a/test/test_helper.rb
+++ b/test/test_helper.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
# Load the normal Rails helper
-require Rails.root.join('test/test_helper.rb')
+require File.expand_path('../../../test/test_helper', __dir__)
# Load other test helper modules
require File.expand_path('authenticate_user', __dir__)
require File.expand_path('workload_object_helper', __dir__)
diff --git a/test/unit/user_selection_test.rb b/test/unit/user_selection_test.rb
index 77411d0..cb40dc2 100644
--- a/test/unit/user_selection_test.rb
+++ b/test/unit/user_selection_test.rb
@@ -10,6 +10,15 @@ class WlUserSelectionTest < ActiveSupport::TestCase
:users, :issue_statuses, :enumerations, :roles
def setup
+ # Redmine >= 6.0 declares `fixtures :all` in its own test_helper, so every
+ # fixture file is loaded regardless of what a test class asks for. Among
+ # them is groups_users.yml, which makes users(:users_008) a member of two
+ # groups. Queries for "all users belonging to any group" therefore no
+ # longer return only the users generated below. Record what is already
+ # there before adding anything.
+ @fixture_group_member_ids =
+ Group.all.flat_map { |group| group.users.select(&:active?) }.map(&:id).uniq
+
@group1 = Group.generate!
@group2 = Group.generate!
@group3 = Group.generate!
@@ -26,11 +35,19 @@ def setup
@group_member_ids
end
+ ##
+ # All users a global workload query may return: the ones generated in setup
+ # plus the group members that come from Redmine's own fixtures.
+ #
+ def all_group_member_ids
+ (@fixture_group_member_ids + @group_member_ids.flatten).uniq.sort
+ end
+
test 'should return all users if the current user is admin' do
current_user = User.generate!(admin: true)
groups = WlGroupSelection.new(user: current_user, groups: [@group1.id, @group2.id, @group3.id])
users = WlUserSelection.new(user: current_user, group_selection: groups)
- assert_equal @group_member_ids.flatten.sort, users.allowed_to_display.map(&:id).sort
+ assert_equal all_group_member_ids, users.allowed_to_display.map(&:id).sort
end
test 'should return all active users when user has permission :view_all_workloads' do
@@ -40,7 +57,7 @@ def setup
groups = WlGroupSelection.new(user: current_user, groups: [@group1.id, @group2.id, @group3.id])
users = WlUserSelection.new(user: current_user, group_selection: groups)
- expected = @group_member_ids.flatten.sort
+ expected = all_group_member_ids
current = users.send(:all_users).map(&:id).sort
assert_equal expected, current
end