From d3d693f239ea10c5c5df144f91cc4c2e87685eaa Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Tue, 3 Mar 2026 16:07:20 +0100 Subject: [PATCH] Implement editing and adding of non working times --- .../non_working_times/calendar_component.rb | 12 ++- .../dialog_component.html.erb | 30 ++++++++ .../non_working_times/dialog_component.rb | 60 +++++++++++++++ .../users/non_working_times/form.rb | 74 +++++++++++++++++++ .../non_working_times/form_component.html.erb | 20 +++++ .../users/non_working_times/form_component.rb | 64 ++++++++++++++++ .../sidebar_component.html.erb | 12 ++- .../non_working_times/sidebar_component.rb | 23 ++++-- .../sub_header_component.html.erb | 20 ++--- .../non_working_times/sub_header_component.rb | 10 ++- .../year_overview_component.rb | 11 +-- .../user_non_working_times/base_contract.rb | 5 ++ .../user_non_working_times/create_contract.rb | 3 + .../user_non_working_times/delete_contract.rb | 4 + .../user_non_working_times/update_contract.rb | 37 ++++++++++ .../users/non_working_times_controller.rb | 69 ++++++++++++++--- app/models/user_non_working_time.rb | 3 + .../user_non_working_times/update_service.rb | 34 +++++++++ app/views/my/non_working_times.html.erb | 2 +- .../users/non_working_times/_list.html.erb | 2 +- bin/recreate-database | 26 +++++++ config/locales/en.yml | 4 + config/routes.rb | 6 +- .../non-working-times-form.controller.ts | 62 ++++++++++++++++ .../users/non-working-times.controller.ts | 23 +++++- frontend/src/stimulus/setup.ts | 2 + 26 files changed, 577 insertions(+), 41 deletions(-) create mode 100644 app/components/users/non_working_times/dialog_component.html.erb create mode 100644 app/components/users/non_working_times/dialog_component.rb create mode 100644 app/components/users/non_working_times/form.rb create mode 100644 app/components/users/non_working_times/form_component.html.erb create mode 100644 app/components/users/non_working_times/form_component.rb create mode 100644 app/contracts/user_non_working_times/update_contract.rb create mode 100644 app/services/user_non_working_times/update_service.rb create mode 100755 bin/recreate-database create mode 100644 frontend/src/stimulus/controllers/dynamic/users/non-working-times-form.controller.ts diff --git a/app/components/users/non_working_times/calendar_component.rb b/app/components/users/non_working_times/calendar_component.rb index 64134d6c365..468ba11cafa 100644 --- a/app/components/users/non_working_times/calendar_component.rb +++ b/app/components/users/non_working_times/calendar_component.rb @@ -35,10 +35,15 @@ module Users include OpPrimer::ComponentHelpers options non_working_times: [], - year: Date.current.year + year: Date.current.year, + user: nil private + def can_update? + user.present? && UserNonWorkingTimes::UpdateContract.can_update?(user: User.current, target_user: user) + end + def wrapper_data { "controller" => "users--non-working-times", @@ -78,8 +83,9 @@ module Users end: (clipped.end_date + 1.day).iso8601, title: event_title(clipped), working_days: clipped.working_days_count, - type: "user" - } + type: "user", + edit_url: can_update? ? edit_user_non_working_time_path(user, nwt.id) : nil + }.compact end end diff --git a/app/components/users/non_working_times/dialog_component.html.erb b/app/components/users/non_working_times/dialog_component.html.erb new file mode 100644 index 00000000000..27ba06e7395 --- /dev/null +++ b/app/components/users/non_working_times/dialog_component.html.erb @@ -0,0 +1,30 @@ +<%= component_wrapper do %> + <%= render(Primer::Alpha::Dialog.new(id: DIALOG_ID, title:)) do |dialog| %> + <% dialog.with_body do %> + <%= render(Users::NonWorkingTimes::FormComponent.new(user:, non_working_time:)) %> + <% end %> + + <% dialog.with_footer do %> + <%= render(Primer::Box.new(display: :flex, justify_content: :space_between, flex: 1)) do %> + <%= render(Primer::Box.new) do %> + <% if non_working_time.persisted? && can_delete? %> + <%= render( + Primer::Beta::Button.new( + scheme: :danger, + tag: :a, + href: destroy_url, + data: { turbo_method: :delete, turbo_confirm: t(:text_are_you_sure) } + ) + ) do %> + <%= t(:button_delete) %> + <% end %> + <% end %> + <% end %> + <%= render(Primer::Box.new(display: :flex, gap: 2)) do %> + <%= render(Primer::Beta::Button.new(data: { "close-dialog-id": DIALOG_ID })) { t(:button_cancel) } %> + <%= render(Primer::Beta::Button.new(scheme: :primary, form: Users::NonWorkingTimes::FormComponent::FORM_ID, type: :submit)) { t(:button_confirm) } %> + <% end %> + <% end %> + <% end %> + <% end %> +<% end %> diff --git a/app/components/users/non_working_times/dialog_component.rb b/app/components/users/non_working_times/dialog_component.rb new file mode 100644 index 00000000000..ea564345cf1 --- /dev/null +++ b/app/components/users/non_working_times/dialog_component.rb @@ -0,0 +1,60 @@ +# 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. +#++ + +module Users + module NonWorkingTimes + class DialogComponent < ApplicationComponent + include OpTurbo::Streamable + include OpPrimer::ComponentHelpers + + DIALOG_ID = "non-working-time-dialog" + + attr_reader :user, :non_working_time + + def initialize(user:, non_working_time:, **) + super(nil, **) + @user = user + @non_working_time = non_working_time + end + + def title + non_working_time.persisted? ? t(:button_edit_non_working_time) : t(:button_add_non_working_time) + end + + def can_delete? + UserNonWorkingTimes::DeleteContract.can_delete?(user: User.current, target_user: user) + end + + def destroy_url + user_non_working_time_path(user, non_working_time) + end + end + end +end diff --git a/app/components/users/non_working_times/form.rb b/app/components/users/non_working_times/form.rb new file mode 100644 index 00000000000..58a173fcafc --- /dev/null +++ b/app/components/users/non_working_times/form.rb @@ -0,0 +1,74 @@ +# 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. +#++ + +module Users + module NonWorkingTimes + class Form < ApplicationForm + form do |f| + f.group(layout: :horizontal) do |g| + g.single_date_picker( + name: :start_date, + label: I18n.t(:label_start_date), + required: true, + value: model.start_date&.iso8601, + datepicker_options: { + inDialog: Users::NonWorkingTimes::DialogComponent::DIALOG_ID, + data: { + action: "change->users--non-working-times-form#previewWorkingDays" + } + } + ) + + g.single_date_picker( + name: :end_date, + label: I18n.t(:label_end_date), + required: true, + value: model.end_date&.iso8601, + datepicker_options: { + inDialog: Users::NonWorkingTimes::DialogComponent::DIALOG_ID, + data: { + action: "change->users--non-working-times-form#previewWorkingDays" + } + } + ) + + g.text_field( + name: :working_days_display, + label: I18n.t(:label_working_days), + disabled: true, + value: model.working_days_count, + datepicker_options: { inDialog: Users::NonWorkingTimes::DialogComponent::DIALOG_ID }, + data: { "users--non-working-times-form-target": "workingDaysInput" } + ) + end + end + end + end +end diff --git a/app/components/users/non_working_times/form_component.html.erb b/app/components/users/non_working_times/form_component.html.erb new file mode 100644 index 00000000000..9e61a2eee66 --- /dev/null +++ b/app/components/users/non_working_times/form_component.html.erb @@ -0,0 +1,20 @@ +<%= component_wrapper do %> + <%= primer_form_with( + model: non_working_time, + url: form_url, + method: form_method, + id: FORM_ID, + data: { + controller: "users--non-working-times-form", + "users--non-working-times-form-preview-url-value" => working_days_preview_url + } + ) do |f| %> + <%= render(Users::NonWorkingTimes::Form.new(f)) %> + + <% if non_working_time.errors.any? %> + <%= render(Primer::Beta::Flash.new(scheme: :danger, mt: 2)) do %> + <%= non_working_time.errors.full_messages.join(", ") %> + <% end %> + <% end %> + <% end %> +<% end %> diff --git a/app/components/users/non_working_times/form_component.rb b/app/components/users/non_working_times/form_component.rb new file mode 100644 index 00000000000..0ae898fe0e8 --- /dev/null +++ b/app/components/users/non_working_times/form_component.rb @@ -0,0 +1,64 @@ +# 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. +#++ + +module Users + module NonWorkingTimes + class FormComponent < ApplicationComponent + include OpTurbo::Streamable + include OpPrimer::ComponentHelpers + + FORM_ID = "non-working-time-form" + + attr_reader :user, :non_working_time + + def initialize(user:, non_working_time:, **) + super(nil, **) + @user = user + @non_working_time = non_working_time + end + + def form_url + if non_working_time.persisted? + user_non_working_time_path(user, non_working_time) + else + user_non_working_times_path(user) + end + end + + def form_method + non_working_time.persisted? ? :patch : :post + end + + def working_days_preview_url + working_days_preview_user_non_working_times_path(user) + end + end + end +end diff --git a/app/components/users/non_working_times/sidebar_component.html.erb b/app/components/users/non_working_times/sidebar_component.html.erb index 590e3696d37..34ddeaaaff9 100644 --- a/app/components/users/non_working_times/sidebar_component.html.erb +++ b/app/components/users/non_working_times/sidebar_component.html.erb @@ -4,8 +4,16 @@ <% end %> <% user_non_working_times.each do |nwt| %> - <%= render(Primer::Box.new(bg: :accent_emphasis, color: :on_emphasis, border_radius: 2, p: 2, mb: 1)) do %> - <%= range_label(nwt) %> + <% if can_update? %> + <%= link_to edit_href(nwt), + class: "color-bg-accent-emphasis color-fg-on-emphasis rounded-2 p-2 mb-1 d-block", + data: { controller: "async-dialog" } do %> + <%= range_label(nwt) %> + <% end %> + <% else %> + <%= render(Primer::Box.new(bg: :accent_emphasis, color: :on_emphasis, border_radius: 2, p: 2, mb: 1)) do %> + <%= range_label(nwt) %> + <% end %> <% end %> <% end %> <% end %> diff --git a/app/components/users/non_working_times/sidebar_component.rb b/app/components/users/non_working_times/sidebar_component.rb index a84fcba6ea0..21c6687815f 100644 --- a/app/components/users/non_working_times/sidebar_component.rb +++ b/app/components/users/non_working_times/sidebar_component.rb @@ -34,7 +34,8 @@ module Users include OpPrimer::ComponentHelpers options non_working_times: [], - year: Date.current.year + year: Date.current.year, + user: nil private @@ -57,18 +58,28 @@ module Users total_user_days + global_day_count end + def can_update? + user.present? && UserNonWorkingTimes::UpdateContract.can_update?(user: User.current, target_user: user) + end + + def can_delete? + user.present? && UserNonWorkingTimes::DeleteContract.can_delete?(user: User.current, target_user: user) + end + + def edit_href(clipped) + edit_user_non_working_time_path(user, clipped.id) + end + def range_label(clipped) date_range = format_date_range(clipped.start_date, clipped.end_date) "#{date_range}: #{I18n.t('label_x_working_days', count: clipped.working_days_count)}" end def format_date_range(first, last) - if first.month == last.month && first.year == last.year - "#{I18n.l(first, format: '%b %d')}-#{last.day}, #{first.year}" - elsif first.year == last.year - "#{I18n.l(first, format: '%b %d')} - #{I18n.l(last, format: '%b %d')}, #{first.year}" + if first.year == last.year + "#{I18n.l(first, format: :short)} - #{I18n.l(last, format: :short)}, #{first.year}" else - "#{I18n.l(first, format: '%b %d, %Y')} - #{I18n.l(last, format: '%b %d, %Y')}" + "#{I18n.l(first, format: :long)} - #{I18n.l(last, format: :long)}" end end end diff --git a/app/components/users/non_working_times/sub_header_component.html.erb b/app/components/users/non_working_times/sub_header_component.html.erb index 84d633c3f22..e9844a77b76 100644 --- a/app/components/users/non_working_times/sub_header_component.html.erb +++ b/app/components/users/non_working_times/sub_header_component.html.erb @@ -15,14 +15,16 @@ I18n.t(:label_today_capitalized) end - component.with_action_button( - scheme: :primary, - leading_icon: :plus, - label: I18n.t(:button_add_non_working_time), - data: { "turbo-stream" => true }, - tag: :a, - href: "#" - ) do - t(:button_add_non_working_time) + if can_create? + component.with_action_button( + scheme: :primary, + leading_icon: :plus, + label: I18n.t(:button_add_non_working_time), + data: { controller: "async-dialog" }, + tag: :a, + href: new_non_working_time_href + ) do + t(:button_add_non_working_time) + end end end %> 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 26d6e961794..7dabcfc028b 100644 --- a/app/components/users/non_working_times/sub_header_component.rb +++ b/app/components/users/non_working_times/sub_header_component.rb @@ -31,7 +31,15 @@ module Users module NonWorkingTimes class SubHeaderComponent < ApplicationComponent - options :year + options :year, :user + + def can_create? + UserNonWorkingTimes::CreateContract.can_create?(user: User.current, target_user: user) + end + + def new_non_working_time_href + new_user_non_working_time_path(user) + end def previous_year_attrs { diff --git a/app/components/users/non_working_times/year_overview_component.rb b/app/components/users/non_working_times/year_overview_component.rb index c90312a3934..0062e74fb6f 100644 --- a/app/components/users/non_working_times/year_overview_component.rb +++ b/app/components/users/non_working_times/year_overview_component.rb @@ -31,23 +31,24 @@ module Users module NonWorkingTimes class YearOverviewComponent < ApplicationComponent - attr_reader :non_working_times, :year + attr_reader :non_working_times, :year, :user - def initialize(year:, non_working_times:, **) + def initialize(year:, non_working_times:, user:, **) super(**) @year = year @non_working_times = non_working_times + @user = user end def call - render(Users::NonWorkingTimes::SubHeaderComponent.new(year: year)) + + render(Users::NonWorkingTimes::SubHeaderComponent.new(year:, user:)) + render(Primer::Alpha::Layout.new(classes: "users-non-working-times-year-overview")) do |layout| layout.with_main do - render(Users::NonWorkingTimes::CalendarComponent.new(non_working_times: non_working_times, year: year)) + render(Users::NonWorkingTimes::CalendarComponent.new(non_working_times: non_working_times, year: year, user:)) end layout.with_sidebar(col_placement: :end) do - render(Users::NonWorkingTimes::SidebarComponent.new(non_working_times: non_working_times, year: year)) + render(Users::NonWorkingTimes::SidebarComponent.new(non_working_times: non_working_times, year: year, user:)) end end end diff --git a/app/contracts/user_non_working_times/base_contract.rb b/app/contracts/user_non_working_times/base_contract.rb index 56d771b1635..275ac9f39af 100644 --- a/app/contracts/user_non_working_times/base_contract.rb +++ b/app/contracts/user_non_working_times/base_contract.rb @@ -38,6 +38,11 @@ module UserNonWorkingTimes def self.model = ::UserNonWorkingTime + def self.can_manage?(user:, target_user:) + user.allowed_globally?(:manage_working_times) || + (target_user.id == user.id && user.allowed_globally?(:manage_own_working_times)) + end + private def validate_manage_permission diff --git a/app/contracts/user_non_working_times/create_contract.rb b/app/contracts/user_non_working_times/create_contract.rb index b4d3217c31a..f8503d74697 100644 --- a/app/contracts/user_non_working_times/create_contract.rb +++ b/app/contracts/user_non_working_times/create_contract.rb @@ -30,5 +30,8 @@ module UserNonWorkingTimes class CreateContract < BaseContract + def self.can_create?(user:, target_user:) + can_manage?(user:, target_user:) + end end end diff --git a/app/contracts/user_non_working_times/delete_contract.rb b/app/contracts/user_non_working_times/delete_contract.rb index dec25b29076..45cdab6b77f 100644 --- a/app/contracts/user_non_working_times/delete_contract.rb +++ b/app/contracts/user_non_working_times/delete_contract.rb @@ -34,5 +34,9 @@ module UserNonWorkingTimes user.allowed_globally?(:manage_working_times) || (model.user_id == user.id && user.allowed_globally?(:manage_own_working_times)) } + + def self.can_delete?(user:, target_user:) + BaseContract.can_manage?(user:, target_user:) + end end end diff --git a/app/contracts/user_non_working_times/update_contract.rb b/app/contracts/user_non_working_times/update_contract.rb new file mode 100644 index 00000000000..b04b931197b --- /dev/null +++ b/app/contracts/user_non_working_times/update_contract.rb @@ -0,0 +1,37 @@ +# 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. +#++ + +module UserNonWorkingTimes + class UpdateContract < BaseContract + def self.can_update?(user:, target_user:) + can_manage?(user:, target_user:) + end + end +end diff --git a/app/controllers/users/non_working_times_controller.rb b/app/controllers/users/non_working_times_controller.rb index 8da18c58ba2..861b7f82bd7 100644 --- a/app/controllers/users/non_working_times_controller.rb +++ b/app/controllers/users/non_working_times_controller.rb @@ -30,16 +30,17 @@ class Users::NonWorkingTimesController < ApplicationController include WorkingTimesAuthorization + include OpTurbo::ComponentStream layout "admin" before_action :check_working_times_feature_flag_is_active - authorization_checked! :index, :create, :destroy + authorization_checked! :index, :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[destroy] + before_action :find_non_working_time, only: %i[edit update destroy] def index @year = (params[:year].presence || Date.current.year).to_i @@ -48,32 +49,78 @@ class Users::NonWorkingTimesController < ApplicationController render "users/edit" end + def new + @non_working_time = @user.non_working_times.build + + respond_with_dialog( + Users::NonWorkingTimes::DialogComponent.new(user: @user, non_working_time: @non_working_time) + ) + end + + def edit + respond_with_dialog( + Users::NonWorkingTimes::DialogComponent.new(user: @user, non_working_time: @non_working_time) + ) + end + def create call = UserNonWorkingTimes::CreateService .new(user: current_user) .call(**non_working_time_params, user: @user) if call.success? - flash[:notice] = I18n.t(:notice_successful_create) + close_dialog_via_turbo_stream(Users::NonWorkingTimes::DialogComponent::DIALOG_ID) + reload_page_via_turbo_stream else - flash[:error] = call.errors.full_messages.join(", ") + update_via_turbo_stream( + component: Users::NonWorkingTimes::FormComponent.new(user: @user, non_working_time: call.result), + status: :unprocessable_entity + ) end - redirect_to user_non_working_times_path(@user) + respond_with_turbo_streams + end + + def update + call = UserNonWorkingTimes::UpdateService + .new(model: @non_working_time, user: current_user) + .call(**non_working_time_params) + + if call.success? + close_dialog_via_turbo_stream(Users::NonWorkingTimes::DialogComponent::DIALOG_ID) + reload_page_via_turbo_stream + else + update_via_turbo_stream( + component: Users::NonWorkingTimes::FormComponent.new(user: @user, non_working_time: call.result), + status: :unprocessable_entity + ) + end + + respond_with_turbo_streams end def destroy call = UserNonWorkingTimes::DeleteService - .new(model: @user_non_working_time, user: current_user) + .new(model: @non_working_time, user: current_user) .call if call.success? - flash[:notice] = I18n.t(:notice_successful_delete) + reload_page_via_turbo_stream else - flash[:error] = call.errors.full_messages.join(", ") + render_error_flash_message_via_turbo_stream(message: call.errors.full_messages.join(", ")) end - redirect_to user_non_working_times_path(@user) + respond_with_turbo_streams + end + + def working_days_preview + start_date = Date.parse(params[:start_date]) + end_date = Date.parse(params[:end_date]) + nwt = @user.non_working_times.build(start_date:, end_date:) + + render json: { working_days: nwt.working_days_count } + rescue ArgumentError, TypeError + head :bad_request end private @@ -85,12 +132,12 @@ class Users::NonWorkingTimesController < ApplicationController end def find_non_working_time - @user_non_working_time = @user.non_working_times.find(params[:id]) + @non_working_time = @user.non_working_times.find(params[:id]) rescue ActiveRecord::RecordNotFound render_404 end def non_working_time_params - params.expect(non_working_time: [:date]) + params.expect(user_non_working_time: %i[start_date end_date]) end end diff --git a/app/models/user_non_working_time.rb b/app/models/user_non_working_time.rb index 6e7dbd1745f..39b939d9853 100644 --- a/app/models/user_non_working_time.rb +++ b/app/models/user_non_working_time.rb @@ -70,6 +70,8 @@ class UserNonWorkingTime < ApplicationRecord end def working_days + return [] if start_date.blank? || end_date.blank? + working_days_in(days) end @@ -108,6 +110,7 @@ class UserNonWorkingTime < ApplicationRecord def no_overlapping_ranges return unless start_date.present? && end_date.present? && user_id.present? + return if end_date < start_date errors.add(:start_date, :overlapping_range) if overlapping_range_exists? end diff --git a/app/services/user_non_working_times/update_service.rb b/app/services/user_non_working_times/update_service.rb new file mode 100644 index 00000000000..d38f924d33d --- /dev/null +++ b/app/services/user_non_working_times/update_service.rb @@ -0,0 +1,34 @@ +# 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. +#++ + +module UserNonWorkingTimes + class UpdateService < ::BaseServices::Update + end +end diff --git a/app/views/my/non_working_times.html.erb b/app/views/my/non_working_times.html.erb index b8a41083f70..dd262a23e53 100644 --- a/app/views/my/non_working_times.html.erb +++ b/app/views/my/non_working_times.html.erb @@ -1,2 +1,2 @@ <%= render(My::WorkingTimesHeaderComponent.new) %> -<%= render(Users::NonWorkingTimes::YearOverviewComponent.new(year: @year, non_working_times: @non_working_times)) %> +<%= render(Users::NonWorkingTimes::YearOverviewComponent.new(year: @year, non_working_times: @non_working_times, user: @user)) %> diff --git a/app/views/users/non_working_times/_list.html.erb b/app/views/users/non_working_times/_list.html.erb index dd275df41a3..105511930d1 100644 --- a/app/views/users/non_working_times/_list.html.erb +++ b/app/views/users/non_working_times/_list.html.erb @@ -27,7 +27,7 @@ See COPYRIGHT and LICENSE files for more details. ++#%> -<%= render(Users::NonWorkingTimes::SubHeaderComponent.new(year: @year)) %> +<%= 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 %> diff --git a/bin/recreate-database b/bin/recreate-database new file mode 100755 index 00000000000..7d57c1f905d --- /dev/null +++ b/bin/recreate-database @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# +# Deletes bundled javascript assets and rebuilds them. +# Useful for when your frontend doesn't work (jQuery not defined etc.) for seemingly no reason at all. + +die() { yell "$*"; exit 1; } +try() { eval "$@" || die "\n\nFailed to run '$*'"; } + +echo "Dropping database" +try "bundle exec rake db:drop" + +echo "Deleting structure.sql to recreate a fresh DB from migrations" +try "rm -f db/structure.sql" + +echo "Creating database" +try "bundle exec rake db:create" + +echo "Migrating database" +try "bundle exec rake db:migrate" + +echo "Seeding database" +try "bundle exec rake db:seed" + +echo "✔ Done." + + diff --git a/config/locales/en.yml b/config/locales/en.yml index 01ec0914e9f..cafa2825875 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -4225,8 +4225,12 @@ en: label_non_working_days: "Availability calendar" label_non_working_days_with_count: "Non-working days (%{count})" label_non_working_days_summary: "Summary" + button_add_non_working_time: "Time off" + button_edit_non_working_time: "Edit time off" label_continued_from_previous_year: "continued from previous year" label_continues_into_next_year: "continues into next year" + label_end_date: "Finish date" + label_working_days: "Working days" label_non_working_times_with_count: "%{year} time off (%{count})" label_non_working_times_summary: "%{year} summary" label_total_user_non_working_times: "Personal non-working days" diff --git a/config/routes.rb b/config/routes.rb index e8fbb37b3e4..69d3c86139d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -921,7 +921,11 @@ 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 create destroy] + resources :non_working_times, controller: "users/non_working_times", only: %i[index new create edit update destroy] do + collection do + get :working_days_preview + end + end collection do get "/invite" => "users/invite#start_dialog" diff --git a/frontend/src/stimulus/controllers/dynamic/users/non-working-times-form.controller.ts b/frontend/src/stimulus/controllers/dynamic/users/non-working-times-form.controller.ts new file mode 100644 index 00000000000..c336105e833 --- /dev/null +++ b/frontend/src/stimulus/controllers/dynamic/users/non-working-times-form.controller.ts @@ -0,0 +1,62 @@ +/* + * -- 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. + * ++ + */ + +import { Controller } from '@hotwired/stimulus'; + +export default class NonWorkingTimesFormController extends Controller { + static targets = [ + 'workingDaysInput', + ]; + + static values = { + previewUrl: String, + }; + + declare readonly workingDaysInputTarget:HTMLInputElement; + declare readonly hasWorkingDaysInputTarget:boolean; + declare readonly previewUrlValue:string; + + previewWorkingDays() { + const startDate = (this.element.querySelector('#user_non_working_time_start_date')?.value); + const endDate = (this.element.querySelector('#user_non_working_time_end_date')?.value); + + if (!startDate || !endDate) return; + + void fetch(`${this.previewUrlValue}?start_date=${startDate}&end_date=${endDate}`, { + headers: { Accept: 'application/json' }, + }) + .then((r) => r.json() as Promise<{ working_days:number }>) + .then(({ working_days }) => { + if (this.hasWorkingDaysInputTarget) { + this.workingDaysInputTarget.value = String(working_days); + } + }); + } +} diff --git a/frontend/src/stimulus/controllers/dynamic/users/non-working-times.controller.ts b/frontend/src/stimulus/controllers/dynamic/users/non-working-times.controller.ts index b31152c02db..4fa13fae815 100644 --- a/frontend/src/stimulus/controllers/dynamic/users/non-working-times.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/users/non-working-times.controller.ts @@ -32,6 +32,8 @@ import { Controller } from '@hotwired/stimulus'; import { Calendar } from '@fullcalendar/core'; import multiMonthPlugin from '@fullcalendar/multimonth'; import allLocales from '@fullcalendar/core/locales-all'; +import { renderStreamMessage } from '@hotwired/turbo'; +import { TurboHelpers } from 'core-turbo/helpers'; interface NonWorkingDayEvent { date?:string; @@ -40,6 +42,7 @@ interface NonWorkingDayEvent { title:string; type:'global' | 'user'; workingDays?:number; + edit_url?:string; } export default class NonWorkingTimesController extends Controller { @@ -97,11 +100,29 @@ export default class NonWorkingTimesController extends Controller { startTime: '00:00', endTime: '24:00', }, + eventClick: (info) => { + const editUrl = info.event.extendedProps.editUrl as string | undefined; + if (editUrl) { + info.jsEvent.preventDefault(); + this.openDialog(editUrl); + } + }, }); this.calendar.render(); } + private openDialog(url:string):void { + TurboHelpers.showProgressBar(); + + void fetch(url, { + headers: { Accept: 'text/vnd.turbo-stream.html' }, + }) + .then((response) => response.text()) + .then((html) => { renderStreamMessage(html); }) + .finally(() => { TurboHelpers.hideProgressBar(); }); + } + private scrollToToday() { if (this.yearValue !== new Date().getFullYear()) return; @@ -126,7 +147,7 @@ export default class NonWorkingTimesController extends Controller { start: event.start, end: event.end, title: event.title, - extendedProps: { workingDays: event.workingDays }, + extendedProps: { workingDays: event.workingDays, editUrl: event.edit_url }, classNames: ['non-working-day--user'], allDay: true, }; diff --git a/frontend/src/stimulus/setup.ts b/frontend/src/stimulus/setup.ts index ccae21c4c00..be2be48ec2d 100644 --- a/frontend/src/stimulus/setup.ts +++ b/frontend/src/stimulus/setup.ts @@ -27,6 +27,7 @@ import LazyPageController from './controllers/dynamic/work-packages/activities-t import EditablePageHeaderTitleController from './controllers/dynamic/editable-page-header-title.controller'; import WorkingHoursFormController from './controllers/dynamic/users/working-hours-form.controller'; import NonWorkingTimesController from './controllers/dynamic/users/non-working-times.controller'; +import NonWorkingTimesFormController from './controllers/dynamic/users/non-working-times-form.controller'; import AutoSubmit from '@stimulus-components/auto-submit'; import RevealController from '@stimulus-components/reveal'; @@ -86,6 +87,7 @@ OpenProjectStimulusApplication.preregister('select-autosize', SelectAutosizeCont OpenProjectStimulusApplication.preregister('editable-page-header-title', EditablePageHeaderTitleController); OpenProjectStimulusApplication.preregister('users--working-hours-form', WorkingHoursFormController); OpenProjectStimulusApplication.preregister('users--non-working-times', NonWorkingTimesController); +OpenProjectStimulusApplication.preregister('users--non-working-times-form', NonWorkingTimesFormController); OpenProjectStimulusApplication.preregister('check-all', CheckAllController); OpenProjectStimulusApplication.preregister('checkable', CheckableController); OpenProjectStimulusApplication.preregister('truncation', TruncationController);