Refactor views for user administration to be correctly in the users controller, add feature specs

This commit is contained in:
Klaus Zanders
2026-03-06 13:23:47 +01:00
parent 15a41d2957
commit f2f2ecca44
13 changed files with 1002 additions and 131 deletions
@@ -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
@@ -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)
@@ -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)
+26
View File
@@ -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
+1 -1
View File
@@ -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 %>
@@ -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)) %>
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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) }
},
+210
View File
@@ -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
+134 -92
View File
@@ -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
+269
View File
@@ -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
@@ -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
+171
View File
@@ -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