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 -![Redmine Workload Version](https://img.shields.io/badge/Redmine_Plugin-v4.0.0-red) ![Redmine Version](https://img.shields.io/badge/v6.0.z-blue) ![Language Support](https://img.shields.io/badge/Languages-en,_de,_fr,_es,_it-green) ![Version Stage](https://img.shields.io/badge/Stage-release-important) ![ci](https://github.com/xmera-circle/redmine_workload/actions/workflows/5-0-stable.yml/badge.svg) +![Redmine Workload Version](https://img.shields.io/badge/Redmine_Plugin-v4.0.0-red) ![Redmine Version](https://img.shields.io/badge/Redmine-v6.1.z-blue) ![Language Support](https://img.shields.io/badge/Languages-en,_de,_fr,_es,_it-green) ![Version Stage](https://img.shields.io/badge/Stage-release-important) ![ci](https://github.com/xmera-circle/redmine_workload/actions/workflows/6-1-stable.yml/badge.svg) 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