From f2f2ecca4468d9ee336cfa75fb2284138c96d92a Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Fri, 6 Mar 2026 13:23:47 +0100 Subject: [PATCH] Refactor views for user administration to be correctly in the users controller, add feature specs --- .../non_working_times/sub_header_component.rb | 2 +- .../users/non_working_times_controller.rb | 9 +- .../users/working_hours_controller.rb | 16 +- app/controllers/users_controller.rb | 26 ++ app/views/users/_general.html.erb | 2 +- .../users/non_working_times/_list.html.erb | 11 +- config/routes.rb | 4 +- lib/open_project/ui/extensible_tabs.rb | 4 +- spec/features/my/working_times_spec.rb | 210 ++++++++++++++ spec/features/users/non_working_times_spec.rb | 226 +++++++++------ spec/features/users/working_hours_spec.rb | 269 ++++++++++++++++++ spec/support/pages/users/non_working_times.rb | 183 ++++++++++++ spec/support/pages/users/working_hours.rb | 171 +++++++++++ 13 files changed, 1002 insertions(+), 131 deletions(-) create mode 100644 spec/features/my/working_times_spec.rb create mode 100644 spec/features/users/working_hours_spec.rb create mode 100644 spec/support/pages/users/non_working_times.rb create mode 100644 spec/support/pages/users/working_hours.rb diff --git a/app/components/users/non_working_times/sub_header_component.rb b/app/components/users/non_working_times/sub_header_component.rb index 7dabcfc028b..1322aac1782 100644 --- a/app/components/users/non_working_times/sub_header_component.rb +++ b/app/components/users/non_working_times/sub_header_component.rb @@ -62,7 +62,7 @@ module Users private def path_for(year:) - url_for(controller: params[:controller], action: params[:action], user_id: params[:user_id], year:) + url_for(controller: params[:controller], action: params[:action], user_id: params[:user_id], year:, tab: params[:tab]) end end end diff --git a/app/controllers/users/non_working_times_controller.rb b/app/controllers/users/non_working_times_controller.rb index c29b715418c..65edcdcba9b 100644 --- a/app/controllers/users/non_working_times_controller.rb +++ b/app/controllers/users/non_working_times_controller.rb @@ -36,19 +36,12 @@ class Users::NonWorkingTimesController < ApplicationController before_action :check_working_times_feature_flag_is_active - authorization_checked! :index, :new, :create, :edit, :update, :destroy, :working_days_preview + authorization_checked! :new, :create, :edit, :update, :destroy, :working_days_preview before_action :find_user before_action :authorize_manage_working_times before_action :find_non_working_time, only: %i[edit update destroy] - def index - @year = (params[:year].presence || Date.current.year).to_i - @non_working_times = @user.non_working_time_entities_for_year(@year) - - render "users/edit" - end - def new @non_working_time = @user.non_working_times.build(prefilled_params) diff --git a/app/controllers/users/working_hours_controller.rb b/app/controllers/users/working_hours_controller.rb index 528e8e669e8..41ade9ea42f 100644 --- a/app/controllers/users/working_hours_controller.rb +++ b/app/controllers/users/working_hours_controller.rb @@ -36,7 +36,7 @@ class Users::WorkingHoursController < ApplicationController before_action :check_working_times_feature_flag_is_active - authorization_checked! :index, :new, :edit, :create, :update, :destroy + authorization_checked! :new, :edit, :create, :update, :destroy before_action :find_user before_action :authorize_manage_working_times @@ -45,20 +45,6 @@ class Users::WorkingHoursController < ApplicationController before_action :authorize_working_hours_edit, only: %i[edit update] before_action :authorize_working_hours_delete, only: %i[destroy] - def index - @current_working_hours = @user.working_hours.current - - @future_working_hours = @user.working_hours.upcoming(Date.current + 1) - - @past_working_hours = if @current_working_hours - @user.working_hours.history_for(@current_working_hours) - else - UserWorkingHours.none - end - - render "users/edit" - end - def new @user_working_hours = if current_context? duplicate_current_working_hours(@user) diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 4c694dbec9b..66c45903af7 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -30,6 +30,7 @@ class UsersController < ApplicationController include OpTurbo::ComponentStream + include WorkingTimesAuthorization layout "admin" @@ -88,6 +89,8 @@ class UsersController < ApplicationController @membership ||= Member.new @individual_principal = @user @contract = Users::UpdateContract.new(@user, current_user) + + prepare_views_for_tab end def create # rubocop:disable Metrics/AbcSize @@ -353,4 +356,27 @@ class UsersController < ApplicationController login: params[:user][:login] || params[:user][:mail], status: User.statuses[:invited]) end + + def prepare_views_for_tab # rubocop:disable Metrics/AbcSize + if params[:tab] == "non_working_times" + authorize_manage_working_times + check_working_times_feature_flag_is_active + + @year = (params[:year].presence || Date.current.year).to_i + @non_working_times = @user.non_working_time_entities_for_year(@year) + elsif params[:tab] == "working_hours" + authorize_manage_working_times + check_working_times_feature_flag_is_active + + @current_working_hours = @user.working_hours.current + + @future_working_hours = @user.working_hours.upcoming(Date.current + 1) + + @past_working_hours = if @current_working_hours + @user.working_hours.history_for(@current_working_hours) + else + UserWorkingHours.none + end + end + end end diff --git a/app/views/users/_general.html.erb b/app/views/users/_general.html.erb index 3d6f5faef87..d869adf9db5 100644 --- a/app/views/users/_general.html.erb +++ b/app/views/users/_general.html.erb @@ -52,7 +52,7 @@ See COPYRIGHT and LICENSE files for more details. "admin--users-password-auth-selected-value": @user.ldap_auth_source_id.blank? }, as: :user do |f| %> - <%= render partial: "form", locals: { f: f } %> + <%= render partial: "users/form", locals: { f: f } %> <%= styled_button_tag t(:button_save), class: "-primary -with-icon icon-checkmark" %> <% end %> diff --git a/app/views/users/non_working_times/_list.html.erb b/app/views/users/non_working_times/_list.html.erb index 105511930d1..c2d150aad23 100644 --- a/app/views/users/non_working_times/_list.html.erb +++ b/app/views/users/non_working_times/_list.html.erb @@ -27,13 +27,4 @@ See COPYRIGHT and LICENSE files for more details. ++#%> -<%= render(Users::NonWorkingTimes::SubHeaderComponent.new(year: @year, user: @user)) %> - -<%= render(Primer::OpenProject::FlexLayout.new(align_items: :flex_start, gap: :normal)) do |layout| %> - <% layout.with_column(flex: 1) do %> - <%= render(Users::NonWorkingTimes::CalendarComponent.new(non_working_times: @non_working_times, year: @year)) %> - <% end %> - <% layout.with_column do %> - <%= render(Users::NonWorkingTimes::SidebarComponent.new(non_working_times: @non_working_times, year: @year)) %> - <% end %> -<% end %> +<%= render(Users::NonWorkingTimes::YearOverviewComponent.new(year: @year, non_working_times: @non_working_times, user: @user)) %> diff --git a/config/routes.rb b/config/routes.rb index 69d3c86139d..2b6a1e3b58d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -920,8 +920,8 @@ Rails.application.routes.draw do resources :users, constraints: { id: /(\d+|me)/ }, except: :edit do resources :memberships, controller: "users/memberships", only: %i[update create destroy] - resources :working_hours, controller: "users/working_hours" - resources :non_working_times, controller: "users/non_working_times", only: %i[index new create edit update destroy] do + resources :working_hours, controller: "users/working_hours", except: [:index] + resources :non_working_times, controller: "users/non_working_times", except: [:index] do collection do get :working_days_preview end diff --git a/lib/open_project/ui/extensible_tabs.rb b/lib/open_project/ui/extensible_tabs.rb index 46461d239ea..7363a462453 100644 --- a/lib/open_project/ui/extensible_tabs.rb +++ b/lib/open_project/ui/extensible_tabs.rb @@ -69,14 +69,14 @@ module OpenProject { name: "working_hours", partial: "users/working_hours/list", - path: ->(params) { user_working_hours_path(params[:user]) }, + path: ->(params) { edit_user_path(params[:user], tab: :working_hours) }, label: :label_working_hours, only_if: ->(*) { OpenProject::FeatureDecisions.user_working_times_active? && User.current.allowed_globally?(:manage_working_times) } }, { name: "non_working_times", partial: "users/non_working_times/list", - path: ->(params) { user_non_working_times_path(params[:user]) }, + path: ->(params) { edit_user_path(params[:user], tab: :non_working_times) }, label: :label_non_working_days, only_if: ->(*) { OpenProject::FeatureDecisions.user_working_times_active? && User.current.allowed_globally?(:manage_working_times) } }, diff --git a/spec/features/my/working_times_spec.rb b/spec/features/my/working_times_spec.rb new file mode 100644 index 00000000000..db0202bd40a --- /dev/null +++ b/spec/features/my/working_times_spec.rb @@ -0,0 +1,210 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe "My working times pages", :js, with_flag: { user_working_times: true } do + describe "/my/non_working_times" do + let(:nwt_page) { Pages::Users::NonWorkingTimes.new(year: 2026) } + + context "with manage_own_working_times permission" do + current_user { create(:user, global_permissions: [:manage_own_working_times]) } + + it "renders the calendar for the current user" do + nwt_page.visit! + + nwt_page.expect_calendar_rendered + end + + it "makes the calendar selectable (new URL data attribute is present)" do + nwt_page.visit! + + nwt_page.expect_selectable_calendar + end + + it "shows the add button" do + nwt_page.visit! + + nwt_page.expect_add_button + end + + context "when clicking a calendar day" do + it "opens the create dialog pre-filled with that date" do + nwt_page.visit! + + nwt_page.click_calendar_day("2026-04-14") + + nwt_page.expect_dialog_open + nwt_page.expect_dialog_dates(start_date: "2026-04-14", end_date: "2026-04-14") + end + end + + context "when creating a non-working time" do + it "can create an entry for themselves" do + nwt_page.visit! + + nwt_page.open_create_dialog + + nwt_page.set_start_date(Date.new(2026, 7, 6)) + nwt_page.set_end_date(Date.new(2026, 7, 10)) + + nwt_page.confirm_dialog + + expect(current_user.non_working_times.count).to eq(1) + end + end + + context "when editing an existing entry" do + let!(:nwt) do + create(:user_non_working_time, user: current_user, + start_date: Date.new(2026, 8, 3), + end_date: Date.new(2026, 8, 7)) + end + + it "can edit via the sidebar link" do + nwt_page.visit! + + nwt_page.open_edit_dialog_from_sidebar + nwt_page.expect_dialog_start_date("2026-08-03") + end + end + end + + context "with manage_working_times permission" do + current_user { create(:user, global_permissions: [:manage_working_times]) } + + it "renders the calendar with the add button and selectable calendar" do + nwt_page.visit! + + nwt_page.expect_add_button + nwt_page.expect_selectable_calendar + end + end + + context "with no working times permissions" do + current_user { create(:user) } + + it "renders the page but without the add button or selectable calendar" do + nwt_page.visit! + + nwt_page.expect_calendar_rendered + nwt_page.expect_no_add_button + nwt_page.expect_non_selectable_calendar + end + end + end + + describe "/my/working_hours" do + let(:wh_page) { Pages::Users::WorkingHours.new } + + context "with manage_own_working_times permission" do + current_user { create(:user, global_permissions: [:manage_own_working_times]) } + + it "renders the current schedule section" do + wh_page.visit! + + wh_page.expect_current_schedule_section + wh_page.expect_future_section + wh_page.expect_history_section + end + + it "shows the pencil button to manage the current schedule" do + wh_page.visit! + + wh_page.expect_editable_current_schedule + end + + it "shows the add button for future schedules" do + wh_page.visit! + + wh_page.expect_add_future_button + end + + context "when creating a current schedule" do + it "opens the dialog without a valid_from field and creates the record" do + wh_page.visit! + + wh_page.open_current_schedule_dialog + wh_page.expect_dialog_title_current + wh_page.expect_no_valid_from_field + + wh_page.submit_dialog + + expect(current_user.working_hours.current).to be_present + end + end + + context "with an existing current schedule" do + let!(:working_hours) do + create(:user_working_hours, + user: current_user, + valid_from: Date.current, + monday: 480, tuesday: 480, wednesday: 480, thursday: 480, friday: 480, + saturday: 0, sunday: 0, + availability_factor: 100) + end + + it "shows the correct stats on the current schedule card" do + wh_page.visit! + + wh_page.expect_stats(work_days: 5, weekly_hours: "40h", availability: "100%") + end + + it "opens the edit dialog for the current schedule" do + wh_page.visit! + + wh_page.open_current_schedule_dialog + wh_page.expect_dialog_title_current + end + end + end + + context "with manage_working_times permission" do + current_user { create(:user, global_permissions: [:manage_working_times]) } + + it "renders the working hours page" do + wh_page.visit! + + wh_page.expect_current_schedule_section + end + end + + context "with no working times permissions" do + current_user { create(:user) } + + it "renders the page but without the edit pencil enabled" do + wh_page.visit! + + wh_page.expect_current_schedule_section + wh_page.expect_not_editable_current_schedule + end + end + end +end diff --git a/spec/features/users/non_working_times_spec.rb b/spec/features/users/non_working_times_spec.rb index a69350d7e89..1989d34bbad 100644 --- a/spec/features/users/non_working_times_spec.rb +++ b/spec/features/users/non_working_times_spec.rb @@ -31,80 +31,50 @@ require "spec_helper" RSpec.describe "User non-working times", :js, with_flag: { user_working_times: true } do - shared_let(:admin) { create(:admin) } + shared_let(:user) { create(:user, global_permissions: %i[manage_user view_all_principals manage_working_times]) } shared_let(:managed_user) { create(:user) } - let(:dialog_selector) { "##{Users::NonWorkingTimes::DialogComponent::DIALOG_ID}" } + let(:nwt_page) { Pages::Users::NonWorkingTimes.new(user: managed_user, year: 2026) } - def visit_non_working_times(for_user: managed_user, year: 2026) - visit user_non_working_times_path(for_user, year:) - end - - def open_create_dialog - click_on I18n.t(:button_add_non_working_time) - expect(page).to have_css(dialog_selector) - end - - def set_date_in_dialog(field_name, date) - datepicker = Components::BasicDatepicker.new(dialog_selector) - datepicker.open("input[name='non_working_time[#{field_name}]']") - datepicker.set_date(date) - end - - def submit_dialog - within(dialog_selector) { click_on I18n.t(:button_confirm) } - expect(page).to have_no_css(dialog_selector) - end - - def expect_sidebar_entry(text) - expect(page).to have_css("a[data-controller='async-dialog']", text:) - end - - def expect_no_sidebar_entry(text) - expect(page).to have_no_css("a[data-controller='async-dialog']", text:) - end - - current_user { admin } + current_user { user } describe "creating a non-working time" do - before { visit_non_working_times } + before { nwt_page.visit! } it "creates a single-day entry" do - open_create_dialog + nwt_page.open_create_dialog - set_date_in_dialog(:start_date, Date.new(2026, 3, 10)) - set_date_in_dialog(:end_date, Date.new(2026, 3, 10)) + nwt_page.set_start_date(Date.new(2026, 3, 10)) + nwt_page.set_end_date(Date.new(2026, 3, 10)) - submit_dialog + nwt_page.confirm_dialog - expect_sidebar_entry("Mar 10") + nwt_page.expect_sidebar_entry("Mar 10") expect(managed_user.non_working_times.count).to eq(1) end it "creates a multi-day range and shows correct working day count" do - open_create_dialog + nwt_page.open_create_dialog # Monday to Friday = 5 working days - set_date_in_dialog(:start_date, Date.new(2026, 3, 9)) - set_date_in_dialog(:end_date, Date.new(2026, 3, 13)) + nwt_page.set_start_date(Date.new(2026, 3, 9)) + nwt_page.set_end_date(Date.new(2026, 3, 13)) - submit_dialog + nwt_page.confirm_dialog - expect_sidebar_entry("5 working days") + nwt_page.expect_sidebar_entry("5 working days") end it "shows a validation error when end date is before start date" do - open_create_dialog + nwt_page.open_create_dialog - set_date_in_dialog(:start_date, Date.new(2026, 3, 13)) - set_date_in_dialog(:end_date, Date.new(2026, 3, 9)) + nwt_page.set_start_date(Date.new(2026, 3, 13)) + nwt_page.set_end_date(Date.new(2026, 3, 9)) - within(dialog_selector) { click_on I18n.t(:button_confirm) } + within(nwt_page.dialog_selector) { click_on I18n.t(:button_confirm) } - expect(page).to have_css(dialog_selector) - within(dialog_selector) do - expect(page).to have_text(I18n.t("activerecord.errors.models.user_non_working_time.attributes.end_date.not_before_start_date")) - end + nwt_page.expect_dialog_open + nwt_page.expect_validation_error(I18n.t("activerecord.errors.messages.not_before_start_date")) end end @@ -115,69 +85,144 @@ RSpec.describe "User non-working times", :js, with_flag: { user_working_times: t end_date: Date.new(2026, 3, 11)) end - before { visit_non_working_times } + before { nwt_page.visit! } it "opens the edit dialog when clicking a sidebar entry" do - find("a[data-controller='async-dialog']").click - expect(page).to have_css(dialog_selector) + nwt_page.open_edit_dialog_from_sidebar - within(dialog_selector) do - expect(page).to have_field("non_working_time[start_date]", with: "2026-03-09") - expect(page).to have_field("non_working_time[end_date]", with: "2026-03-11") - end + nwt_page.expect_dialog_dates(start_date: "2026-03-09", end_date: "2026-03-11") end it "saves updated dates" do - find("a[data-controller='async-dialog']").click - expect(page).to have_css(dialog_selector) + nwt_page.open_edit_dialog_from_sidebar - set_date_in_dialog(:end_date, Date.new(2026, 3, 13)) - submit_dialog + nwt_page.set_end_date(Date.new(2026, 3, 13)) + nwt_page.confirm_dialog expect(non_working_time.reload.end_date).to eq(Date.new(2026, 3, 13)) end end describe "deleting a non-working time" do - shared_let(:non_working_time) do + let!(:non_working_time) do create(:user_non_working_time, user: managed_user, start_date: Date.new(2026, 4, 1), end_date: Date.new(2026, 4, 3)) end - before { visit_non_working_times } + before { nwt_page.visit! } it "deletes the entry via the delete button in the edit dialog" do - find("a[data-controller='async-dialog']").click - expect(page).to have_css(dialog_selector) + nwt_page.open_edit_dialog_from_sidebar + nwt_page.delete_in_dialog - accept_confirm do - within(dialog_selector) { click_on I18n.t(:button_delete) } - end - - expect(page).to have_no_css(dialog_selector) expect(UserNonWorkingTime.exists?(non_working_time.id)).to be(false) end end + describe "calendar interaction" do + before { nwt_page.visit! } + + it "pre-fills start and end date when clicking a single calendar day" do + nwt_page.click_calendar_day("2026-03-10") + + nwt_page.expect_dialog_open + nwt_page.expect_dialog_dates(start_date: "2026-03-10", end_date: "2026-03-10") + end + + it "passes the new URL to the calendar so day selection is enabled" do + nwt_page.expect_selectable_calendar + end + end + + describe "calendar interaction - editing from the calendar event" do + shared_let(:non_working_time) do + create(:user_non_working_time, user: managed_user, + start_date: Date.new(2026, 3, 9), + end_date: Date.new(2026, 3, 11)) + end + + before { nwt_page.visit! } + + it "opens the edit dialog when clicking a calendar event" do + nwt_page.open_edit_dialog_from_calendar + + nwt_page.expect_dialog_dates(start_date: "2026-03-09", end_date: "2026-03-11") + end + end + + describe "working days count preview" do + before { nwt_page.visit! } + + it "updates the working days count in real time as dates change" do + nwt_page.open_create_dialog + + # Monday to Friday = 5 working days + nwt_page.set_start_date(Date.new(2026, 3, 9)) + nwt_page.set_end_date(Date.new(2026, 3, 13)) + + nwt_page.expect_working_days_count(5) + end + end + + describe "overlap validation" do + shared_let(:existing_nwt) do + create(:user_non_working_time, user: managed_user, + start_date: Date.new(2026, 3, 9), + end_date: Date.new(2026, 3, 15)) + end + + before { nwt_page.visit! } + + it "shows a validation error when the new range overlaps an existing entry" do + nwt_page.open_create_dialog + + nwt_page.set_start_date(Date.new(2026, 3, 12)) + nwt_page.set_end_date(Date.new(2026, 3, 20)) + + within(nwt_page.dialog_selector) { click_on I18n.t(:button_confirm) } + + nwt_page.expect_dialog_open + nwt_page.expect_validation_error(I18n.t("activerecord.errors.messages.overlapping_range")) + end + end + + describe "global non-working days exclusion" do + shared_let(:holiday) do + create(:non_working_day, date: Date.new(2026, 3, 11)) # Wednesday + end + + before { nwt_page.visit! } + + it "excludes system non-working days from the working day count preview" do + nwt_page.open_create_dialog + + # Mon Mar 9 to Fri Mar 13 would be 5 days, but Wed Mar 11 is a system holiday + nwt_page.set_start_date(Date.new(2026, 3, 9)) + nwt_page.set_end_date(Date.new(2026, 3, 13)) + + nwt_page.expect_working_days_count(4) + end + end + describe "access control" do context "with manage_own_working_times permission" do current_user { create(:user, global_permissions: [:manage_own_working_times]) } + let(:nwt_page) { Pages::Users::NonWorkingTimes.new(user: current_user, year: 2026) } - it "can view and manage their own non-working times" do - visit user_non_working_times_path(current_user, year: 2026) - - expect(page).to have_button(I18n.t(:button_add_non_working_time)) + it "is denied access to their own non-working times on the users page" do + nwt_page.visit! + nwt_page.expect_not_authorized end it "is denied access to another user's non-working times" do - visit_non_working_times - expect(page).to have_text(I18n.t(:notice_not_authorized)) + Pages::Users::NonWorkingTimes.new(user: managed_user, year: 2026).visit! + nwt_page.expect_not_authorized end end - context "with manage_working_times permission" do - current_user { create(:user, global_permissions: [:manage_working_times]) } + context "with manage_user, view_all_principals, and manage_working_times permissions" do + current_user { create(:user, global_permissions: %i[manage_user view_all_principals manage_working_times]) } shared_let(:other_user_nwt) do create(:user_non_working_time, user: managed_user, @@ -185,29 +230,26 @@ RSpec.describe "User non-working times", :js, with_flag: { user_working_times: t end_date: Date.new(2026, 5, 8)) end - before { visit_non_working_times } + before { nwt_page.visit! } it "can view another user's non-working times page with the add button" do - expect(page).to have_button(I18n.t(:button_add_non_working_time)) + nwt_page.expect_add_button end it "can open the edit dialog for another user's entry via the sidebar" do - find("a[data-controller='async-dialog']").click - expect(page).to have_css(dialog_selector) + nwt_page.open_edit_dialog_from_sidebar - within(dialog_selector) do - expect(page).to have_field("non_working_time[start_date]", with: "2026-05-04") - expect(page).to have_button(I18n.t(:button_delete)) - end + nwt_page.expect_dialog_start_date("2026-05-04") + nwt_page.expect_dialog_has_delete_button end it "can create a new entry for another user" do - open_create_dialog + nwt_page.open_create_dialog - set_date_in_dialog(:start_date, Date.new(2026, 6, 1)) - set_date_in_dialog(:end_date, Date.new(2026, 6, 5)) + nwt_page.set_start_date(Date.new(2026, 6, 1)) + nwt_page.set_end_date(Date.new(2026, 6, 5)) - submit_dialog + nwt_page.confirm_dialog expect(managed_user.non_working_times.count).to eq(2) end @@ -217,8 +259,8 @@ RSpec.describe "User non-working times", :js, with_flag: { user_working_times: t current_user { create(:user) } it "is denied access" do - visit_non_working_times - expect(page).to have_text(I18n.t(:notice_not_authorized)) + nwt_page.visit! + nwt_page.expect_not_authorized end end end diff --git a/spec/features/users/working_hours_spec.rb b/spec/features/users/working_hours_spec.rb new file mode 100644 index 00000000000..4e95d47407f --- /dev/null +++ b/spec/features/users/working_hours_spec.rb @@ -0,0 +1,269 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe "User working hours", :js, with_flag: { user_working_times: true } do + shared_let(:admin) { create(:admin) } + shared_let(:managed_user) { create(:user) } + + let(:wh_page) { Pages::Users::WorkingHours.new(user: managed_user) } + + current_user { admin } + + describe "current schedule card" do + context "when no working hours exist" do + before { wh_page.visit! } + + it "shows the not-set placeholder text in the stats" do + wh_page.expect_current_schedule_section + wh_page.expect_not_set + end + + it "shows the edit pencil linked to the create dialog" do + wh_page.expect_editable_current_schedule + end + end + + context "when working hours are set for today" do + shared_let(:working_hours) do + create(:user_working_hours, + user: managed_user, + valid_from: Date.current, + monday: 480, tuesday: 480, wednesday: 480, thursday: 480, friday: 480, + saturday: 0, sunday: 0, + availability_factor: 80) + end + + before { wh_page.visit! } + + it "displays the correct work days, hours, availability, and effective hours" do + wh_page.expect_stats( + work_days: 5, + weekly_hours: "40h", + availability: "80%", + effective_hours: "32h" + ) + end + + it "shows the pencil linked to the edit dialog" do + wh_page.open_current_schedule_dialog + + wh_page.expect_dialog_title_current + end + end + end + + describe "creating a current schedule" do + before { wh_page.visit! } + + it "creates working hours via the current schedule dialog" do + wh_page.open_current_schedule_dialog + + wh_page.expect_dialog_title_current + wh_page.expect_no_valid_from_field + + wh_page.submit_dialog + + expect(managed_user.working_hours.count).to eq(1) + expect(managed_user.working_hours.current.valid_from).to eq(Date.current) + end + end + + describe "editing the current schedule" do + shared_let(:working_hours) do + create(:user_working_hours, + user: managed_user, + valid_from: Date.current, + monday: 480, tuesday: 480, wednesday: 480, thursday: 480, friday: 480, + saturday: 0, sunday: 0, + availability_factor: 100) + end + + before { wh_page.visit! } + + it "opens the edit dialog with the current schedule's title" do + wh_page.open_current_schedule_dialog + + wh_page.expect_dialog_title_current + wh_page.expect_no_valid_from_field + end + + it "saves changes to the current schedule" do + wh_page.open_current_schedule_dialog + wh_page.set_availability_factor(75) + wh_page.save_dialog + + expect(working_hours.reload.availability_factor).to eq(75) + end + end + + describe "future schedules" do + describe "adding a future schedule" do + before { wh_page.visit! } + + it "shows the future schedule section with an add button" do + wh_page.expect_future_section + wh_page.expect_add_future_button + end + + it "shows the blank slate when no future schedules exist" do + wh_page.expect_future_blank_slate + end + + it "creates a future schedule via the dialog" do + wh_page.open_add_future_schedule_dialog + + wh_page.expect_dialog_title_future + wh_page.expect_valid_from_field + + wh_page.set_valid_from(Date.new(2027, 1, 1)) + + wh_page.submit_dialog + + expect(managed_user.working_hours.upcoming(Date.new(2027, 1, 1)).count).to eq(1) + end + end + + describe "editing a future schedule" do + shared_let(:future_wh) do + create(:user_working_hours, + user: managed_user, + valid_from: Date.new(2027, 6, 1), + monday: 240, tuesday: 240, wednesday: 240, thursday: 240, friday: 240, + saturday: 0, sunday: 0, + availability_factor: 100) + end + + before { wh_page.visit! } + + it "opens the edit dialog from the action menu" do + wh_page.open_row_action_menu + click_on I18n.t(:button_edit) + + expect(page).to have_css(wh_page.dialog_selector) + wh_page.expect_dialog_title_future + end + + it "saves updated values" do + wh_page.open_row_action_menu + click_on I18n.t(:button_edit) + + wh_page.set_availability_factor(50) + wh_page.save_dialog + + expect(future_wh.reload.availability_factor).to eq(50) + end + end + + describe "deleting a future schedule" do + it "deletes the schedule via the action menu" do + future_wh = create(:user_working_hours, + user: managed_user, + valid_from: Date.new(2027, 6, 1), + monday: 480, tuesday: 480, wednesday: 480, thursday: 480, friday: 480, + saturday: 0, sunday: 0, + availability_factor: 100) + + wh_page.visit! + + wh_page.open_row_action_menu + wh_page.delete_schedule + + expect(UserWorkingHours.exists?(future_wh.id)).to be(false) + end + end + end + + describe "schedule history" do + shared_let(:past_wh) do + create(:user_working_hours, + user: managed_user, + valid_from: Date.new(2025, 1, 1), + monday: 360, tuesday: 360, wednesday: 360, thursday: 360, friday: 360, + saturday: 0, sunday: 0, + availability_factor: 100) + end + + shared_let(:current_wh) do + create(:user_working_hours, + user: managed_user, + valid_from: Date.new(2026, 1, 1), + monday: 480, tuesday: 480, wednesday: 480, thursday: 480, friday: 480, + saturday: 0, sunday: 0, + availability_factor: 100) + end + + before { wh_page.visit! } + + it "shows past schedules in the history section" do + wh_page.expect_history_section + # The 2025 entry has 6h/day × 5 days = 30h/week + expect(page).to have_text("30h") + end + end + + describe "access control" do + context "with manage_own_working_times permission" do + current_user { create(:user, global_permissions: [:manage_own_working_times]) } + let(:wh_page) { Pages::Users::WorkingHours.new(user: current_user) } + + it "is denied access to their own working hours on the users page" do + wh_page.visit! + wh_page.expect_not_authorized + end + + it "is denied access to another user's working hours" do + Pages::Users::WorkingHours.new(user: managed_user).visit! + wh_page.expect_not_authorized + end + end + + context "with manage_user, view_all_principals, and manage_working_times permissions" do + current_user { create(:user, global_permissions: %i[manage_user view_all_principals manage_working_times]) } + + it "can view another user's working hours page" do + wh_page.visit! + + wh_page.expect_current_schedule_section + wh_page.expect_editable_current_schedule + end + end + + context "with no working times permissions" do + current_user { create(:user) } + + it "is denied access" do + wh_page.visit! + wh_page.expect_not_authorized + end + end + end +end diff --git a/spec/support/pages/users/non_working_times.rb b/spec/support/pages/users/non_working_times.rb new file mode 100644 index 00000000000..29fa5bf748a --- /dev/null +++ b/spec/support/pages/users/non_working_times.rb @@ -0,0 +1,183 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "support/pages/page" + +module Pages + module Users + class NonWorkingTimes < ::Pages::Page + attr_reader :user, :year + + # Pass user: nil for the /my/non_working_times context + def initialize(user: nil, year: Date.current.year) + super() + @user = user + @year = year + end + + def path + if user + edit_user_path(user, tab: :non_working_times, year:) + else + my_non_working_times_path(year:) + end + end + + def dialog_selector + "##{::Users::NonWorkingTimes::DialogComponent::DIALOG_ID}" + end + + # -- Actions -- + + def open_create_dialog + click_on I18n.t(:button_add_non_working_time) + expect(page).to have_css(dialog_selector) + end + + def open_edit_dialog_from_sidebar + find("a[data-controller='async-dialog'][href*='/edit']").click + expect(page).to have_css(dialog_selector) + end + + def open_edit_dialog_from_calendar + find(".non-working-day--user").click + expect(page).to have_css(dialog_selector) + end + + def click_calendar_day(date) + find("[data-date='#{date}']").click + end + + def set_start_date(date) + set_date_field(:start_date, date) + end + + def set_end_date(date) + set_date_field(:end_date, date) + end + + def confirm_dialog + within(dialog_selector) { click_on I18n.t(:button_confirm) } + expect(page).to have_no_css(dialog_selector) + end + + def delete_in_dialog + accept_confirm do + within(dialog_selector) { click_on I18n.t(:button_delete) } + end + expect(page).to have_no_css(dialog_selector) + end + + # -- Expectations -- + + def expect_dialog_open + expect(page).to have_css(dialog_selector) + end + + def expect_dialog_closed + expect(page).to have_no_css(dialog_selector) + end + + def expect_dialog_start_date(value) + within(dialog_selector) do + expect(page).to have_field("user_non_working_time[start_date]", with: value) + end + end + + def expect_dialog_end_date(value) + within(dialog_selector) do + expect(page).to have_field("user_non_working_time[end_date]", with: value) + end + end + + def expect_dialog_dates(start_date:, end_date:) + expect_dialog_start_date(start_date) + expect_dialog_end_date(end_date) + end + + def expect_dialog_has_delete_button + within(dialog_selector) do + expect(page).to have_link(I18n.t(:button_delete)) + end + end + + def expect_validation_error(message) + within(dialog_selector) do + expect(page).to have_text(message) + end + end + + def expect_working_days_count(count) + expect(page).to have_field(I18n.t(:label_working_days), disabled: true, with: count.to_s) + end + + def expect_sidebar_entry(text) + expect(page).to have_css("a[data-controller='async-dialog']", text:) + end + + def expect_no_sidebar_entry(text) + expect(page).to have_no_css("a[data-controller='async-dialog']", text:) + end + + def expect_add_button + expect(page).to have_link(I18n.t(:button_add_non_working_time)) + end + + def expect_no_add_button + expect(page).to have_no_link(I18n.t(:button_add_non_working_time)) + end + + def expect_selectable_calendar + expect(page).to have_css("[data-users--non-working-times-new-url-value]") + end + + def expect_non_selectable_calendar + expect(page).to have_no_css("[data-users--non-working-times-new-url-value]") + end + + def expect_calendar_rendered + expect(page).to have_css(".op-fc-wrapper") + expect(page).to have_css(".users-non-working-times-calendar-view") + end + + def expect_not_authorized + expect(page).to have_text(I18n.t(:notice_not_authorized)) + end + + private + + def set_date_field(field_name, date) + datepicker = Components::BasicDatepicker.new(dialog_selector) + datepicker.open("input[name='user_non_working_time[#{field_name}]']") + datepicker.set_date(date) + end + end + end +end diff --git a/spec/support/pages/users/working_hours.rb b/spec/support/pages/users/working_hours.rb new file mode 100644 index 00000000000..24dce02c8cc --- /dev/null +++ b/spec/support/pages/users/working_hours.rb @@ -0,0 +1,171 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "support/pages/page" + +module Pages + module Users + class WorkingHours < ::Pages::Page + attr_reader :user + + # Pass user: nil for the /my/working_hours context + def initialize(user: nil) + super() + @user = user + end + + def path + if user + edit_user_path(user, tab: :working_hours) + else + my_working_hours_path + end + end + + def dialog_selector + "##{::Users::WorkingHours::DialogComponent::DIALOG_ID}" + end + + # -- Actions -- + + def open_current_schedule_dialog + find("a[data-controller='async-dialog'][href*='current']").click + expect(page).to have_css(dialog_selector) + end + + def open_add_future_schedule_dialog + first("a[data-controller='async-dialog'][href$='working_hours/new']").click + expect(page).to have_css(dialog_selector) + end + + def open_row_action_menu + find(:link_or_button) { it.has_selector?("svg.octicon-kebab-horizontal") }.click + end + + def set_valid_from(date) + datepicker = Components::BasicDatepicker.new(dialog_selector) + datepicker.open("input[name='user_working_hours[valid_from]']") + datepicker.set_date(date) + end + + def set_availability_factor(value) + within(dialog_selector) do + fill_in "user_working_hours[availability_factor]", with: value.to_s + end + end + + def submit_dialog + within(dialog_selector) { click_on I18n.t(:button_create) } + expect(page).to have_no_css(dialog_selector) + end + + def save_dialog + within(dialog_selector) { click_on I18n.t(:button_save) } + expect(page).to have_no_css(dialog_selector) + end + + def delete_schedule + accept_confirm do + click_on I18n.t(:button_delete) + end + expect(page).to have_no_css(dialog_selector) + end + + # -- Expectations -- + + def expect_current_schedule_section + expect(page).to have_text(I18n.t("users.working_hours.current_schedule.title")) + end + + def expect_future_section + expect(page).to have_text(I18n.t("users.working_hours.future.title")) + end + + def expect_history_section + expect(page).to have_text(I18n.t("users.working_hours.history.title")) + end + + def expect_not_set + expect(page).to have_text(I18n.t("users.working_hours.current_schedule.not_set"), minimum: 1) + end + + def expect_stats(work_days: nil, weekly_hours: nil, availability: nil, effective_hours: nil) + expect(page).to have_text(work_days.to_s) if work_days + expect(page).to have_text(weekly_hours) if weekly_hours + expect(page).to have_text(availability) if availability + expect(page).to have_text(effective_hours) if effective_hours + end + + def expect_future_blank_slate + expect(page).to have_text(I18n.t("users.working_hours.future.blank_title")) + end + + def expect_editable_current_schedule + expect(page).to have_css("a[data-controller='async-dialog'][href*='current']") + end + + def expect_not_editable_current_schedule + expect(page).to have_no_css("a[data-controller='async-dialog'][href*='current']") + end + + def expect_add_future_button + expect(page).to have_css("a[data-controller='async-dialog'][href$='working_hours/new']") + end + + def expect_dialog_title_current + within(dialog_selector) do + expect(page).to have_text(I18n.t("users.working_hours.form.title_current")) + end + end + + def expect_dialog_title_future + within(dialog_selector) do + expect(page).to have_text(I18n.t("users.working_hours.form.title")) + end + end + + def expect_no_valid_from_field + within(dialog_selector) do + expect(page).to have_no_field(I18n.t("users.working_hours.form.start_date")) + end + end + + def expect_valid_from_field + within(dialog_selector) do + expect(page).to have_field(I18n.t("users.working_hours.form.start_date")) + end + end + + def expect_not_authorized + expect(page).to have_text(I18n.t(:notice_not_authorized)) + end + end + end +end