From d2b9e6fa8cc4b6141b67368727cef7d2eb5a91b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Fri, 26 Apr 2024 10:26:30 +0200 Subject: [PATCH 001/129] Prepare basic recurring meeting setup --- Gemfile | 3 + Gemfile.lock | 8 ++ .../meetings/header_component.html.erb | 37 ++++--- .../index_page_header_component.html.erb | 18 ++++ .../index_page_header_component.rb | 82 ++++++++++++++ .../recurring_meetings_controller.rb | 49 +++++++++ .../forms/recurring_meeting/schedule_form.rb | 102 ++++++++++++++++++ .../app/forms/recurring_meeting/title_form.rb | 47 ++++++++ modules/meeting/app/models/meeting.rb | 1 + .../meeting/app/models/recurring_meeting.rb | 14 +++ .../views/recurring_meetings/_form.html.erb | 34 ++++++ .../views/recurring_meetings/index.html.erb | 39 +++++++ .../app/views/recurring_meetings/new.html.erb | 51 +++++++++ modules/meeting/config/locales/en.yml | 4 + modules/meeting/config/routes.rb | 3 + ...0240426073948_create_recurring_meetings.rb | 15 +++ .../lib/open_project/meeting/engine.rb | 3 +- .../spec/models/recurring_meeting_spec.rb | 5 + .../spec/requests/recurring_meetings_spec.rb | 7 ++ 19 files changed, 504 insertions(+), 18 deletions(-) create mode 100644 modules/meeting/app/components/recurring_meetings/index_page_header_component.html.erb create mode 100644 modules/meeting/app/components/recurring_meetings/index_page_header_component.rb create mode 100644 modules/meeting/app/controllers/recurring_meetings_controller.rb create mode 100644 modules/meeting/app/forms/recurring_meeting/schedule_form.rb create mode 100644 modules/meeting/app/forms/recurring_meeting/title_form.rb create mode 100644 modules/meeting/app/models/recurring_meeting.rb create mode 100644 modules/meeting/app/views/recurring_meetings/_form.html.erb create mode 100644 modules/meeting/app/views/recurring_meetings/index.html.erb create mode 100644 modules/meeting/app/views/recurring_meetings/new.html.erb create mode 100644 modules/meeting/db/migrate/20240426073948_create_recurring_meetings.rb create mode 100644 modules/meeting/spec/models/recurring_meeting_spec.rb create mode 100644 modules/meeting/spec/requests/recurring_meetings_spec.rb diff --git a/Gemfile b/Gemfile index 6b2200ffa589..0a88fd08bed1 100644 --- a/Gemfile +++ b/Gemfile @@ -171,6 +171,9 @@ gem "paper_trail", "~> 15.2.0" gem "op-clamav-client", "~> 3.4", require: "clamav" +# Recurring meeting events definition +gem "ice_cube", github: "ice-cube-ruby/ice_cube", ref: "10ae8dc" + group :production do # we use dalli as standard memcache client # requires memcached 1.4+ diff --git a/Gemfile.lock b/Gemfile.lock index bfde1854325a..9edc2f3edde9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -6,6 +6,13 @@ GIT capybara_accessible_selectors (0.11.0) capybara (~> 3.36) +GIT + remote: https://github.com/ice-cube-ruby/ice_cube.git + revision: 10ae8dc1c64ea23c9461f2b046cf7ee4513050b9 + ref: 10ae8dc + specs: + ice_cube (0.16.4) + GIT remote: https://github.com/opf/md-to-pdf revision: fe05b4f8bae8fd46f4fa93b8e0adee6295ef7388 @@ -1265,6 +1272,7 @@ DEPENDENCIES httpx i18n-js (~> 4.2.3) i18n-tasks (~> 1.0.13) + ice_cube! json_schemer (~> 2.3.0) json_spec (~> 1.1.4) ladle diff --git a/modules/meeting/app/components/meetings/header_component.html.erb b/modules/meeting/app/components/meetings/header_component.html.erb index a3c97958fa88..b19e4f945354 100644 --- a/modules/meeting/app/components/meetings/header_component.html.erb +++ b/modules/meeting/app/components/meetings/header_component.html.erb @@ -36,26 +36,29 @@ item.with_leading_visual_icon(icon: :pencil) end if @meeting.editable? - menu.with_item(label: t(:button_copy), - href: copy_meeting_path(@meeting), - content_arguments: { - data: { turbo_stream: true } - }) do |item| - item.with_leading_visual_icon(icon: :copy) - end + unless @meeting.template? + menu.with_item(label: t(:button_copy), + href: copy_meeting_path(@meeting), + content_arguments: { + data: { turbo_stream: true } + }) do |item| + item.with_leading_visual_icon(icon: :copy) + end - menu.with_item(label: t(:label_icalendar_download), - href: download_ics_meeting_path(@meeting)) do |item| - item.with_leading_visual_icon(icon: :download) - end + menu.with_item(label: t(:label_icalendar_download), + href: download_ics_meeting_path(@meeting)) do |item| + item.with_leading_visual_icon(icon: :download) + end - if @meeting.open? && User.current.allowed_in_project?(:send_meeting_agendas_notification, @meeting.project) - menu.with_item(label: t('meeting.label_mail_all_participants'), - href: notify_meeting_path(@meeting), - form_arguments: { method: :post, data: { turbo: 'false' } }) do |item| - item.with_leading_visual_icon(icon: :mail) + if @meeting.open? &&User.current.allowed_in_project?(:send_meeting_agendas_notification, @meeting.project + ) + menu.with_item(label: t('meeting.label_mail_all_participants'), + href: notify_meeting_path(@meeting), + form_arguments: { method: :post, data: { turbo: 'false' } }) do |item| + item.with_leading_visual_icon(icon: :mail) + end + end end - end menu.with_item(label: t(:label_history), tag: :a, diff --git a/modules/meeting/app/components/recurring_meetings/index_page_header_component.html.erb b/modules/meeting/app/components/recurring_meetings/index_page_header_component.html.erb new file mode 100644 index 000000000000..5eee7795c3c2 --- /dev/null +++ b/modules/meeting/app/components/recurring_meetings/index_page_header_component.html.erb @@ -0,0 +1,18 @@ +<%= render(Primer::OpenProject::PageHeader.new) do |header| + header.with_title { page_title } + header.with_breadcrumbs(breadcrumb_items) + if render_create_button? + header.with_action_button(tag: :a, + href: dynamic_path, + scheme: :primary, + mobile_icon: :plus, + mobile_label: label_text, + aria: { label: accessibility_label_text }, + title: accessibility_label_text, + id: id, + test_selector: "add-recurring-meeting-button") do |button| + button.with_leading_visual_icon(icon: :plus) + label_text + end + end +end %> diff --git a/modules/meeting/app/components/recurring_meetings/index_page_header_component.rb b/modules/meeting/app/components/recurring_meetings/index_page_header_component.rb new file mode 100644 index 000000000000..02178283648f --- /dev/null +++ b/modules/meeting/app/components/recurring_meetings/index_page_header_component.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2010-2024 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 RecurringMeetings + class IndexPageHeaderComponent < ApplicationComponent + include OpPrimer::ComponentHelpers + include ApplicationHelper + + def initialize(project: nil) + super + @project = project + end + + def render_create_button? + if @project + User.current.allowed_in_project?(:create_meetings, @project) + else + User.current.allowed_in_any_project?(:create_meetings) + end + end + + def dynamic_path + polymorphic_path([:new, @project, :recurring_meeting]) + end + + def id + "add-recurring-meeting-button" + end + + def accessibility_label_text + I18n.t(:label_recurring_meeting_new) + end + + def label_text + I18n.t(:label_recurring_meeting) + end + + def page_title + I18n.t(:label_recurring_meeting_plural) + end + + def breadcrumb_items + [parent_element, + page_title] + end + + def parent_element + if @project.present? + { href: project_overview_path(@project.id), text: @project.name } + else + { href: home_path, text: I18n.t(:label_home) } + end + end + end +end diff --git a/modules/meeting/app/controllers/recurring_meetings_controller.rb b/modules/meeting/app/controllers/recurring_meetings_controller.rb new file mode 100644 index 000000000000..720ea7987e29 --- /dev/null +++ b/modules/meeting/app/controllers/recurring_meetings_controller.rb @@ -0,0 +1,49 @@ +class RecurringMeetingsController < ApplicationController + include Layout + + before_action :find_meeting, only: %i[show] + before_action :find_optional_project, only: %i[index new create] + before_action :authorize_global, only: %i[index new create] + + menu_item :meetings + + def index + @recurring_meetings = + if @project + RecurringMeeting.visible.where(project_id: @project.id) + else + RecurringMeeting.visible + end + end + + def new + @recurring_meeting = RecurringMeeting.new(project: @project) + end + + def show; end + + def create + @recurring_meeting = RecurringMeeting.new(recurring_meeting_params.merge(project: @project)) + + if @recurring_meeting.save + flash[:notice] = t(:notice_successful_create) + redirect_to action: :index + else + render action: :new + end + end + + private + + def find_meeting + @recurring_meeting = RecurringMeeting.visible.find(params[:id]) + rescue ActiveRecord::RecordNotFound + render_404 + end + + def recurring_meeting_params + params + .require(:recurring_meeting) + .permit(:title) + end +end diff --git a/modules/meeting/app/forms/recurring_meeting/schedule_form.rb b/modules/meeting/app/forms/recurring_meeting/schedule_form.rb new file mode 100644 index 000000000000..f1213ad11373 --- /dev/null +++ b/modules/meeting/app/forms/recurring_meeting/schedule_form.rb @@ -0,0 +1,102 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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. +#++ + +class RecurringMeeting::ScheduleForm < ApplicationForm + include OpenProject::StaticRouting::UrlHelpers + + form do |form| + form.select_list( + name: :recurrence, + input_width: :medium, + label: RecurringMeeting.human_attribute_name(:recurrence), + required: true, + autofocus: true + ) do |list| + list.option(label: "Daily", value: "daily") + list.option(label: "Daily on workdays", value: "workdays") + list.option(label: "Weekly", value: "weekly") + list.option(label: "Monthly", value: "monthly") + end + + form.check_box_group(label: "Days", layout: :horizontal, visually_hide_label: true) do |check_group| + check_group.check_box( + name: "monday", + label: "Monday" + ) + check_group.check_box( + name: "tuesday", + label: "Tuesday" + ) + check_group.check_box( + name: "wednesday", + label: "Wednesaday" + ) + check_group.check_box( + name: "thursday", + label: "Thursday" + ) + check_group.check_box( + name: "friday", + label: "Friday" + ) + check_group.check_box( + name: "saturday", + label: "Saturday" + ) + check_group.check_box( + name: "sunday", + label: "Sunday" + ) + end + + form.text_field( + name: :interval, + input_width: :medium, + label: RecurringMeeting.human_attribute_name(:interval), + type: :number, + step: 1 + ) + + form.select_list( + name: :end, + input_width: :medium, + label: RecurringMeeting.human_attribute_name(:ends_on), + required: true, + autofocus: true + ) do |list| + list.option(label: "Never", value: "Never") + list.option(label: "After a number of events", value: "after") + list.option(label: "On a pecific date", value: "date") + end + end + + def initialize(meeting:) + super() + @meeting = meeting + end +end diff --git a/modules/meeting/app/forms/recurring_meeting/title_form.rb b/modules/meeting/app/forms/recurring_meeting/title_form.rb new file mode 100644 index 000000000000..083003682cf7 --- /dev/null +++ b/modules/meeting/app/forms/recurring_meeting/title_form.rb @@ -0,0 +1,47 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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. +#++ + +class RecurringMeeting::TitleForm < ApplicationForm + include OpenProject::StaticRouting::UrlHelpers + + form do |form| + form.text_field( + name: :title, + input_width: :medium, + placeholder: RecurringMeeting.human_attribute_name(:title), + label: RecurringMeeting.human_attribute_name(:title), + required: true, + autofocus: true + ) + end + + def initialize(meeting:) + super() + @meeting = meeting + end +end diff --git a/modules/meeting/app/models/meeting.rb b/modules/meeting/app/models/meeting.rb index 27697a97fc57..75f6378e7aed 100644 --- a/modules/meeting/app/models/meeting.rb +++ b/modules/meeting/app/models/meeting.rb @@ -34,6 +34,7 @@ class Meeting < ApplicationRecord belongs_to :project belongs_to :author, class_name: "User" + belongs_to :recurring_meeting, optional: true has_one :agenda, dependent: :destroy, class_name: "MeetingAgenda" has_one :minutes, dependent: :destroy, class_name: "MeetingMinutes" has_many :contents, -> { readonly }, class_name: "MeetingContent" diff --git a/modules/meeting/app/models/recurring_meeting.rb b/modules/meeting/app/models/recurring_meeting.rb new file mode 100644 index 000000000000..b2bd3a83c7c6 --- /dev/null +++ b/modules/meeting/app/models/recurring_meeting.rb @@ -0,0 +1,14 @@ +class RecurringMeeting < ApplicationRecord + serialize :schedule, coder: IceCube::Schedule + + belongs_to :project + belongs_to :author, class_name: "User" + + has_many :meetings, inverse_of: :recurring_meeting + + scope :visible, ->(*args) { + includes(:project) + .references(:projects) + .merge(Project.allowed_to(args.first || User.current, :view_meetings)) + } +end diff --git a/modules/meeting/app/views/recurring_meetings/_form.html.erb b/modules/meeting/app/views/recurring_meetings/_form.html.erb new file mode 100644 index 000000000000..8b90537dac52 --- /dev/null +++ b/modules/meeting/app/views/recurring_meetings/_form.html.erb @@ -0,0 +1,34 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) 2012-2024 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. + +++#%> + +<%= error_messages_for 'recurring_meeting' %> + +<%= render(RecurringMeeting::TitleForm.new(f, meeting: @meeting)) %> + +<%= render(RecurringMeeting::ScheduleForm.new(f, meeting: @meeting)) %> diff --git a/modules/meeting/app/views/recurring_meetings/index.html.erb b/modules/meeting/app/views/recurring_meetings/index.html.erb new file mode 100644 index 000000000000..77e2c7e20d7b --- /dev/null +++ b/modules/meeting/app/views/recurring_meetings/index.html.erb @@ -0,0 +1,39 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) 2012-2024 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. + +++#%> +<% html_title t(:label_recurring_meeting_plural) %> + +<%= render(RecurringMeetings::IndexPageHeaderComponent.new(project: @project)) %> + +<% if @recurring_meetings.empty? -%> + <%= no_results_box %> +<% else -%> + <% @recurring_meetings.each do |meeting| %> + <%= link_to meeting.title, recurring_meeting_path(meeting) %> + <% end %> +<% end -%> diff --git a/modules/meeting/app/views/recurring_meetings/new.html.erb b/modules/meeting/app/views/recurring_meetings/new.html.erb new file mode 100644 index 000000000000..7b8c3f889fe8 --- /dev/null +++ b/modules/meeting/app/views/recurring_meetings/new.html.erb @@ -0,0 +1,51 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) 2012-2024 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. + +++#%> + +<%= + render(Primer::OpenProject::PageHeader.new) do |header| + header.with_title { t(:label_recurring_meeting_new) } + header.with_breadcrumbs([@project.present? ? + { href: project_overview_path(@project.id), text: @project.name } : + { href: home_path, text: I18n.t(:label_home) }, + { href: @project.present? ? project_recurring_meetings_path(@project.id) : recurring_meetings_path, + text: I18n.t(:label_meeting_plural) }, + t(:label_recurring_meeting_new)]) + end +%> + + +<%= primer_form_with( + model: @recurring_meeting, + url: { :controller => '/recurring_meetings', :action => 'create', :project_id => @project }) do |f| %> + <%= render :partial => 'form', locals: { f: f } %> + + <%= render Primer::Beta::Button.new(type: :submit, scheme: :primary, mt: 3) do %> + <%= t(:button_create) %> + <% end %> +<% end %> diff --git a/modules/meeting/config/locales/en.yml b/modules/meeting/config/locales/en.yml index 797124c433fb..b33596fd4cdb 100644 --- a/modules/meeting/config/locales/en.yml +++ b/modules/meeting/config/locales/en.yml @@ -65,6 +65,7 @@ en: messages: invalid_time_format: "is not a valid time. Required format: HH:MM" models: + recurring_meeting: "Recurring meeting" structured_meeting: "Meeting (dynamic)" meeting_agenda_item: "Agenda item" meeting_agenda: "Agenda" @@ -118,6 +119,9 @@ en: label_meeting_date_time: "Date/Time" label_meeting_date_and_time: "Date and time" label_meeting_diff: "Diff" + label_recurring_meeting: "Recurring meeting" + label_recurring_meeting_new: "New recurring meeting" + label_recurring_meeting_plural: "Recurring meetings" label_upcoming_meetings: "Upcoming meetings" label_past_meetings: "Past meetings" label_upcoming_meetings_short: "Upcoming" diff --git a/modules/meeting/config/routes.rb b/modules/meeting/config/routes.rb index a22ef0828d5d..b73b6a296fa8 100644 --- a/modules/meeting/config/routes.rb +++ b/modules/meeting/config/routes.rb @@ -33,6 +33,7 @@ get "menu" => "meetings/menus#show" end end + resources :recurring_meetings, only: %i[index new create show] end resources :work_packages, only: %i[] do @@ -55,6 +56,8 @@ resource :menu, only: %[show] end + resources :recurring_meetings, only: %i[index new create show] + resources :meetings do get :new_dialog, on: :collection member do diff --git a/modules/meeting/db/migrate/20240426073948_create_recurring_meetings.rb b/modules/meeting/db/migrate/20240426073948_create_recurring_meetings.rb new file mode 100644 index 000000000000..87f3f785c9a3 --- /dev/null +++ b/modules/meeting/db/migrate/20240426073948_create_recurring_meetings.rb @@ -0,0 +1,15 @@ +class CreateRecurringMeetings < ActiveRecord::Migration[7.1] + def change + create_table :recurring_meetings do |t| + t.text :title + t.text :schedule + t.belongs_to :project, foreign_key: true, index: true + t.belongs_to :author, foreign_key: { to_table: :users } + + t.timestamps + end + + add_reference :meetings, :recurring_meeting_id, index: true + add_column :meetings, :template, :boolean, default: false, null: false + end +end diff --git a/modules/meeting/lib/open_project/meeting/engine.rb b/modules/meeting/lib/open_project/meeting/engine.rb index 4c60ed2fedef..2e1060b2fce5 100644 --- a/modules/meeting/lib/open_project/meeting/engine.rb +++ b/modules/meeting/lib/open_project/meeting/engine.rb @@ -44,7 +44,8 @@ class Engine < ::Rails::Engine meeting_agendas: %i[history show diff], meeting_minutes: %i[history show diff], "meetings/menus": %i[show], - work_package_meetings_tab: %i[index count] }, + work_package_meetings_tab: %i[index count], + recurring_meetings: %i[index show new create] }, permissible_on: :project permission :create_meetings, { meetings: %i[new create copy new_dialog], diff --git a/modules/meeting/spec/models/recurring_meeting_spec.rb b/modules/meeting/spec/models/recurring_meeting_spec.rb new file mode 100644 index 000000000000..51386b89bf4b --- /dev/null +++ b/modules/meeting/spec/models/recurring_meeting_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe RecurringMeeting, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/modules/meeting/spec/requests/recurring_meetings_spec.rb b/modules/meeting/spec/requests/recurring_meetings_spec.rb new file mode 100644 index 000000000000..5f22e0ff9eb3 --- /dev/null +++ b/modules/meeting/spec/requests/recurring_meetings_spec.rb @@ -0,0 +1,7 @@ +require 'rails_helper' + +RSpec.describe "RecurringMeetings", type: :request do + describe "GET /index" do + pending "add some examples (or delete) #{__FILE__}" + end +end From 488636867c2eefdffc1260aace20de3f2fa44f0f Mon Sep 17 00:00:00 2001 From: Mir Bhatia Date: Tue, 28 May 2024 20:48:23 +0200 Subject: [PATCH 002/129] Add proof of concept to create schedules --- .../recurring_meetings_controller.rb | 30 +++++++++++++++++++ .../forms/recurring_meeting/schedule_form.rb | 26 +++++++++++++--- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/modules/meeting/app/controllers/recurring_meetings_controller.rb b/modules/meeting/app/controllers/recurring_meetings_controller.rb index 720ea7987e29..cba4cf6acf7d 100644 --- a/modules/meeting/app/controllers/recurring_meetings_controller.rb +++ b/modules/meeting/app/controllers/recurring_meetings_controller.rb @@ -24,6 +24,7 @@ def show; end def create @recurring_meeting = RecurringMeeting.new(recurring_meeting_params.merge(project: @project)) + create_schedule(params[:recurring_meeting]) if @recurring_meeting.save flash[:notice] = t(:notice_successful_create) @@ -46,4 +47,33 @@ def recurring_meeting_params .require(:recurring_meeting) .permit(:title) end + + def create_schedule(params) # rubocop:disable Metrics/AbcSize,Metrics/PerceivedComplexity + interval = params[:interval].to_i + recurrence = params[:recurrence] + ends = params[:end] + + days = [] # make the form pass an array directly + days << :monday if params[:monday].to_i == 1 + days << :tuesday if params[:tuesday].to_i == 1 + days << :wednesday if params[:wednesday].to_i == 1 + days << :thursday if params[:thursday].to_i == 1 + days << :friday if params[:friday].to_i == 1 + days << :saturday if params[:saturday].to_i == 1 + days << :sunday if params[:sunday].to_i == 1 + + schedule = IceCube::Schedule.new + rule = IceCube::Rule + .then { |r| recurrence == "daily" ? r.daily(interval) : r } + .then { |r| recurrence == "workdays" ? r.weekly(interval).day(:monday, :tuesday, :wednesday, :thursday, :friday) : r } # has to match chosen working days # rubocop:disable Layout/LineLength + .then { |r| recurrence == "weekly" ? r.weekly(interval).day(*days) : r } + .then { |r| recurrence == "monthly" ? r.monthly(interval) : r } + # .then { |r| ends == "never" ? r.until(params[:start_date] + 12.months) : r } # 12 month max or some sort of limit for 'never'? ; start_date needs to be added in # rubocop:disable Layout/LineLength + # .then { |r| ends == "date" ? r.until(params[:end_date]) : r } # end_date needs to be added in + .then { |r| ends == "after" ? r.count(params[:count].to_i) : r } + + schedule.add_recurrence_rule(rule) + + @recurring_meeting.schedule = schedule + end end diff --git a/modules/meeting/app/forms/recurring_meeting/schedule_form.rb b/modules/meeting/app/forms/recurring_meeting/schedule_form.rb index f1213ad11373..dd7b19de0a13 100644 --- a/modules/meeting/app/forms/recurring_meeting/schedule_form.rb +++ b/modules/meeting/app/forms/recurring_meeting/schedule_form.rb @@ -85,14 +85,32 @@ class RecurringMeeting::ScheduleForm < ApplicationForm form.select_list( name: :end, input_width: :medium, - label: RecurringMeeting.human_attribute_name(:ends_on), + label: RecurringMeeting.human_attribute_name(:ends), required: true, autofocus: true ) do |list| - list.option(label: "Never", value: "Never") - list.option(label: "After a number of events", value: "after") - list.option(label: "On a pecific date", value: "date") + list.option(label: "Never", value: "never") + list.option(label: "After a number of occurrences", value: "after") + list.option(label: "On a specific date", value: "date") end + + form.text_field( + name: :count, + input_width: :medium, + label: RecurringMeeting.human_attribute_name(:count), + type: :number, + step: 1 + ) + + # form.date_select( + # :start_date, + # label: RecurringMeeting.human_attribute_name(:start_date) + # ) + + # form.date_select( + # :end_date, + # label: RecurringMeeting.human_attribute_name(:end_date) + # ) end def initialize(meeting:) From fa2797806dec8821492af3301a212c9a9d10aa47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Tue, 1 Oct 2024 21:23:01 +0200 Subject: [PATCH 003/129] Add feature flag --- config/initializers/feature_decisions.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/config/initializers/feature_decisions.rb b/config/initializers/feature_decisions.rb index 4a14e7a891e9..e411bbecefd8 100644 --- a/config/initializers/feature_decisions.rb +++ b/config/initializers/feature_decisions.rb @@ -45,3 +45,6 @@ OpenProject::FeatureDecisions.add :custom_field_of_type_hierarchy, description: "Allows the use of the custom field type 'Hierarchy'." + +OpenProject::FeatureDecisions.add :recurring_meetings, + description: "Differentiate between one-time and recurring meetings." From fd1323d4d1468617f1527432a9c3da2a47889b09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Tue, 1 Oct 2024 21:30:34 +0200 Subject: [PATCH 004/129] Rework menu --- Gemfile | 2 +- Gemfile.lock | 9 +-------- app/menus/submenu.rb | 9 +++++++++ modules/meeting/app/menus/meetings/menu.rb | 4 +++- 4 files changed, 14 insertions(+), 10 deletions(-) diff --git a/Gemfile b/Gemfile index 0a88fd08bed1..cb8253370904 100644 --- a/Gemfile +++ b/Gemfile @@ -172,7 +172,7 @@ gem "paper_trail", "~> 15.2.0" gem "op-clamav-client", "~> 3.4", require: "clamav" # Recurring meeting events definition -gem "ice_cube", github: "ice-cube-ruby/ice_cube", ref: "10ae8dc" +gem "ice_cube", "~> 0.17.0" group :production do # we use dalli as standard memcache client diff --git a/Gemfile.lock b/Gemfile.lock index 9edc2f3edde9..d91c601285a5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -6,13 +6,6 @@ GIT capybara_accessible_selectors (0.11.0) capybara (~> 3.36) -GIT - remote: https://github.com/ice-cube-ruby/ice_cube.git - revision: 10ae8dc1c64ea23c9461f2b046cf7ee4513050b9 - ref: 10ae8dc - specs: - ice_cube (0.16.4) - GIT remote: https://github.com/opf/md-to-pdf revision: fe05b4f8bae8fd46f4fa93b8e0adee6295ef7388 @@ -1272,7 +1265,7 @@ DEPENDENCIES httpx i18n-js (~> 4.2.3) i18n-tasks (~> 1.0.13) - ice_cube! + ice_cube (~> 0.17.0) json_schemer (~> 2.3.0) json_spec (~> 1.1.4) ladle diff --git a/app/menus/submenu.rb b/app/menus/submenu.rb index 8909c15f32fa..c1f631333496 100644 --- a/app/menus/submenu.rb +++ b/app/menus/submenu.rb @@ -118,6 +118,15 @@ def menu_item(title:, icon_key: nil, count: nil, show_enterprise_icon: false, qu show_enterprise_icon:) end + def menu_link(title:, href:, icon_key: nil, count: nil, show_enterprise_icon: false) + OpenProject::Menu::MenuItem.new(title:, + href:, + icon: icon_map.fetch(icon_key, icon_key), + count:, + selected: current_page?(href), + show_enterprise_icon:) + end + def selected?(query_params) query_params.each_key do |filter_key| next if filter_key == :show_enterprise_icon diff --git a/modules/meeting/app/menus/meetings/menu.rb b/modules/meeting/app/menus/meetings/menu.rb index 9afeca06170d..9b5d66a9a0c4 100644 --- a/modules/meeting/app/menus/meetings/menu.rb +++ b/modules/meeting/app/menus/meetings/menu.rb @@ -51,7 +51,9 @@ def top_level_menu_items menu_item(title: I18n.t(:label_upcoming_meetings), query_params: { filters: upcoming_filter, sort: "start_time" }), menu_item(title: I18n.t(:label_past_meetings), - query_params: { filters: past_filter, sort: "start_time:desc" }) + query_params: { filters: past_filter, sort: "start_time:desc" }), + menu_link(title: I18n.t(:label_recurring_meeting_plural), + href: polymorphic_path([project, :recurring_meetings])) ] end From 1339c0b27672e2e20cc78c5fe142b89f16845095 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Tue, 1 Oct 2024 21:46:02 +0200 Subject: [PATCH 005/129] Pass request to meetings menu This doesn't help, as the requested url is the turbo frame --- app/menus/submenu.rb | 6 ++++-- .../controllers/meetings/menus_controller.rb | 2 +- .../recurring_meetings_controller.rb | 6 ++++++ modules/meeting/app/menus/meetings/menu.rb | 21 ++++++++++--------- 4 files changed, 22 insertions(+), 13 deletions(-) diff --git a/app/menus/submenu.rb b/app/menus/submenu.rb index c1f631333496..024c18662792 100644 --- a/app/menus/submenu.rb +++ b/app/menus/submenu.rb @@ -27,11 +27,13 @@ # ++ class Submenu include Rails.application.routes.url_helpers - attr_reader :view_type, :project, :params + include ActionView::Helpers::UrlHelper + attr_reader :view_type, :project, :request, :params - def initialize(view_type:, project: nil, params: nil) + def initialize(view_type:, params:, request: nil, project: nil) @view_type = view_type @project = project + @request = request @params = params end diff --git a/modules/meeting/app/controllers/meetings/menus_controller.rb b/modules/meeting/app/controllers/meetings/menus_controller.rb index 61e6d880f532..5fc4953fb442 100644 --- a/modules/meeting/app/controllers/meetings/menus_controller.rb +++ b/modules/meeting/app/controllers/meetings/menus_controller.rb @@ -30,7 +30,7 @@ class MenusController < ApplicationController before_action :load_and_authorize_in_optional_project def show - @submenu_menu_items = ::Meetings::Menu.new(project: @project, params:).menu_items + @submenu_menu_items = ::Meetings::Menu.new(project: @project, params:, request:).menu_items @create_btn_options = if @project.present? && User.current.allowed_in_project?(:create_meetings, @project) { href: new_project_meeting_path(@project), module_key: "meeting" } elsif @project.nil? && User.current.allowed_in_any_project?(:create_meetings) diff --git a/modules/meeting/app/controllers/recurring_meetings_controller.rb b/modules/meeting/app/controllers/recurring_meetings_controller.rb index cba4cf6acf7d..9bfe19137099 100644 --- a/modules/meeting/app/controllers/recurring_meetings_controller.rb +++ b/modules/meeting/app/controllers/recurring_meetings_controller.rb @@ -36,6 +36,12 @@ def create private + def find_optional_project + @project = Project.find(params[:project_id]) if params[:project_id].present? + rescue ActiveRecord::RecordNotFound + render_404 + end + def find_meeting @recurring_meeting = RecurringMeeting.visible.find(params[:id]) rescue ActiveRecord::RecordNotFound diff --git a/modules/meeting/app/menus/meetings/menu.rb b/modules/meeting/app/menus/meetings/menu.rb index 9b5d66a9a0c4..2786568f2893 100644 --- a/modules/meeting/app/menus/meetings/menu.rb +++ b/modules/meeting/app/menus/meetings/menu.rb @@ -27,13 +27,8 @@ # ++ module Meetings class Menu < Submenu - attr_reader :view_type, :project - - def initialize(project: nil, params: nil) - @project = project - @params = params - - super(view_type:, project:, params:) + def initialize(request:, params:, project: nil) + super(view_type: nil, project:, params:, request:) end def menu_items @@ -52,9 +47,15 @@ def top_level_menu_items query_params: { filters: upcoming_filter, sort: "start_time" }), menu_item(title: I18n.t(:label_past_meetings), query_params: { filters: past_filter, sort: "start_time:desc" }), - menu_link(title: I18n.t(:label_recurring_meeting_plural), - href: polymorphic_path([project, :recurring_meetings])) - ] + recurring_menu_item + ].compact + end + + def recurring_menu_item + return unless OpenProject::FeatureDecisions.recurring_meetings_active? + + menu_link(title: I18n.t(:label_recurring_meeting_plural), + href: polymorphic_path([project, :recurring_meetings])) end def involvement_sidebar_menu_items From 91276b290b2e7f9c35aef4a044116cc79f2df2f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 2 Oct 2024 20:55:06 +0200 Subject: [PATCH 006/129] Add filter menu for recurring meetings --- app/menus/submenu.rb | 15 +---- .../meetings/meeting_filters_component.rb | 3 +- .../controllers/meetings/menus_controller.rb | 2 +- .../recurring_meetings_controller.rb | 3 +- modules/meeting/app/menus/meetings/menu.rb | 12 ++-- .../meeting/app/models/queries/meetings.rb | 1 + .../queries/meetings/filters/type_filter.rb | 58 +++++++++++++++++++ modules/meeting/config/locales/en.yml | 1 + 8 files changed, 75 insertions(+), 20 deletions(-) create mode 100644 modules/meeting/app/models/queries/meetings/filters/type_filter.rb diff --git a/app/menus/submenu.rb b/app/menus/submenu.rb index 024c18662792..a76770c8dfa6 100644 --- a/app/menus/submenu.rb +++ b/app/menus/submenu.rb @@ -27,13 +27,11 @@ # ++ class Submenu include Rails.application.routes.url_helpers - include ActionView::Helpers::UrlHelper - attr_reader :view_type, :project, :request, :params + attr_reader :view_type, :project, :params - def initialize(view_type:, params:, request: nil, project: nil) + def initialize(view_type:, params:, project: nil) @view_type = view_type @project = project - @request = request @params = params end @@ -120,15 +118,6 @@ def menu_item(title:, icon_key: nil, count: nil, show_enterprise_icon: false, qu show_enterprise_icon:) end - def menu_link(title:, href:, icon_key: nil, count: nil, show_enterprise_icon: false) - OpenProject::Menu::MenuItem.new(title:, - href:, - icon: icon_map.fetch(icon_key, icon_key), - count:, - selected: current_page?(href), - show_enterprise_icon:) - end - def selected?(query_params) query_params.each_key do |filter_key| next if filter_key == :show_enterprise_icon diff --git a/modules/meeting/app/components/meetings/meeting_filters_component.rb b/modules/meeting/app/components/meetings/meeting_filters_component.rb index f40c91d9aaa6..0266367a801f 100644 --- a/modules/meeting/app/components/meetings/meeting_filters_component.rb +++ b/modules/meeting/app/components/meetings/meeting_filters_component.rb @@ -64,7 +64,8 @@ def allowed_filter?(filter) Queries::Meetings::Filters::AttendedUserFilter, Queries::Meetings::Filters::AuthorFilter, Queries::Meetings::Filters::InvitedUserFilter, - Queries::Meetings::Filters::TimeFilter + Queries::Meetings::Filters::TimeFilter, + Queries::Meetings::Filters::TypeFilter ] if project.nil? diff --git a/modules/meeting/app/controllers/meetings/menus_controller.rb b/modules/meeting/app/controllers/meetings/menus_controller.rb index 5fc4953fb442..61e6d880f532 100644 --- a/modules/meeting/app/controllers/meetings/menus_controller.rb +++ b/modules/meeting/app/controllers/meetings/menus_controller.rb @@ -30,7 +30,7 @@ class MenusController < ApplicationController before_action :load_and_authorize_in_optional_project def show - @submenu_menu_items = ::Meetings::Menu.new(project: @project, params:, request:).menu_items + @submenu_menu_items = ::Meetings::Menu.new(project: @project, params:).menu_items @create_btn_options = if @project.present? && User.current.allowed_in_project?(:create_meetings, @project) { href: new_project_meeting_path(@project), module_key: "meeting" } elsif @project.nil? && User.current.allowed_in_any_project?(:create_meetings) diff --git a/modules/meeting/app/controllers/recurring_meetings_controller.rb b/modules/meeting/app/controllers/recurring_meetings_controller.rb index 9bfe19137099..a384ba20a227 100644 --- a/modules/meeting/app/controllers/recurring_meetings_controller.rb +++ b/modules/meeting/app/controllers/recurring_meetings_controller.rb @@ -2,8 +2,9 @@ class RecurringMeetingsController < ApplicationController include Layout before_action :find_meeting, only: %i[show] - before_action :find_optional_project, only: %i[index new create] + before_action :find_optional_project, only: %i[index show new create] before_action :authorize_global, only: %i[index new create] + before_action :authorize, only: %i[show] menu_item :meetings diff --git a/modules/meeting/app/menus/meetings/menu.rb b/modules/meeting/app/menus/meetings/menu.rb index 2786568f2893..54cbfa9e31a9 100644 --- a/modules/meeting/app/menus/meetings/menu.rb +++ b/modules/meeting/app/menus/meetings/menu.rb @@ -27,8 +27,8 @@ # ++ module Meetings class Menu < Submenu - def initialize(request:, params:, project: nil) - super(view_type: nil, project:, params:, request:) + def initialize(params:, project: nil) + super(view_type: nil, project:, params:) end def menu_items @@ -54,8 +54,8 @@ def top_level_menu_items def recurring_menu_item return unless OpenProject::FeatureDecisions.recurring_meetings_active? - menu_link(title: I18n.t(:label_recurring_meeting_plural), - href: polymorphic_path([project, :recurring_meetings])) + menu_item(title: I18n.t(:label_recurring_meeting_plural), + query_params: { filters: recurring_meeting_type_filter, sort: "start_time:desc" }) end def involvement_sidebar_menu_items @@ -92,5 +92,9 @@ def attendee_filter def author_filter [{ author_id: { operator: "=", values: [User.current.id.to_s] } }].to_json end + + def recurring_meeting_type_filter + [{ type: { operator: "=", values: [RecurringMeeting.to_s] } }].to_json + end end end diff --git a/modules/meeting/app/models/queries/meetings.rb b/modules/meeting/app/models/queries/meetings.rb index 1e73d1c94f52..0ce63c6a0ead 100644 --- a/modules/meeting/app/models/queries/meetings.rb +++ b/modules/meeting/app/models/queries/meetings.rb @@ -34,5 +34,6 @@ module Queries::Meetings filter Filters::InvitedUserFilter filter Filters::AuthorFilter filter Filters::DatesIntervalFilter + filter Filters::TypeFilter end end diff --git a/modules/meeting/app/models/queries/meetings/filters/type_filter.rb b/modules/meeting/app/models/queries/meetings/filters/type_filter.rb new file mode 100644 index 000000000000..a409647be091 --- /dev/null +++ b/modules/meeting/app/models/queries/meetings/filters/type_filter.rb @@ -0,0 +1,58 @@ +#-- 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. +#++ + +class Queries::Meetings::Filters::TypeFilter < Queries::Meetings::Filters::MeetingFilter + def allowed_values + allowed = [ + [I18n.t("meeting.types.classic"), "Meeting"], + [I18n.t("meeting.types.structured"), "DynamicMeeting"] + ] + + if OpenProject::FeatureDecisions.recurring_meetings_active? + allowed + [[I18n.t("meeting.types.recurring"), "RecurringMeeting"]] + else + allowed + end + end + + def type + :list + end + + def self.key + :type + end + + def apply_to(query_scope) + if operator == "=" + query_scope.where(type: values) + else + query_scope.where.not(type: values) + end + end +end diff --git a/modules/meeting/config/locales/en.yml b/modules/meeting/config/locales/en.yml index b33596fd4cdb..c7cd3c72ebca 100644 --- a/modules/meeting/config/locales/en.yml +++ b/modules/meeting/config/locales/en.yml @@ -169,6 +169,7 @@ en: classic: "Classic" classic_text: "Organize your meeting in a formattable text agenda and protocol." structured: "One-time" + recurring: "Meeting series" structured_text: "Organize your meeting as a list of agenda items, optionally linking them to a work package." structured_text_copy: "Copying a meeting will currently not copy the associated meeting agenda items, just the details" copied: "Copied from Meeting #%{id}" From 006002ea1a3a9b1d4416f126431d0f7607513fca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 2 Oct 2024 21:25:09 +0200 Subject: [PATCH 007/129] Start primerized create form --- .../app/forms/recurring_meeting/title_form.rb | 8 ----- .../views/recurring_meetings/_form.html.erb | 36 ++++++++++++++++++- 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/modules/meeting/app/forms/recurring_meeting/title_form.rb b/modules/meeting/app/forms/recurring_meeting/title_form.rb index 083003682cf7..8b5ff5ce0d8c 100644 --- a/modules/meeting/app/forms/recurring_meeting/title_form.rb +++ b/modules/meeting/app/forms/recurring_meeting/title_form.rb @@ -30,14 +30,6 @@ class RecurringMeeting::TitleForm < ApplicationForm include OpenProject::StaticRouting::UrlHelpers form do |form| - form.text_field( - name: :title, - input_width: :medium, - placeholder: RecurringMeeting.human_attribute_name(:title), - label: RecurringMeeting.human_attribute_name(:title), - required: true, - autofocus: true - ) end def initialize(meeting:) diff --git a/modules/meeting/app/views/recurring_meetings/_form.html.erb b/modules/meeting/app/views/recurring_meetings/_form.html.erb index 8b90537dac52..d3c9ddbc07c8 100644 --- a/modules/meeting/app/views/recurring_meetings/_form.html.erb +++ b/modules/meeting/app/views/recurring_meetings/_form.html.erb @@ -29,6 +29,40 @@ See COPYRIGHT and LICENSE files for more details. <%= error_messages_for 'recurring_meeting' %> -<%= render(RecurringMeeting::TitleForm.new(f, meeting: @meeting)) %> +<%= + primer_form_with(url: "/foo") do |form_builder| + render_inline_form(form_builder) do |form| + form.text_field( + name: :title, + input_width: :medium, + placeholder: RecurringMeeting.human_attribute_name(:title), + label: RecurringMeeting.human_attribute_name(:title), + required: true, + autofocus: true + ) + + form.group(layout: :horizontal) do |date_form| + date_form.text_field( + name: :start_date, + full_width: false, + input_width: :medium, + placeholder: RecurringMeeting.human_attribute_name(:start_date), + label: RecurringMeeting.human_attribute_name(:start_date), + required: true + ) + + date_form.text_field( + type: :time, + name: :start_time, + full_width: false, + input_width: :medium, + placeholder: RecurringMeeting.human_attribute_name(:start_time), + label: RecurringMeeting.human_attribute_name(:start_time), + required: true + ) + end + end + end +%> <%= render(RecurringMeeting::ScheduleForm.new(f, meeting: @meeting)) %> From c11e5d4db4789e3c54989a9345b8cc1d547cdcf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Sun, 10 Nov 2024 21:25:29 +0100 Subject: [PATCH 008/129] Add recurring meetings to dropdown and dialog --- .../meetings/index/dialog_component.html.erb | 5 ++-- .../meetings/index/dialog_component.rb | 13 +++++++--- .../meetings/index/form_component.html.erb | 4 +-- .../meetings/index/form_component.rb | 4 +-- .../index_sub_header_component.html.erb | 18 +++++++++---- .../app/controllers/meetings_controller.rb | 25 ++++++++++++++++--- modules/meeting/app/menus/meetings/menu.rb | 8 ------ modules/meeting/config/locales/en.yml | 3 ++- modules/meeting/config/routes.rb | 6 ++++- 9 files changed, 58 insertions(+), 28 deletions(-) diff --git a/modules/meeting/app/components/meetings/index/dialog_component.html.erb b/modules/meeting/app/components/meetings/index/dialog_component.html.erb index d66c3ab90b5b..24d5eff880fc 100644 --- a/modules/meeting/app/components/meetings/index/dialog_component.html.erb +++ b/modules/meeting/app/components/meetings/index/dialog_component.html.erb @@ -1,10 +1,11 @@ <%= render(Primer::Alpha::Dialog.new( - id: "new-meeting-dialog", title: title, + id: "new-meeting-dialog", + title:, size: :medium_portrait, data: { 'keep-open-on-submit': true } )) do |dialog| dialog.with_header(variant: :large) - render(Meetings::Index::FormComponent.new(meeting: @meeting, project: @project, type: @type)) + render(Meetings::Index::FormComponent.new(meeting: @meeting, project: @project, copy_from: @copy_from)) end %> diff --git a/modules/meeting/app/components/meetings/index/dialog_component.rb b/modules/meeting/app/components/meetings/index/dialog_component.rb index 1b8edb028109..0bd67c4dd2b7 100644 --- a/modules/meeting/app/components/meetings/index/dialog_component.rb +++ b/modules/meeting/app/components/meetings/index/dialog_component.rb @@ -33,12 +33,12 @@ class Index::DialogComponent < ApplicationComponent include OpTurbo::Streamable include OpPrimer::ComponentHelpers - def initialize(meeting:, project:, type:) + def initialize(meeting:, project:, copy_from: nil) super @meeting = meeting @project = project - @type = type + @copy_from = copy_from end private @@ -52,7 +52,14 @@ def render? end def title - @type == :new ? I18n.t("label_meeting_new_one_time") : "Copy meeting" + return I18n.t(:label_meeting_copy) if @copy_from + + case @meeting + when RecurringMeeting + I18n.t("label_meeting_new_recurring") + else + I18n.t("label_meeting_new_one_time") + end end end end diff --git a/modules/meeting/app/components/meetings/index/form_component.html.erb b/modules/meeting/app/components/meetings/index/form_component.html.erb index c0c6f358811c..5c4210a190da 100644 --- a/modules/meeting/app/components/meetings/index/form_component.html.erb +++ b/modules/meeting/app/components/meetings/index/form_component.html.erb @@ -41,9 +41,9 @@ render(Meeting::Type.new(f)) end - unless @type == :new + if @copy_from modal_body.with_row do - render(Meeting::CopiedFrom.new(f, id: @type.id)) + render(Meeting::CopiedFrom.new(f, id: @copy_from.id)) end modal_body.with_row(mt: 3) do diff --git a/modules/meeting/app/components/meetings/index/form_component.rb b/modules/meeting/app/components/meetings/index/form_component.rb index 8f277a1024f0..1a3eb95da7d3 100644 --- a/modules/meeting/app/components/meetings/index/form_component.rb +++ b/modules/meeting/app/components/meetings/index/form_component.rb @@ -32,12 +32,12 @@ class Index::FormComponent < ApplicationComponent include OpTurbo::Streamable include OpPrimer::ComponentHelpers - def initialize(meeting:, project:, type:) + def initialize(meeting:, project:, copy_from: nil) super @meeting = meeting @project = project - @type = type + @copy_from = copy_from end private diff --git a/modules/meeting/app/components/meetings/index_sub_header_component.html.erb b/modules/meeting/app/components/meetings/index_sub_header_component.html.erb index 30d5ea1f23e3..f942d9b21ad6 100644 --- a/modules/meeting/app/components/meetings/index_sub_header_component.html.erb +++ b/modules/meeting/app/components/meetings/index_sub_header_component.html.erb @@ -23,15 +23,23 @@ I18n.t(:label_meeting) end - menu.with_item(label: I18n.t("meeting.types.classic"), + menu.with_item(label: I18n.t("meeting.types.structured"), tag: :a, - href: dynamic_path + href: new_dialog_meetings_path(project_id: @project&.id, type: :structured), + content_arguments: { data: { controller: "async-dialog" }} ) - menu.with_item(label: I18n.t("meeting.types.structured"), + if OpenProject::FeatureDecisions.recurring_meetings_active? + menu.with_item(label: I18n.t("meeting.types.recurring"), + tag: :a, + href: new_dialog_recurring_meetings_path(project_id: @project&.id, type: :recurring), + content_arguments: { data: { controller: "async-dialog" }} + ) + end + + menu.with_item(label: I18n.t("meeting.types.classic"), tag: :a, - href: new_dialog_meetings_path(project_id: @project&.id), - content_arguments: { data: { controller: "async-dialog" }} + href: dynamic_path ) end end diff --git a/modules/meeting/app/controllers/meetings_controller.rb b/modules/meeting/app/controllers/meetings_controller.rb index 7a1f8801e56f..6f879d97443b 100644 --- a/modules/meeting/app/controllers/meetings_controller.rb +++ b/modules/meeting/app/controllers/meetings_controller.rb @@ -127,7 +127,7 @@ def create # rubocop:disable Metrics/AbcSize component: Meetings::Index::FormComponent.new( meeting: @meeting, project: @project, - type: @copy_from || :new + copy_from: @copy_from ), status: :bad_request ) @@ -139,7 +139,10 @@ def create # rubocop:disable Metrics/AbcSize end def new_dialog - respond_with_dialog Meetings::Index::DialogComponent.new(meeting: @meeting, project: @project, type: :new) + respond_with_dialog Meetings::Index::DialogComponent.new( + meeting: @meeting, + project: @project, + ) end def new; end @@ -161,7 +164,11 @@ def copy end format.turbo_stream do - respond_with_dialog Meetings::Index::DialogComponent.new(meeting: @meeting, project: @project, type: copy_from) + respond_with_dialog Meetings::Index::DialogComponent.new( + meeting: @meeting, + project: @project, + copy_from: + ) end end end @@ -329,11 +336,21 @@ def load_meetings(query) end def build_meeting - @meeting = Meeting.new + @meeting = meeting_class.new @meeting.project = @project @meeting.author = User.current end + def meeting_class + case params[:type] + when "recurring" + RecurringMeeting + when "structured" + StructuredMeeting + else + Meeting + end + def global_upcoming_meetings projects = Project.allowed_in_project(User.current, :view_meetings) diff --git a/modules/meeting/app/menus/meetings/menu.rb b/modules/meeting/app/menus/meetings/menu.rb index 54cbfa9e31a9..ac8866daef9e 100644 --- a/modules/meeting/app/menus/meetings/menu.rb +++ b/modules/meeting/app/menus/meetings/menu.rb @@ -47,17 +47,9 @@ def top_level_menu_items query_params: { filters: upcoming_filter, sort: "start_time" }), menu_item(title: I18n.t(:label_past_meetings), query_params: { filters: past_filter, sort: "start_time:desc" }), - recurring_menu_item ].compact end - def recurring_menu_item - return unless OpenProject::FeatureDecisions.recurring_meetings_active? - - menu_item(title: I18n.t(:label_recurring_meeting_plural), - query_params: { filters: recurring_meeting_type_filter, sort: "start_time:desc" }) - end - def involvement_sidebar_menu_items [ menu_item(title: I18n.t(:label_upcoming_invitations)), diff --git a/modules/meeting/config/locales/en.yml b/modules/meeting/config/locales/en.yml index c7cd3c72ebca..099fa80a6fa0 100644 --- a/modules/meeting/config/locales/en.yml +++ b/modules/meeting/config/locales/en.yml @@ -106,6 +106,7 @@ en: label_meeting_plural: "Meetings" label_meeting_new: "New Meeting" label_meeting_new_one_time: "New one-time meeting" + label_meeting_new_recurring: "New recurring meeting" label_meeting_create: "Create meeting" label_meeting_copy: "Copy meeting" label_meeting_edit: "Edit Meeting" @@ -169,7 +170,7 @@ en: classic: "Classic" classic_text: "Organize your meeting in a formattable text agenda and protocol." structured: "One-time" - recurring: "Meeting series" + recurring: "Recurring" structured_text: "Organize your meeting as a list of agenda items, optionally linking them to a work package." structured_text_copy: "Copying a meeting will currently not copy the associated meeting agenda items, just the details" copied: "Copied from Meeting #%{id}" diff --git a/modules/meeting/config/routes.rb b/modules/meeting/config/routes.rb index b73b6a296fa8..4ee399085f6f 100644 --- a/modules/meeting/config/routes.rb +++ b/modules/meeting/config/routes.rb @@ -56,7 +56,11 @@ resource :menu, only: %[show] end - resources :recurring_meetings, only: %i[index new create show] + resources :recurring_meetings, only: %i[index new create show] do + collection do + get :new_dialog + end + end resources :meetings do get :new_dialog, on: :collection From d02b49bba295277e5b1e5d4585e5d78b42cdeeca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Sun, 10 Nov 2024 21:50:40 +0100 Subject: [PATCH 009/129] Add schedule --- .../meetings/index/form_component.html.erb | 6 +++ .../index_sub_header_component.html.erb | 2 +- .../app/controllers/meetings_controller.rb | 24 +++------ .../app/forms/meeting/recurring_frequency.rb | 50 +++++++++++++++++++ modules/meeting/app/forms/meeting/type.rb | 2 +- .../meeting/app/models/recurring_meeting.rb | 38 +++++++++++++- modules/meeting/config/locales/en.yml | 11 ++++ ...0240426073948_create_recurring_meetings.rb | 6 ++- 8 files changed, 118 insertions(+), 21 deletions(-) create mode 100644 modules/meeting/app/forms/meeting/recurring_frequency.rb diff --git a/modules/meeting/app/components/meetings/index/form_component.html.erb b/modules/meeting/app/components/meetings/index/form_component.html.erb index 5c4210a190da..46f897c05625 100644 --- a/modules/meeting/app/components/meetings/index/form_component.html.erb +++ b/modules/meeting/app/components/meetings/index/form_component.html.erb @@ -41,6 +41,12 @@ render(Meeting::Type.new(f)) end + if @meeting.is_a?(RecurringMeeting) + modal_body.with_row(mt: 3) do + render(Meeting::RecurringFrequency.new(f)) + end + end + if @copy_from modal_body.with_row do render(Meeting::CopiedFrom.new(f, id: @copy_from.id)) diff --git a/modules/meeting/app/components/meetings/index_sub_header_component.html.erb b/modules/meeting/app/components/meetings/index_sub_header_component.html.erb index f942d9b21ad6..d53adc2338ec 100644 --- a/modules/meeting/app/components/meetings/index_sub_header_component.html.erb +++ b/modules/meeting/app/components/meetings/index_sub_header_component.html.erb @@ -32,7 +32,7 @@ if OpenProject::FeatureDecisions.recurring_meetings_active? menu.with_item(label: I18n.t("meeting.types.recurring"), tag: :a, - href: new_dialog_recurring_meetings_path(project_id: @project&.id, type: :recurring), + href: new_dialog_meetings_path(project_id: @project&.id, type: :recurring), content_arguments: { data: { controller: "async-dialog" }} ) end diff --git a/modules/meeting/app/controllers/meetings_controller.rb b/modules/meeting/app/controllers/meetings_controller.rb index 6f879d97443b..ee8732cdf1f2 100644 --- a/modules/meeting/app/controllers/meetings_controller.rb +++ b/modules/meeting/app/controllers/meetings_controller.rb @@ -342,13 +342,14 @@ def build_meeting end def meeting_class - case params[:type] - when "recurring" - RecurringMeeting - when "structured" - StructuredMeeting - else - Meeting + case params[:type] + when "recurring" + RecurringMeeting + when "structured" + StructuredMeeting + else + Meeting + end end def global_upcoming_meetings @@ -400,15 +401,6 @@ def structured_meeting_params end end - def meeting_type(given_type) - case given_type - when "dynamic" - "StructuredMeeting" - else - "Meeting" - end - end - def verify_activities_module_activated render_403 if @project && !@project.module_enabled?("activity") end diff --git a/modules/meeting/app/forms/meeting/recurring_frequency.rb b/modules/meeting/app/forms/meeting/recurring_frequency.rb new file mode 100644 index 000000000000..e3fdcbd5971d --- /dev/null +++ b/modules/meeting/app/forms/meeting/recurring_frequency.rb @@ -0,0 +1,50 @@ +#-- 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. +#++ + +class Meeting::RecurringFrequency < ApplicationForm + form do |meeting_form| + meeting_form.text_field( + name: :title, + required: true, + label: Meeting.human_attribute_name(:title), + placeholder: Meeting.human_attribute_name(:title), + visually_hide_label: false + ) + meeting_form.select_list( + name: :frequency, + label: I18n.t("activerecord.attributes.recurring_meeting.frequency"), + input_width: :medium, + caption: I18n.t("saml.instructions.signature_method", default_option: "RSA SHA-1") + ) do |list| + %i[daily weekly monthly yearly].each do |value| + label = I18n.t(:"recurring_meeting.frequency.#{value}") + list.option(label:, value:) + end + end + end +end diff --git a/modules/meeting/app/forms/meeting/type.rb b/modules/meeting/app/forms/meeting/type.rb index bc8135b7dd3a..880a0b2ab73a 100644 --- a/modules/meeting/app/forms/meeting/type.rb +++ b/modules/meeting/app/forms/meeting/type.rb @@ -28,6 +28,6 @@ class Meeting::Type < ApplicationForm form do |meeting_form| - meeting_form.hidden(name: :type, value: "StructuredMeeting") + meeting_form.hidden(name: :type, value: @builder.object.class.name) end end diff --git a/modules/meeting/app/models/recurring_meeting.rb b/modules/meeting/app/models/recurring_meeting.rb index b2bd3a83c7c6..29fac505f1ed 100644 --- a/modules/meeting/app/models/recurring_meeting.rb +++ b/modules/meeting/app/models/recurring_meeting.rb @@ -1,9 +1,23 @@ class RecurringMeeting < ApplicationRecord - serialize :schedule, coder: IceCube::Schedule - belongs_to :project belongs_to :author, class_name: "User" + validates_presence_of :start_time, :title, :frequency, :end_after + validates_presence_of :end_time, if: -> { end_after_specific_date? } + validates_numericality_of :iterations, if: -> { end_after_iterations? } + + enum frequency: { + daily: 0, + weekly: 1, + monthly: 2, + yearly: 3 + }.freeze, _prefix: true + + enum end_after: { + specific_date: 0, + iterations: 1 + }.freeze, _prefix: true + has_many :meetings, inverse_of: :recurring_meeting scope :visible, ->(*args) { @@ -11,4 +25,24 @@ class RecurringMeeting < ApplicationRecord .references(:projects) .merge(Project.allowed_to(args.first || User.current, :view_meetings)) } + + def schedule + IceCube::Schedule.new(start_time, end_time: end_date).tap do |s| + s.add_recurrence_rule count_rule(frequency_rule) + end + end + + private + + def frequency_rule + IceCube::Rule.public_send(frequency) + end + + def count_rule(rule) + if end_after_iterations? + rule.count(iterations) + else + rule + end + end end diff --git a/modules/meeting/config/locales/en.yml b/modules/meeting/config/locales/en.yml index 099fa80a6fa0..8453c01fbe36 100644 --- a/modules/meeting/config/locales/en.yml +++ b/modules/meeting/config/locales/en.yml @@ -61,6 +61,10 @@ en: presenter: "Presenter" meeting_section: title: "Title" + recurring_meeting: + frequency: "Frequency" + end_after: "End after" + iterations: "Iterations" errors: messages: invalid_time_format: "is not a valid time. Required format: HH:MM" @@ -181,6 +185,13 @@ en: placeholder_title: "New section" empty_text: "Drag items here or create a new one" + recurring_meeting: + frequency: + daily: "Daily" + weekly: "Weekly" + monthly: "Monthly" + yearly: "Yearly" + notice_successful_notification: "Notification sent successfully" notice_timezone_missing: No time zone is set and %{zone} is assumed. To choose your time zone, please click here. notice_meeting_updated: "This page has been updated by someone else. Reload to view changes." diff --git a/modules/meeting/db/migrate/20240426073948_create_recurring_meetings.rb b/modules/meeting/db/migrate/20240426073948_create_recurring_meetings.rb index 87f3f785c9a3..5fb0e5e1a4ef 100644 --- a/modules/meeting/db/migrate/20240426073948_create_recurring_meetings.rb +++ b/modules/meeting/db/migrate/20240426073948_create_recurring_meetings.rb @@ -1,8 +1,12 @@ class CreateRecurringMeetings < ActiveRecord::Migration[7.1] def change create_table :recurring_meetings do |t| + t.datetime :start_time + t.date :end_date, null: true t.text :title - t.text :schedule + t.integer :frequency, default: 0, null: false + t.integer :end_after, default: 0, null: false + t.integer :iterations, null: true t.belongs_to :project, foreign_key: true, index: true t.belongs_to :author, foreign_key: { to_table: :users } From b3985d1f3104f648f8a561305fccf860a25df102 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Mon, 11 Nov 2024 11:12:24 +0100 Subject: [PATCH 010/129] Add frequency/iterations form --- .../meetings/index/form_component.html.erb | 31 ++++- .../app/forms/recurring_meeting/end_after.rb | 42 ++++++ .../frequency.rb} | 13 +- .../{title_form.rb => iterations.rb} | 18 ++- .../forms/recurring_meeting/schedule_form.rb | 120 ------------------ .../forms/recurring_meeting/specific_date.rb | 46 +++++++ modules/meeting/config/locales/en.yml | 5 +- script/anonymize-sql-dump | 3 +- 8 files changed, 129 insertions(+), 149 deletions(-) create mode 100644 modules/meeting/app/forms/recurring_meeting/end_after.rb rename modules/meeting/app/forms/{meeting/recurring_frequency.rb => recurring_meeting/frequency.rb} (78%) rename modules/meeting/app/forms/recurring_meeting/{title_form.rb => iterations.rb} (81%) delete mode 100644 modules/meeting/app/forms/recurring_meeting/schedule_form.rb create mode 100644 modules/meeting/app/forms/recurring_meeting/specific_date.rb diff --git a/modules/meeting/app/components/meetings/index/form_component.html.erb b/modules/meeting/app/components/meetings/index/form_component.html.erb index 46f897c05625..54f727cf89c3 100644 --- a/modules/meeting/app/components/meetings/index/form_component.html.erb +++ b/modules/meeting/app/components/meetings/index/form_component.html.erb @@ -4,7 +4,10 @@ scope: :meeting, model: @meeting, method: :post, - data: { turbo: true }, + data: { + turbo: true, + controller: "show-when-value-selected" + }, html: { :id => 'meeting-form' }, url: {:controller => '/meetings', :action => 'create', :project_id => @project} ) do |f| @@ -37,14 +40,30 @@ render(Meeting::Location.new(f)) end - modal_body.with_row do - render(Meeting::Type.new(f)) - end - if @meeting.is_a?(RecurringMeeting) modal_body.with_row(mt: 3) do - render(Meeting::RecurringFrequency.new(f)) + render(RecurringMeeting::Frequency.new(f)) end + + modal_body.with_row(mt: 3) do + render(RecurringMeeting::EndAfter.new(f)) + end + + modal_body.with_row(mt: 3, + data: { value: "specific_date", "show-when-value-selected-target": "effect" } + ) do + render(RecurringMeeting::SpecificDate.new(f)) + end + + modal_body.with_row(mt: 3, + hidden: true, + data: { value: "iterations", "show-when-value-selected-target": "effect" }) do + render(RecurringMeeting::Iterations.new(f)) + end + end + + modal_body.with_row do + render(Meeting::Type.new(f)) end if @copy_from diff --git a/modules/meeting/app/forms/recurring_meeting/end_after.rb b/modules/meeting/app/forms/recurring_meeting/end_after.rb new file mode 100644 index 000000000000..9442cc6b7727 --- /dev/null +++ b/modules/meeting/app/forms/recurring_meeting/end_after.rb @@ -0,0 +1,42 @@ +#-- 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. +#++ + +class RecurringMeeting::EndAfter < ApplicationForm + form do |meeting_form| + meeting_form.select_list( + name: "end_after", + label: I18n.t("activerecord.attributes.recurring_meeting.end_after"), + data: { + "show-when-value-selected-target": "cause" + } + ) do |list| + list.option(value: "specific_date", label: I18n.t("recurring_meeting.end_after.specific_date")) + list.option(value: "iterations", label: I18n.t("recurring_meeting.end_after.iterations")) + end + end +end diff --git a/modules/meeting/app/forms/meeting/recurring_frequency.rb b/modules/meeting/app/forms/recurring_meeting/frequency.rb similarity index 78% rename from modules/meeting/app/forms/meeting/recurring_frequency.rb rename to modules/meeting/app/forms/recurring_meeting/frequency.rb index e3fdcbd5971d..a960efc1257a 100644 --- a/modules/meeting/app/forms/meeting/recurring_frequency.rb +++ b/modules/meeting/app/forms/recurring_meeting/frequency.rb @@ -26,20 +26,11 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class Meeting::RecurringFrequency < ApplicationForm +class RecurringMeeting::Frequency < ApplicationForm form do |meeting_form| - meeting_form.text_field( - name: :title, - required: true, - label: Meeting.human_attribute_name(:title), - placeholder: Meeting.human_attribute_name(:title), - visually_hide_label: false - ) meeting_form.select_list( - name: :frequency, + name: "frequency", label: I18n.t("activerecord.attributes.recurring_meeting.frequency"), - input_width: :medium, - caption: I18n.t("saml.instructions.signature_method", default_option: "RSA SHA-1") ) do |list| %i[daily weekly monthly yearly].each do |value| label = I18n.t(:"recurring_meeting.frequency.#{value}") diff --git a/modules/meeting/app/forms/recurring_meeting/title_form.rb b/modules/meeting/app/forms/recurring_meeting/iterations.rb similarity index 81% rename from modules/meeting/app/forms/recurring_meeting/title_form.rb rename to modules/meeting/app/forms/recurring_meeting/iterations.rb index 8b5ff5ce0d8c..a9f1feb2c5af 100644 --- a/modules/meeting/app/forms/recurring_meeting/title_form.rb +++ b/modules/meeting/app/forms/recurring_meeting/iterations.rb @@ -1,6 +1,6 @@ #-- copyright # OpenProject is an open source project management software. -# Copyright (C) 2012-2024 the OpenProject GmbH +# 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. @@ -26,14 +26,12 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class RecurringMeeting::TitleForm < ApplicationForm - include OpenProject::StaticRouting::UrlHelpers - - form do |form| - end - - def initialize(meeting:) - super() - @meeting = meeting +class RecurringMeeting::Iterations < ApplicationForm + form do |meeting_form| + meeting_form.text_field( + name: :iterations, + type: :number, + label: I18n.t("activerecord.attributes.recurring_meeting.iterations") + ) end end diff --git a/modules/meeting/app/forms/recurring_meeting/schedule_form.rb b/modules/meeting/app/forms/recurring_meeting/schedule_form.rb deleted file mode 100644 index dd7b19de0a13..000000000000 --- a/modules/meeting/app/forms/recurring_meeting/schedule_form.rb +++ /dev/null @@ -1,120 +0,0 @@ -#-- copyright -# OpenProject is an open source project management software. -# Copyright (C) 2012-2024 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. -#++ - -class RecurringMeeting::ScheduleForm < ApplicationForm - include OpenProject::StaticRouting::UrlHelpers - - form do |form| - form.select_list( - name: :recurrence, - input_width: :medium, - label: RecurringMeeting.human_attribute_name(:recurrence), - required: true, - autofocus: true - ) do |list| - list.option(label: "Daily", value: "daily") - list.option(label: "Daily on workdays", value: "workdays") - list.option(label: "Weekly", value: "weekly") - list.option(label: "Monthly", value: "monthly") - end - - form.check_box_group(label: "Days", layout: :horizontal, visually_hide_label: true) do |check_group| - check_group.check_box( - name: "monday", - label: "Monday" - ) - check_group.check_box( - name: "tuesday", - label: "Tuesday" - ) - check_group.check_box( - name: "wednesday", - label: "Wednesaday" - ) - check_group.check_box( - name: "thursday", - label: "Thursday" - ) - check_group.check_box( - name: "friday", - label: "Friday" - ) - check_group.check_box( - name: "saturday", - label: "Saturday" - ) - check_group.check_box( - name: "sunday", - label: "Sunday" - ) - end - - form.text_field( - name: :interval, - input_width: :medium, - label: RecurringMeeting.human_attribute_name(:interval), - type: :number, - step: 1 - ) - - form.select_list( - name: :end, - input_width: :medium, - label: RecurringMeeting.human_attribute_name(:ends), - required: true, - autofocus: true - ) do |list| - list.option(label: "Never", value: "never") - list.option(label: "After a number of occurrences", value: "after") - list.option(label: "On a specific date", value: "date") - end - - form.text_field( - name: :count, - input_width: :medium, - label: RecurringMeeting.human_attribute_name(:count), - type: :number, - step: 1 - ) - - # form.date_select( - # :start_date, - # label: RecurringMeeting.human_attribute_name(:start_date) - # ) - - # form.date_select( - # :end_date, - # label: RecurringMeeting.human_attribute_name(:end_date) - # ) - end - - def initialize(meeting:) - super() - @meeting = meeting - end -end diff --git a/modules/meeting/app/forms/recurring_meeting/specific_date.rb b/modules/meeting/app/forms/recurring_meeting/specific_date.rb new file mode 100644 index 000000000000..d905d22fdef0 --- /dev/null +++ b/modules/meeting/app/forms/recurring_meeting/specific_date.rb @@ -0,0 +1,46 @@ +#-- 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. +#++ + +class RecurringMeeting::SpecificDate < ApplicationForm + form do |meeting_form| + meeting_form.text_field( + name: :end_date, + type: "date", + value: @initial_value, + placeholder: Meeting.human_attribute_name(:end_date), + label: Meeting.human_attribute_name(:end_date), + leading_visual: { icon: :calendar }, + required: false, + autofocus: false + ) + end + + def initialize(initial_value: 1.year.from_now.strftime("%Y-%m-%d")) + @initial_value = initial_value + end +end diff --git a/modules/meeting/config/locales/en.yml b/modules/meeting/config/locales/en.yml index 8453c01fbe36..faba581ee5c5 100644 --- a/modules/meeting/config/locales/en.yml +++ b/modules/meeting/config/locales/en.yml @@ -64,7 +64,7 @@ en: recurring_meeting: frequency: "Frequency" end_after: "End after" - iterations: "Iterations" + iterations: "Occurrences" errors: messages: invalid_time_format: "is not a valid time. Required format: HH:MM" @@ -191,6 +191,9 @@ en: weekly: "Weekly" monthly: "Monthly" yearly: "Yearly" + end_after: + specific_date: "A specific date" + iterations: "A number of occurrences" notice_successful_notification: "Notification sent successfully" notice_timezone_missing: No time zone is set and %{zone} is assumed. To choose your time zone, please click here. diff --git a/script/anonymize-sql-dump b/script/anonymize-sql-dump index fc1c6260fc0f..7ae83bbbdc0a 100755 --- a/script/anonymize-sql-dump +++ b/script/anonymize-sql-dump @@ -139,7 +139,8 @@ FOR table_name, column_name IN ( 'role_permissions', 'enabled_modules', 'two_factor_authentication_devices', - 'tokens' + 'tokens', + 'job_statuses' ) AND information_schema.columns.column_name NOT LIKE '%type%' AND NOT (information_schema.columns.table_name = 'grids' AND information_schema.columns.column_name = 'options') From 367b5a3b7b34222f5987a4a45c6bf370f8a2cbc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Mon, 11 Nov 2024 12:00:01 +0100 Subject: [PATCH 011/129] Add services --- .../meetings/index/form_component.html.erb | 16 ++- .../meetings/index/form_component.rb | 12 +- .../recurring_meetings/base_contract.rb | 46 ++++++ .../recurring_meetings/create_contract.rb | 45 ++++++ .../recurring_meetings/update_contract.rb | 39 +++++ .../recurring_meetings_controller.rb | 85 ++++++----- modules/meeting/app/models/meeting.rb | 103 +------------- .../app/models/meeting/virtual_start_time.rb | 134 ++++++++++++++++++ .../meeting/app/models/recurring_meeting.rb | 15 +- .../recurring_meetings/create_service.rb | 60 ++++++++ .../set_attributes_service.rb | 37 +++++ .../recurring_meetings/update_service.rb | 32 +++++ ...0240426073948_create_recurring_meetings.rb | 2 +- 13 files changed, 484 insertions(+), 142 deletions(-) create mode 100644 modules/meeting/app/contracts/recurring_meetings/base_contract.rb create mode 100644 modules/meeting/app/contracts/recurring_meetings/create_contract.rb create mode 100644 modules/meeting/app/contracts/recurring_meetings/update_contract.rb create mode 100644 modules/meeting/app/models/meeting/virtual_start_time.rb create mode 100644 modules/meeting/app/services/recurring_meetings/create_service.rb create mode 100644 modules/meeting/app/services/recurring_meetings/set_attributes_service.rb create mode 100644 modules/meeting/app/services/recurring_meetings/update_service.rb diff --git a/modules/meeting/app/components/meetings/index/form_component.html.erb b/modules/meeting/app/components/meetings/index/form_component.html.erb index 54f727cf89c3..0ffa33325afc 100644 --- a/modules/meeting/app/components/meetings/index/form_component.html.erb +++ b/modules/meeting/app/components/meetings/index/form_component.html.erb @@ -8,12 +8,24 @@ turbo: true, controller: "show-when-value-selected" }, - html: { :id => 'meeting-form' }, - url: {:controller => '/meetings', :action => 'create', :project_id => @project} + html: { + id: 'meeting-form' + }, + url: { + controller: create_controller, + action: 'create', + project_id: @project + } ) do |f| component_collection do |collection| collection.with_component(Primer::Alpha::Dialog::Body.new) do flex_layout(mb: 3) do |modal_body| + if @meeting.errors[:base].present? + modal_body.with_row do + render(Primer::Alpha::Banner.new(mb: 3, icon: :stop, scheme: :danger)) { @meeting.errors[:base].join("\n") } + end + end + if @project.nil? modal_body.with_row(mt: 3) do render(Meeting::ProjectAutocompleter.new(f)) diff --git a/modules/meeting/app/components/meetings/index/form_component.rb b/modules/meeting/app/components/meetings/index/form_component.rb index 1a3eb95da7d3..c020a40ff029 100644 --- a/modules/meeting/app/components/meetings/index/form_component.rb +++ b/modules/meeting/app/components/meetings/index/form_component.rb @@ -42,12 +42,20 @@ def initialize(meeting:, project:, copy_from: nil) private + def create_controller + if @meeting.is_a?(RecurringMeeting) + "/recurring_meetings" + else + "/meetings" + end + end + def start_date_initial_value - format_time_as_date(@meeting.start_time, format: "%Y-%m-%d") + @meeting.start_date.presence || format_time_as_date(@meeting.start_time, format: "%Y-%m-%d") end def start_time_initial_value - format_time(@meeting.start_time, include_date: false, format: "%H:%M") + @meeting.start_time_hour.presence || format_time(@meeting.start_time, include_date: false, format: "%H:%M") end end end diff --git a/modules/meeting/app/contracts/recurring_meetings/base_contract.rb b/modules/meeting/app/contracts/recurring_meetings/base_contract.rb new file mode 100644 index 000000000000..dc476fed2605 --- /dev/null +++ b/modules/meeting/app/contracts/recurring_meetings/base_contract.rb @@ -0,0 +1,46 @@ +#-- 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 RecurringMeetings + class BaseContract < ::ModelContract + def self.model + RecurringMeeting + end + + attribute :title + attribute :author_id + attribute :project_id + attribute :start_time + attribute :start_date + attribute :start_time_hour + attribute :frequency + attribute :end_after + attribute :end_date + attribute :iterations + end +end diff --git a/modules/meeting/app/contracts/recurring_meetings/create_contract.rb b/modules/meeting/app/contracts/recurring_meetings/create_contract.rb new file mode 100644 index 000000000000..ddccd94c364f --- /dev/null +++ b/modules/meeting/app/contracts/recurring_meetings/create_contract.rb @@ -0,0 +1,45 @@ +#-- 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 RecurringMeetings + class CreateContract < BaseContract + validate :user_allowed_to_add + + # Virtual attributes for the form + attribute :duration + attribute :location + + private + + def user_allowed_to_add + unless user.allowed_in_project?(:create_meetings, model.project) + errors.add :base, :error_unauthorized + end + end + end +end diff --git a/modules/meeting/app/contracts/recurring_meetings/update_contract.rb b/modules/meeting/app/contracts/recurring_meetings/update_contract.rb new file mode 100644 index 000000000000..358a4d9b5de6 --- /dev/null +++ b/modules/meeting/app/contracts/recurring_meetings/update_contract.rb @@ -0,0 +1,39 @@ +#-- 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 RecurringMeetings + class UpdateContract < BaseContract + validate :user_allowed_to_edit + + def user_allowed_to_edit + unless user.allowed_in_project?(:edit_meetings, model.project) + errors.add :base, :error_unauthorized + end + end + end +end diff --git a/modules/meeting/app/controllers/recurring_meetings_controller.rb b/modules/meeting/app/controllers/recurring_meetings_controller.rb index a384ba20a227..784893da5fef 100644 --- a/modules/meeting/app/controllers/recurring_meetings_controller.rb +++ b/modules/meeting/app/controllers/recurring_meetings_controller.rb @@ -1,11 +1,16 @@ class RecurringMeetingsController < ApplicationController include Layout + include OpTurbo::ComponentStream + include OpTurbo::FlashStreamHelper + include OpTurbo::DialogStreamHelper before_action :find_meeting, only: %i[show] before_action :find_optional_project, only: %i[index show new create] before_action :authorize_global, only: %i[index new create] before_action :authorize, only: %i[show] + before_action :convert_params, only: %i[create] + menu_item :meetings def index @@ -24,14 +29,27 @@ def new def show; end def create - @recurring_meeting = RecurringMeeting.new(recurring_meeting_params.merge(project: @project)) - create_schedule(params[:recurring_meeting]) + call = ::RecurringMeetings::CreateService + .new(user: current_user) + .call(@converted_params) - if @recurring_meeting.save - flash[:notice] = t(:notice_successful_create) - redirect_to action: :index + if call.success? + redirect_to status: :see_other, action: :show, id: call.result else - render action: :new + respond_to do |format| + format.turbo_stream do + update_via_turbo_stream( + component: Meetings::Index::FormComponent.new( + meeting: call.result, + project: @project, + copy_from: @copy_from + ), + status: :bad_request + ) + + respond_with_turbo_streams + end + end end end @@ -49,38 +67,35 @@ def find_meeting render_404 end + def convert_params + # We do some preprocessing of `meeting_params` that we will store in this + # instance variable. + @converted_params = recurring_meeting_params.to_h + + @converted_params[:project] = @project + @converted_params[:duration] = @converted_params[:duration].to_hours if @converted_params[:duration].present? + end + def recurring_meeting_params params - .require(:recurring_meeting) - .permit(:title) + .require(:meeting) + .permit(:title, :location, :start_time_hour, :duration, :start_date, + :frequency, :end_after, :end_date, :iterations) end - def create_schedule(params) # rubocop:disable Metrics/AbcSize,Metrics/PerceivedComplexity - interval = params[:interval].to_i - recurrence = params[:recurrence] - ends = params[:end] - - days = [] # make the form pass an array directly - days << :monday if params[:monday].to_i == 1 - days << :tuesday if params[:tuesday].to_i == 1 - days << :wednesday if params[:wednesday].to_i == 1 - days << :thursday if params[:thursday].to_i == 1 - days << :friday if params[:friday].to_i == 1 - days << :saturday if params[:saturday].to_i == 1 - days << :sunday if params[:sunday].to_i == 1 - - schedule = IceCube::Schedule.new - rule = IceCube::Rule - .then { |r| recurrence == "daily" ? r.daily(interval) : r } - .then { |r| recurrence == "workdays" ? r.weekly(interval).day(:monday, :tuesday, :wednesday, :thursday, :friday) : r } # has to match chosen working days # rubocop:disable Layout/LineLength - .then { |r| recurrence == "weekly" ? r.weekly(interval).day(*days) : r } - .then { |r| recurrence == "monthly" ? r.monthly(interval) : r } - # .then { |r| ends == "never" ? r.until(params[:start_date] + 12.months) : r } # 12 month max or some sort of limit for 'never'? ; start_date needs to be added in # rubocop:disable Layout/LineLength - # .then { |r| ends == "date" ? r.until(params[:end_date]) : r } # end_date needs to be added in - .then { |r| ends == "after" ? r.count(params[:count].to_i) : r } - - schedule.add_recurrence_rule(rule) - - @recurring_meeting.schedule = schedule + def find_copy_from_meeting + copied_from_meeting_id = params[:copied_from_meeting_id] || params[:meeting][:copied_from_meeting_id] + return unless copied_from_meeting_id + + @copy_from = Meeting.visible.find(copied_from_meeting_id) + rescue ActiveRecord::RecordNotFound + render_404 + end + + def structured_meeting_params + if params[:structured_meeting].present? + params + .require(:structured_meeting) + end end end diff --git a/modules/meeting/app/models/meeting.rb b/modules/meeting/app/models/meeting.rb index 75f6378e7aed..344132e455bb 100644 --- a/modules/meeting/app/models/meeting.rb +++ b/modules/meeting/app/models/meeting.rb @@ -27,7 +27,7 @@ #++ class Meeting < ApplicationRecord - include VirtualAttribute + include VirtualStartTime include OpenProject::Journal::AttachmentHelper self.table_name = "meetings" @@ -90,20 +90,8 @@ class Meeting < ApplicationRecord validates_presence_of :title, :project_id, :duration - # We only save start_time as an aggregated value of start_date and hour, - # but still need start_date and _hour for validation purposes - virtual_attribute :start_date do - @start_date - end - virtual_attribute :start_time_hour do - @start_time_hour - end - - validate :validate_date_and_time - - before_save :update_start_time! before_save :add_new_participants_as_watcher - after_initialize :set_initial_values + after_update :send_rescheduling_mail, if: -> { saved_change_to_start_time? || saved_change_to_duration? } enum state: { @@ -125,21 +113,6 @@ def changed_hash OpenProject::Cache::CacheKey.expand(parts) end - ## - # Return the computed start_time when changed - def start_time - if parse_start_time? - parsed_start_time - else - super - end - end - - def start_time=(value) - super(value&.to_datetime) - update_derived_fields - end - def start_month start_time.month end @@ -252,80 +225,8 @@ def allowed_participants protected - def set_initial_values - # set defaults - # Start date is set to tomorrow at 10 AM (Current users local time) - write_attribute(:start_time, User.current.time_zone.now.at_midnight + 34.hours) if start_time.nil? - self.duration ||= 1 - update_derived_fields - end - - def update_derived_fields - @start_date = format_time_as_date(start_time, format: "%Y-%m-%d") - @start_time_hour = format_time(start_time, include_date: false, format: "%H:%M") - end - private - ## - # Validate date and time setters. - # If start_time has been changed, check that value. - # Otherwise start_{date, time_hour} was used, then validate those - def validate_date_and_time - if parse_start_time? - errors.add :start_date, :not_an_iso_date if parsed_start_date.nil? - errors.add :start_time_hour, :invalid_time_format if parsed_start_time_hour.nil? - elsif start_time.nil? - errors.add :start_time, :invalid - end - end - - ## - # Actually sets the aggregated start_time attribute. - def update_start_time! - write_attribute(:start_time, start_time) - end - - ## - # Determines whether new raw values were provided. - def parse_start_time? - changed.intersect?(%w(start_date start_time_hour)) - end - - ## - # Returns the parse result of both start_date and start_time_hour - def parsed_start_time - date = parsed_start_date - time = parsed_start_time_hour - - return if date.nil? || time.nil? - - Time.zone.local( - date.year, - date.month, - date.day, - time.hour, - time.min - ) - end - - ## - # Enforce ISO 8601 date parsing for the given input string - # This avoids weird parsing of dates due to malformed input. - def parsed_start_date - Date.iso8601(@start_date) - rescue ArgumentError - nil - end - - ## - # Enforce HH::MM time parsing for the given input string - def parsed_start_time_hour - Time.strptime(@start_time_hour, "%H:%M") - rescue ArgumentError - nil - end - def add_new_participants_as_watcher participants.select(&:new_record?).each do |p| add_watcher(p.user) diff --git a/modules/meeting/app/models/meeting/virtual_start_time.rb b/modules/meeting/app/models/meeting/virtual_start_time.rb new file mode 100644 index 000000000000..cebfab7fde4a --- /dev/null +++ b/modules/meeting/app/models/meeting/virtual_start_time.rb @@ -0,0 +1,134 @@ +#-- 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 Meeting::VirtualStartTime + extend ActiveSupport::Concern + + included do + include VirtualAttribute + + # We only save start_time as an aggregated value of start_date and hour, + # but still need start_date and _hour for validation purposes + virtual_attribute :start_date do + @start_date + end + virtual_attribute :start_time_hour do + @start_time_hour + end + + validate :validate_date_and_time + after_initialize :set_initial_values + end + + ## + # Actually sets the aggregated start_time attribute. + def update_start_time! + write_attribute(:start_time, start_time) + end + + ## + # Validate date and time setters. + # If start_time has been changed, check that value. + # Otherwise start_{date, time_hour} was used, then validate those + def validate_date_and_time + if parse_start_time? + errors.add :start_date, :not_an_iso_date if parsed_start_date.nil? + errors.add :start_time_hour, :invalid_time_format if parsed_start_time_hour.nil? + elsif start_time.nil? + errors.add :start_time, :invalid + end + end + + ## + # Determines whether new raw values were provided. + def parse_start_time? + changed.intersect?(%w(start_date start_time_hour)) + end + + ## + # Returns the parse result of both start_date and start_time_hour + def parsed_start_time + date = parsed_start_date + time = parsed_start_time_hour + + return if date.nil? || time.nil? + + Time.zone.local( + date.year, + date.month, + date.day, + time.hour, + time.min + ) + end + + def set_initial_values + # set defaults + # Start date is set to tomorrow at 10 AM (Current users local time) + write_attribute(:start_time, User.current.time_zone.now.at_midnight + 34.hours) if start_time.nil? + self.duration ||= 1 + update_derived_fields + end + + ## + # Return the computed start_time when changed + def start_time + if parse_start_time? + parsed_start_time + else + super + end + end + + def start_time=(value) + super(value&.to_datetime) + update_derived_fields + end + + def update_derived_fields + @start_date = format_time_as_date(start_time, format: "%Y-%m-%d") + @start_time_hour = format_time(start_time, include_date: false, format: "%H:%M") + end + + ## + # Enforce ISO 8601 date parsing for the given input string + # This avoids weird parsing of dates due to malformed input. + def parsed_start_date + Date.iso8601(@start_date) + rescue ArgumentError + nil + end + + ## + # Enforce HH::MM time parsing for the given input string + def parsed_start_time_hour + Time.strptime(@start_time_hour, "%H:%M") + rescue ArgumentError + nil + end +end diff --git a/modules/meeting/app/models/recurring_meeting.rb b/modules/meeting/app/models/recurring_meeting.rb index 29fac505f1ed..2b04858840bb 100644 --- a/modules/meeting/app/models/recurring_meeting.rb +++ b/modules/meeting/app/models/recurring_meeting.rb @@ -1,9 +1,12 @@ class RecurringMeeting < ApplicationRecord + include ::Meeting::VirtualStartTime belongs_to :project belongs_to :author, class_name: "User" + before_save :update_start_time! + validates_presence_of :start_time, :title, :frequency, :end_after - validates_presence_of :end_time, if: -> { end_after_specific_date? } + validates_presence_of :end_date, if: -> { end_after_specific_date? } validates_numericality_of :iterations, if: -> { end_after_iterations? } enum frequency: { @@ -26,6 +29,16 @@ class RecurringMeeting < ApplicationRecord .merge(Project.allowed_to(args.first || User.current, :view_meetings)) } + # Keep location and duration as a virtual attribute + # so it can be passed to the template on save + virtual_attribute :location do + nil + + end + virtual_attribute :duration do + nil + end + def schedule IceCube::Schedule.new(start_time, end_time: end_date).tap do |s| s.add_recurrence_rule count_rule(frequency_rule) diff --git a/modules/meeting/app/services/recurring_meetings/create_service.rb b/modules/meeting/app/services/recurring_meetings/create_service.rb new file mode 100644 index 000000000000..85d280e3634f --- /dev/null +++ b/modules/meeting/app/services/recurring_meetings/create_service.rb @@ -0,0 +1,60 @@ +#-- 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 RecurringMeetings + class CreateService < ::BaseServices::Create + protected + + attr_accessor :template_params + + def before_perform(params, _) + @template_params = extract_template_params(params) + + super + end + + def after_perform(call) + return call unless call.success? + + template = StructuredMeeting.new(@template_params) + template.template = true + template.recurring_meeting = call.result + + unless template.save + call.result = false + call.errors.merge!(template.errors) + end + + call + end + + def extract_template_params(params) + params.slice(:start_date, :start_time_hour, :title, :location, :duration) + end + end +end diff --git a/modules/meeting/app/services/recurring_meetings/set_attributes_service.rb b/modules/meeting/app/services/recurring_meetings/set_attributes_service.rb new file mode 100644 index 000000000000..d2d764f60c18 --- /dev/null +++ b/modules/meeting/app/services/recurring_meetings/set_attributes_service.rb @@ -0,0 +1,37 @@ +#-- 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 RecurringMeetings + class SetAttributesService < ::BaseServices::SetAttributes + def set_default_attributes(_params) + model.change_by_system do + model.author = user + end + end + end +end diff --git a/modules/meeting/app/services/recurring_meetings/update_service.rb b/modules/meeting/app/services/recurring_meetings/update_service.rb new file mode 100644 index 000000000000..7f510a39bf4d --- /dev/null +++ b/modules/meeting/app/services/recurring_meetings/update_service.rb @@ -0,0 +1,32 @@ +#-- 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 RecurringMeetings + class UpdateService < ::BaseServices::Update + end +end diff --git a/modules/meeting/db/migrate/20240426073948_create_recurring_meetings.rb b/modules/meeting/db/migrate/20240426073948_create_recurring_meetings.rb index 5fb0e5e1a4ef..6cbe26431257 100644 --- a/modules/meeting/db/migrate/20240426073948_create_recurring_meetings.rb +++ b/modules/meeting/db/migrate/20240426073948_create_recurring_meetings.rb @@ -13,7 +13,7 @@ def change t.timestamps end - add_reference :meetings, :recurring_meeting_id, index: true + add_reference :meetings, :recurring_meeting, index: true add_column :meetings, :template, :boolean, default: false, null: false end end From 115f11094daa64d962d715d39e0a9a158276232a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Mon, 11 Nov 2024 14:19:44 +0100 Subject: [PATCH 012/129] Recurring meetings menu entry --- app/menus/submenu.rb | 4 ++++ modules/meeting/app/menus/meetings/menu.rb | 12 ++++++++++++ .../meeting/app/views/meetings/menus/_menu.html.erb | 8 ++++++-- 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/app/menus/submenu.rb b/app/menus/submenu.rb index a76770c8dfa6..f1653935796e 100644 --- a/app/menus/submenu.rb +++ b/app/menus/submenu.rb @@ -144,4 +144,8 @@ def icon_map def query_path(query_params) raise NotImplementedError end + + def url_helpers + @url_helpers ||= OpenProject::StaticRouting::StaticRouter.new.url_helpers + end end diff --git a/modules/meeting/app/menus/meetings/menu.rb b/modules/meeting/app/menus/meetings/menu.rb index ac8866daef9e..bbcc3c3bd5f0 100644 --- a/modules/meeting/app/menus/meetings/menu.rb +++ b/modules/meeting/app/menus/meetings/menu.rb @@ -47,9 +47,21 @@ def top_level_menu_items query_params: { filters: upcoming_filter, sort: "start_time" }), menu_item(title: I18n.t(:label_past_meetings), query_params: { filters: past_filter, sort: "start_time:desc" }), + recurring_menu_item ].compact end + def recurring_menu_item + return unless OpenProject::FeatureDecisions.recurring_meetings_active? + + href = polymorphic_path([@project, :recurring_meetings]) + OpenProject::Menu::MenuItem.new( + title: I18n.t("label_recurring_meeting_plural"), + href:, + selected: params[:current_href] == href + ) + end + def involvement_sidebar_menu_items [ menu_item(title: I18n.t(:label_upcoming_invitations)), diff --git a/modules/meeting/app/views/meetings/menus/_menu.html.erb b/modules/meeting/app/views/meetings/menus/_menu.html.erb index 74bacf441dd4..c1fc8a539731 100644 --- a/modules/meeting/app/views/meetings/menus/_menu.html.erb +++ b/modules/meeting/app/views/meetings/menus/_menu.html.erb @@ -1,5 +1,9 @@ - <%= turbo_frame_tag "meeting_sidemenu", - src: @project.present? ? menu_project_meetings_path(@project, **params.permit(:filters, :sort)) : meetings_menu_path(**params.permit(:filters, :sort)), +<% request_params = params + .permit(:filters, :sort) + .merge(current_href: request.path) +%> +<%= turbo_frame_tag "meeting_sidemenu", + src: @project.present? ? menu_project_meetings_path(@project, **request_params) : meetings_menu_path(**request_params), target: '_top', data: { turbo: false }, loading: :lazy %> From 2229ae4ef6d7a3eb72ae8f217ee7e9cacc4499cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Mon, 11 Nov 2024 14:34:28 +0100 Subject: [PATCH 013/129] Fix setting project --- .../meeting/app/services/recurring_meetings/create_service.rb | 4 ++-- modules/meeting/config/routes.rb | 3 --- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/modules/meeting/app/services/recurring_meetings/create_service.rb b/modules/meeting/app/services/recurring_meetings/create_service.rb index 85d280e3634f..037afbc56e97 100644 --- a/modules/meeting/app/services/recurring_meetings/create_service.rb +++ b/modules/meeting/app/services/recurring_meetings/create_service.rb @@ -42,12 +42,12 @@ def after_perform(call) return call unless call.success? template = StructuredMeeting.new(@template_params) + template.project = call.result.project template.template = true template.recurring_meeting = call.result unless template.save - call.result = false - call.errors.merge!(template.errors) + call.merge! ServiceResult.failure(result: template, errors: template.errors) end call diff --git a/modules/meeting/config/routes.rb b/modules/meeting/config/routes.rb index 4ee399085f6f..d00f7c4e11ba 100644 --- a/modules/meeting/config/routes.rb +++ b/modules/meeting/config/routes.rb @@ -57,9 +57,6 @@ end resources :recurring_meetings, only: %i[index new create show] do - collection do - get :new_dialog - end end resources :meetings do From 101a52c843634a95808655d51c8fc47ce1ec8a93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Tue, 12 Nov 2024 08:39:31 +0100 Subject: [PATCH 014/129] Template --- .../meeting_agenda_items/list_component.html.erb | 9 +++++++++ .../components/meetings/header_component.html.erb | 6 +++++- .../controllers/recurring_meetings_controller.rb | 6 +++++- .../services/recurring_meetings/create_service.rb | 15 ++++++++++----- modules/meeting/config/locales/en.yml | 6 ++++++ 5 files changed, 35 insertions(+), 7 deletions(-) diff --git a/modules/meeting/app/components/meeting_agenda_items/list_component.html.erb b/modules/meeting/app/components/meeting_agenda_items/list_component.html.erb index a11caa657beb..ade8dfa430f3 100644 --- a/modules/meeting/app/components/meeting_agenda_items/list_component.html.erb +++ b/modules/meeting/app/components/meeting_agenda_items/list_component.html.erb @@ -1,6 +1,15 @@ <%= component_wrapper(data: wrapper_data_attributes) do flex_layout(mb: 3) do |flex| + if @meeting.template? + flex.with_row(mb: 3) do + render Primer::Alpha::Banner.new(scheme: :default, + icon: :info, + dismiss_scheme: :none) do + I18n.t("recurring_meeting.template.banner") + end + end + end flex.with_row(classes: 'dragula-container', id: insert_target_modifier_id, data: { 'allowed-drop-type': 'section' }.merge(drop_target_config) ) do first_and_last = [@meeting.sections.first, @meeting.sections.last] render( diff --git a/modules/meeting/app/components/meetings/header_component.html.erb b/modules/meeting/app/components/meetings/header_component.html.erb index b19e4f945354..540fa30f5719 100644 --- a/modules/meeting/app/components/meetings/header_component.html.erb +++ b/modules/meeting/app/components/meetings/header_component.html.erb @@ -19,7 +19,11 @@ cancel_path: cancel_edit_meeting_path(@meeting), label: Meeting.human_attribute_name(:title), placeholder: Meeting.human_attribute_name(:title),) - @meeting.title + if @meeting.template? + "#{@meeting.title} (#{I18n.t(:label_template)})" + else + @meeting.title + end end header.with_breadcrumbs(breadcrumb_items) header.with_description { render(Meetings::HeaderInfolineComponent.new(@meeting)) } diff --git a/modules/meeting/app/controllers/recurring_meetings_controller.rb b/modules/meeting/app/controllers/recurring_meetings_controller.rb index 784893da5fef..e6f46eb10c4d 100644 --- a/modules/meeting/app/controllers/recurring_meetings_controller.rb +++ b/modules/meeting/app/controllers/recurring_meetings_controller.rb @@ -26,7 +26,11 @@ def new @recurring_meeting = RecurringMeeting.new(project: @project) end - def show; end + def show + respond_to do |format| + format.html + end + end def create call = ::RecurringMeetings::CreateService diff --git a/modules/meeting/app/services/recurring_meetings/create_service.rb b/modules/meeting/app/services/recurring_meetings/create_service.rb index 037afbc56e97..1e88adc34e1a 100644 --- a/modules/meeting/app/services/recurring_meetings/create_service.rb +++ b/modules/meeting/app/services/recurring_meetings/create_service.rb @@ -41,11 +41,7 @@ def before_perform(params, _) def after_perform(call) return call unless call.success? - template = StructuredMeeting.new(@template_params) - template.project = call.result.project - template.template = true - template.recurring_meeting = call.result - + template = create_meeting_template unless template.save call.merge! ServiceResult.failure(result: template, errors: template.errors) end @@ -56,5 +52,14 @@ def after_perform(call) def extract_template_params(params) params.slice(:start_date, :start_time_hour, :title, :location, :duration) end + + def create_meeting_template + StructuredMeeting.new(@template_params).tap do |template| + template.project = call.result.project + template.template = true + template.recurring_meeting = call.result + template.author = user + end + end end end diff --git a/modules/meeting/config/locales/en.yml b/modules/meeting/config/locales/en.yml index faba581ee5c5..1c0dec921e0a 100644 --- a/modules/meeting/config/locales/en.yml +++ b/modules/meeting/config/locales/en.yml @@ -127,6 +127,7 @@ en: label_recurring_meeting: "Recurring meeting" label_recurring_meeting_new: "New recurring meeting" label_recurring_meeting_plural: "Recurring meetings" + label_template: "Template" label_upcoming_meetings: "Upcoming meetings" label_past_meetings: "Past meetings" label_upcoming_meetings_short: "Upcoming" @@ -186,6 +187,11 @@ en: empty_text: "Drag items here or create a new one" recurring_meeting: + template: + banner: > + You are currently editing a template for a recurring meeting. + Every new instance of a metting in this series will use this template. + Changes will not affect past or already created meetings. frequency: daily: "Daily" weekly: "Weekly" From 23e0aefcf7f52ddb16b96fcc52068751e8a473df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Tue, 12 Nov 2024 14:40:50 +0100 Subject: [PATCH 015/129] add bi-weekly --- modules/meeting/app/models/recurring_meeting.rb | 5 +++-- modules/meeting/config/locales/en.yml | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/modules/meeting/app/models/recurring_meeting.rb b/modules/meeting/app/models/recurring_meeting.rb index 2b04858840bb..d53ff458cc0f 100644 --- a/modules/meeting/app/models/recurring_meeting.rb +++ b/modules/meeting/app/models/recurring_meeting.rb @@ -12,8 +12,9 @@ class RecurringMeeting < ApplicationRecord enum frequency: { daily: 0, weekly: 1, - monthly: 2, - yearly: 3 + biweekly: 2, + monthly: 3, + yearly: 4 }.freeze, _prefix: true enum end_after: { diff --git a/modules/meeting/config/locales/en.yml b/modules/meeting/config/locales/en.yml index 1c0dec921e0a..05cfbaa412f2 100644 --- a/modules/meeting/config/locales/en.yml +++ b/modules/meeting/config/locales/en.yml @@ -195,6 +195,7 @@ en: frequency: daily: "Daily" weekly: "Weekly" + biweekly: "Bi-weekly (Every 2 weeks)" monthly: "Monthly" yearly: "Yearly" end_after: From c69210ec93a7f4a93a744d7f35f9b0e1519cef38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 13 Nov 2024 13:30:19 +0100 Subject: [PATCH 016/129] Add template sideinfo --- .../list_component.html.erb | 4 ++- .../side_panel/attachments_component.html.erb | 8 +++++- .../side_panel/details_component.html.erb | 28 +++++++++++++++---- .../participants_component.html.erb | 4 +++ .../meetings/side_panel_component.html.erb | 5 +++- modules/meeting/app/models/meeting.rb | 5 ++++ .../meeting/app/models/recurring_meeting.rb | 9 ++++++ modules/meeting/config/locales/en.yml | 10 +++++-- 8 files changed, 62 insertions(+), 11 deletions(-) diff --git a/modules/meeting/app/components/meeting_agenda_items/list_component.html.erb b/modules/meeting/app/components/meeting_agenda_items/list_component.html.erb index ade8dfa430f3..d418ed0d9533 100644 --- a/modules/meeting/app/components/meeting_agenda_items/list_component.html.erb +++ b/modules/meeting/app/components/meeting_agenda_items/list_component.html.erb @@ -6,7 +6,9 @@ render Primer::Alpha::Banner.new(scheme: :default, icon: :info, dismiss_scheme: :none) do - I18n.t("recurring_meeting.template.banner") + t("recurring_meeting.template.banner_html", + link: link_to(@meeting.recurring_meeting.title, + recurring_meeting_path(@meeting.recurring_meeting))) end end end diff --git a/modules/meeting/app/components/meetings/side_panel/attachments_component.html.erb b/modules/meeting/app/components/meetings/side_panel/attachments_component.html.erb index f4c2fb18794b..480ae708c87d 100644 --- a/modules/meeting/app/components/meetings/side_panel/attachments_component.html.erb +++ b/modules/meeting/app/components/meetings/side_panel/attachments_component.html.erb @@ -2,7 +2,13 @@ component_wrapper do render(Primer::OpenProject::SidePanel::Section.new) do |section| section.with_title { t(:label_attachment_plural) } - section.with_description { I18n.t('meeting.attachments.text') } + section.with_description do + if @meeting.templated? + I18n.t('meeting.attachments.template') + else + I18n.t('meeting.attachments.text') + end + end section.with_counter(count: @meeting.attachments.count) section.with_footer_button( diff --git a/modules/meeting/app/components/meetings/side_panel/details_component.html.erb b/modules/meeting/app/components/meetings/side_panel/details_component.html.erb index 1c55629c1ce8..e8e73b8d8f42 100644 --- a/modules/meeting/app/components/meetings/side_panel/details_component.html.erb +++ b/modules/meeting/app/components/meetings/side_panel/details_component.html.erb @@ -17,10 +17,28 @@ end flex_layout do |details| - details.with_row do - render_meeting_attribute_row(:calendar) do - render(Primer::Beta::Text.new) do - format_date(@meeting.start_time) + if @meeting.template? + details.with_row do + render_meeting_attribute_row(:"git-commit") do + render(Primer::Beta::Text.new) do + @meeting.recurring_meeting.human_frequency + end + end + end + + details.with_row(mt: 2) do + render_meeting_attribute_row(:"git-commit") do + render(Primer::Beta::Text.new) do + @meeting.recurring_meeting.human_date_of_week + end + end + end if @meeting.recurring_meeting.frequency != "daily" + else + details.with_row do + render_meeting_attribute_row(:calendar) do + render(Primer::Beta::Text.new) do + format_date(@meeting.start_time) + end end end end @@ -30,7 +48,7 @@ flex_layout(align_items: :center) do |time| time.with_column do render(Primer::Beta::Text.new) do - "#{format_time(@meeting.start_time, include_date: false)} - #{format_time(@meeting.end_time, include_date:false)}" + "#{format_time(@meeting.start_time, include_date: false)} - #{format_time(@meeting.end_time, include_date: false)}" end end diff --git a/modules/meeting/app/components/meetings/side_panel/participants_component.html.erb b/modules/meeting/app/components/meetings/side_panel/participants_component.html.erb index f487a08ec514..89ea9685416b 100644 --- a/modules/meeting/app/components/meetings/side_panel/participants_component.html.erb +++ b/modules/meeting/app/components/meetings/side_panel/participants_component.html.erb @@ -4,6 +4,10 @@ section.with_title { Meeting.human_attribute_name(:participants) } section.with_counter(count: @meeting.invited_or_attended_participants.count) + if @meeting.templated? + section.with_description { I18n.t('meeting.participants.template') } + end + if @meeting.editable? section.with_action_icon( icon: :gear, diff --git a/modules/meeting/app/components/meetings/side_panel_component.html.erb b/modules/meeting/app/components/meetings/side_panel_component.html.erb index 9239d6131fa6..11cabc8cbe59 100644 --- a/modules/meeting/app/components/meetings/side_panel_component.html.erb +++ b/modules/meeting/app/components/meetings/side_panel_component.html.erb @@ -2,9 +2,12 @@ component_wrapper do render(Primer::OpenProject::SidePanel.new) do |panel| panel.with_section(Meetings::SidePanel::DetailsComponent.new(meeting: @meeting)) - panel.with_section(Meetings::SidePanel::StateComponent.new(meeting: @meeting)) + unless @meeting.template? + panel.with_section(Meetings::SidePanel::StateComponent.new(meeting: @meeting)) + end desktop_grid_row_arguments = { display: [:none, nil, :"table_cell"] } + panel.with_section(Meetings::SidePanel::ParticipantsComponent.new(meeting: @meeting), grid_row_arguments: desktop_grid_row_arguments.merge({classes: "meetings-side-panel--participants-section"})) diff --git a/modules/meeting/app/models/meeting.rb b/modules/meeting/app/models/meeting.rb index 344132e455bb..75ac011d764e 100644 --- a/modules/meeting/app/models/meeting.rb +++ b/modules/meeting/app/models/meeting.rb @@ -35,6 +35,7 @@ class Meeting < ApplicationRecord belongs_to :project belongs_to :author, class_name: "User" belongs_to :recurring_meeting, optional: true + has_one :agenda, dependent: :destroy, class_name: "MeetingAgenda" has_one :minutes, dependent: :destroy, class_name: "MeetingMinutes" has_many :contents, -> { readonly }, class_name: "MeetingContent" @@ -133,6 +134,10 @@ def text agenda.text if agenda.present? end + def templated? + !!template + end + def author=(user) super # Don't add the author as participant if we already have some through nested attributes diff --git a/modules/meeting/app/models/recurring_meeting.rb b/modules/meeting/app/models/recurring_meeting.rb index d53ff458cc0f..1c5add4e6b90 100644 --- a/modules/meeting/app/models/recurring_meeting.rb +++ b/modules/meeting/app/models/recurring_meeting.rb @@ -40,6 +40,15 @@ class RecurringMeeting < ApplicationRecord nil end + def human_frequency + I18n.t("recurring_meeting.frequency.#{frequency}") + end + + def human_date_of_week + day_of_the_week = I18n.l(start_time, format: "%A") + I18n.t("recurring_meeting.frequency.every_weekday", day_of_the_week:) + end + def schedule IceCube::Schedule.new(start_time, end_time: end_date).tap do |s| s.add_recurrence_rule count_rule(frequency_rule) diff --git a/modules/meeting/config/locales/en.yml b/modules/meeting/config/locales/en.yml index 05cfbaa412f2..3f19df87cee9 100644 --- a/modules/meeting/config/locales/en.yml +++ b/modules/meeting/config/locales/en.yml @@ -146,7 +146,10 @@ en: meeting: + participants: + template: "These participants will be invited automatically to all future meetings as they are created." attachments: + template: "These attached files will be included in all future meetings in the series." text: "Attached files are available to all meeting participants. You can also drag and drop these into agenda item notes." copy: title: "Copy meeting: %{title}" @@ -188,11 +191,12 @@ en: recurring_meeting: template: - banner: > - You are currently editing a template for a recurring meeting. - Every new instance of a metting in this series will use this template. + banner_html: > + You are currently editing a template of a meeting series: %{link}. + Every new instance of a meeting in this series will use this template. Changes will not affect past or already created meetings. frequency: + every_weekday: "Every %{day_of_the_week}" daily: "Daily" weekly: "Weekly" biweekly: "Bi-weekly (Every 2 weeks)" From 6e822aad1497312f52dcc8d19e2556c38f8fb3f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 13 Nov 2024 13:39:09 +0100 Subject: [PATCH 017/129] Skip mails when adding participants --- modules/meeting/app/models/meeting.rb | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/modules/meeting/app/models/meeting.rb b/modules/meeting/app/models/meeting.rb index 75ac011d764e..8be8c3be124e 100644 --- a/modules/meeting/app/models/meeting.rb +++ b/modules/meeting/app/models/meeting.rb @@ -228,8 +228,6 @@ def allowed_participants .where(user_id: available_members) end - protected - private def add_new_participants_as_watcher @@ -239,12 +237,16 @@ def add_new_participants_as_watcher end def send_participant_added_mail(participant) - if persisted? && Journal::NotificationConfiguration.active? + return if templated? || new_record? + + if Journal::NotificationConfiguration.active? MeetingMailer.invited(self, participant.user, User.current).deliver_later end end def send_rescheduling_mail + return if templated? || new_record? + MeetingNotificationService .new(self) .call :rescheduled, From f15878781dfe5af28ab03baa010113942d2f1c93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 13 Nov 2024 13:41:23 +0100 Subject: [PATCH 018/129] exclude templated meetings --- .../app/contracts/meeting_agenda_items/create_contract.rb | 1 + modules/meeting/app/models/meeting.rb | 4 ++++ modules/meeting/app/models/queries/meetings/meeting_query.rb | 4 +++- 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/modules/meeting/app/contracts/meeting_agenda_items/create_contract.rb b/modules/meeting/app/contracts/meeting_agenda_items/create_contract.rb index 1592c764b24b..fa1e7ab69c5c 100644 --- a/modules/meeting/app/contracts/meeting_agenda_items/create_contract.rb +++ b/modules/meeting/app/contracts/meeting_agenda_items/create_contract.rb @@ -35,6 +35,7 @@ class CreateContract < BaseContract def self.assignable_meetings(user) StructuredMeeting .open + .not_templated .visible(user) end diff --git a/modules/meeting/app/models/meeting.rb b/modules/meeting/app/models/meeting.rb index 8be8c3be124e..9c13a5a36579 100644 --- a/modules/meeting/app/models/meeting.rb +++ b/modules/meeting/app/models/meeting.rb @@ -51,6 +51,10 @@ class Meeting < ApplicationRecord default_scope do order("#{Meeting.table_name}.start_time DESC") end + + scope :templated, -> { where(template: true) } + scope :not_templated, -> { where(template: false) } + scope :from_tomorrow, -> { where(["start_time >= ?", Date.tomorrow.beginning_of_day]) } scope :from_today, -> { where(["start_time >= ?", Time.zone.today.beginning_of_day]) } scope :with_users_by_date, -> { diff --git a/modules/meeting/app/models/queries/meetings/meeting_query.rb b/modules/meeting/app/models/queries/meetings/meeting_query.rb index eb65640b5ea0..229a4c180982 100644 --- a/modules/meeting/app/models/queries/meetings/meeting_query.rb +++ b/modules/meeting/app/models/queries/meetings/meeting_query.rb @@ -41,7 +41,9 @@ def results end def default_scope - Meeting.visible(user) + Meeting + .not_templated + .visible(user) end end end From 70461c03f5fc03d05056f40af8000325d59fd7db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 13 Nov 2024 13:44:42 +0100 Subject: [PATCH 019/129] Add header for templates --- .../components/meetings/header_component.rb | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/modules/meeting/app/components/meetings/header_component.rb b/modules/meeting/app/components/meetings/header_component.rb index 4aba72081558..4be6741234cb 100644 --- a/modules/meeting/app/components/meetings/header_component.rb +++ b/modules/meeting/app/components/meetings/header_component.rb @@ -36,6 +36,7 @@ class HeaderComponent < ApplicationComponent STATE_DEFAULT = :show STATE_EDIT = :edit STATE_OPTIONS = [STATE_DEFAULT, STATE_EDIT].freeze + def initialize(meeting:, project: nil, state: STATE_DEFAULT) super @@ -56,10 +57,27 @@ def delete_enabled? end def breadcrumb_items - [parent_element, - { href: @project.present? ? project_meetings_path(@project.id) : meetings_path, - text: I18n.t(:label_meeting_plural) }, - @meeting.title] + [ + parent_element, + { href: @project.present? ? project_meetings_path(@project.id) : meetings_path, + text: I18n.t(:label_meeting_plural) }, + meeting_series_element, + meeting_element + ].compact + end + + def meeting_element + if @meeting.templated? + I18n.t(:label_template) + else + @meeting.title + end + end + + def meeting_series_element + if @meeting.recurring_meeting.present? + { href: recurring_meeting_path(@meeting), text: @meeting.recurring_meeting.title } + end end def parent_element From 228586c9e505a4bc5a9812df6718c9fa685f1c9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 13 Nov 2024 13:48:32 +0100 Subject: [PATCH 020/129] Prevent deletion of meeting template --- modules/meeting/app/components/meetings/header_component.rb | 2 +- modules/meeting/app/controllers/meetings_controller.rb | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/modules/meeting/app/components/meetings/header_component.rb b/modules/meeting/app/components/meetings/header_component.rb index 4be6741234cb..9c9ebb2b1071 100644 --- a/modules/meeting/app/components/meetings/header_component.rb +++ b/modules/meeting/app/components/meetings/header_component.rb @@ -53,7 +53,7 @@ def check_for_updates_interval private def delete_enabled? - User.current.allowed_in_project?(:delete_meetings, @meeting.project) + !@meeting.templated? && User.current.allowed_in_project?(:delete_meetings, @meeting.project) end def breadcrumb_items diff --git a/modules/meeting/app/controllers/meetings_controller.rb b/modules/meeting/app/controllers/meetings_controller.rb index ee8732cdf1f2..3f53d897f31e 100644 --- a/modules/meeting/app/controllers/meetings_controller.rb +++ b/modules/meeting/app/controllers/meetings_controller.rb @@ -39,6 +39,7 @@ class MeetingsController < ApplicationController before_action :authorize, except: %i[index new create update_title update_details update_participants change_state new_dialog] before_action :authorize_global, only: %i[index new create update_title update_details update_participants change_state new_dialog] + before_action :prevent_template_destruction, only: :destroy helper :watchers helper :meeting_contents @@ -458,4 +459,8 @@ def copy_attributes send_notifications: copy_param(:send_notifications) } end + + def prevent_template_destruction + render_400 if @meeting.templated? + end end From 03f11938430b7aa1082941553f6575c4a27542a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 13 Nov 2024 13:50:40 +0100 Subject: [PATCH 021/129] Fix breadcrumb --- modules/meeting/app/components/meetings/header_component.rb | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/modules/meeting/app/components/meetings/header_component.rb b/modules/meeting/app/components/meetings/header_component.rb index 9c9ebb2b1071..926d31270a0a 100644 --- a/modules/meeting/app/components/meetings/header_component.rb +++ b/modules/meeting/app/components/meetings/header_component.rb @@ -41,6 +41,7 @@ def initialize(meeting:, project: nil, state: STATE_DEFAULT) super @meeting = meeting + @series = meeting.recurring_meeting @project = project @state = fetch_or_fallback(STATE_OPTIONS, state) end @@ -75,8 +76,8 @@ def meeting_element end def meeting_series_element - if @meeting.recurring_meeting.present? - { href: recurring_meeting_path(@meeting), text: @meeting.recurring_meeting.title } + if @series.present? + { href: recurring_meeting_path(@series), text: @series.title } end end From 25cca0e6faef6158793408a49f412e77c6300f7c Mon Sep 17 00:00:00 2001 From: Mir Bhatia Date: Wed, 13 Nov 2024 20:51:04 +0100 Subject: [PATCH 022/129] WIP Add show table and initialization --- .../index_page_header_component.html.erb | 1 + .../index_page_header_component.rb | 12 +- .../index_sub_header_component.html.erb | 14 ++ .../index_sub_header_component.rb | 55 +++++++ .../recurring_meetings/row_component.rb | 138 ++++++++++++++++++ .../show_component.html.erb | 0 .../recurring_meetings/show_component.rb | 41 ++++++ .../recurring_meetings/table_component.rb | 70 +++++++++ .../app/controllers/meetings_controller.rb | 25 +++- .../recurring_meetings_controller.rb | 29 ++++ modules/meeting/app/models/meeting.rb | 2 + .../app/services/meetings/copy_service.rb | 26 +++- .../views/recurring_meetings/index.html.erb | 2 +- .../views/recurring_meetings/show.html.erb | 38 +++++ modules/meeting/config/locales/en.yml | 6 + modules/meeting/config/routes.rb | 1 + .../lib/open_project/meeting/engine.rb | 2 +- 17 files changed, 451 insertions(+), 11 deletions(-) create mode 100644 modules/meeting/app/components/recurring_meetings/index_sub_header_component.html.erb create mode 100644 modules/meeting/app/components/recurring_meetings/index_sub_header_component.rb create mode 100644 modules/meeting/app/components/recurring_meetings/row_component.rb create mode 100644 modules/meeting/app/components/recurring_meetings/show_component.html.erb create mode 100644 modules/meeting/app/components/recurring_meetings/show_component.rb create mode 100644 modules/meeting/app/components/recurring_meetings/table_component.rb create mode 100644 modules/meeting/app/views/recurring_meetings/show.html.erb diff --git a/modules/meeting/app/components/recurring_meetings/index_page_header_component.html.erb b/modules/meeting/app/components/recurring_meetings/index_page_header_component.html.erb index 5eee7795c3c2..6a2c7cd04cbf 100644 --- a/modules/meeting/app/components/recurring_meetings/index_page_header_component.html.erb +++ b/modules/meeting/app/components/recurring_meetings/index_page_header_component.html.erb @@ -1,5 +1,6 @@ <%= render(Primer::OpenProject::PageHeader.new) do |header| header.with_title { page_title } + header.with_description { page_description } header.with_breadcrumbs(breadcrumb_items) if render_create_button? header.with_action_button(tag: :a, diff --git a/modules/meeting/app/components/recurring_meetings/index_page_header_component.rb b/modules/meeting/app/components/recurring_meetings/index_page_header_component.rb index 02178283648f..bfb54c682b9d 100644 --- a/modules/meeting/app/components/recurring_meetings/index_page_header_component.rb +++ b/modules/meeting/app/components/recurring_meetings/index_page_header_component.rb @@ -33,9 +33,11 @@ class IndexPageHeaderComponent < ApplicationComponent include OpPrimer::ComponentHelpers include ApplicationHelper - def initialize(project: nil) + def initialize(project: nil, meeting: nil) super + @project = project + @meeting = meeting end def render_create_button? @@ -63,11 +65,17 @@ def label_text end def page_title - I18n.t(:label_recurring_meeting_plural) + @meeting.present? ? @meeting.title + " (Meeting series)" : I18n.t(:label_recurring_meeting_plural) + end + + def page_description + "Meeting schedule goes here" end def breadcrumb_items [parent_element, + { href: @project.present? ? project_meetings_path(@project.id) : meetings_path, + text: I18n.t(:label_meeting_plural) }, page_title] end diff --git a/modules/meeting/app/components/recurring_meetings/index_sub_header_component.html.erb b/modules/meeting/app/components/recurring_meetings/index_sub_header_component.html.erb new file mode 100644 index 000000000000..a7f0ed672dd1 --- /dev/null +++ b/modules/meeting/app/components/recurring_meetings/index_sub_header_component.html.erb @@ -0,0 +1,14 @@ +<%= render(Primer::OpenProject::SubHeader.new(data: { + controller: "filter--filters-form", + "application-target": "dynamic", + "filter--filters-form-output-format-value": "json", +})) do |subheader| + + # subheader.with_filter_component do + # render(Meetings::MeetingFilterButtonComponent.new(query: @query, project: @project)) + # end + + # subheader.with_bottom_pane_component(mt: 0) do + # render(Meetings::MeetingFiltersComponent.new(query: @query, project: @project)) + # end +end %> diff --git a/modules/meeting/app/components/recurring_meetings/index_sub_header_component.rb b/modules/meeting/app/components/recurring_meetings/index_sub_header_component.rb new file mode 100644 index 000000000000..eb028901f8a0 --- /dev/null +++ b/modules/meeting/app/components/recurring_meetings/index_sub_header_component.rb @@ -0,0 +1,55 @@ +# 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 RecurringMeetings + # rubocop:disable OpenProject/AddPreviewForViewComponent + class IndexSubHeaderComponent < ApplicationComponent + # rubocop:enable OpenProject/AddPreviewForViewComponent + include ApplicationHelper + + def initialize(query: nil, project: nil) + super + @query = query + @project = project + end + + def dynamic_path + polymorphic_path([:new, @project, :meeting]) + end + + def accessibility_label_text + I18n.t(:label_meeting_new) + end + + def label_text + I18n.t(:label_meeting) + end + end +end diff --git a/modules/meeting/app/components/recurring_meetings/row_component.rb b/modules/meeting/app/components/recurring_meetings/row_component.rb new file mode 100644 index 000000000000..22ed4ed05ad0 --- /dev/null +++ b/modules/meeting/app/components/recurring_meetings/row_component.rb @@ -0,0 +1,138 @@ +# 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 RecurringMeetings + class RowComponent < ::OpPrimer::BorderBoxRowComponent + def column_args(column) + if column == :title + { style: "grid-column: span 2" } + else + super + end + end + + def start_time + if model["state"] == "open" || model["state"] == "closed" + link_to safe_join([helpers.format_date(model["start_time"]), helpers.format_time(model["start_time"], include_date: false)], " "), + project_meeting_path(Project.find(model["project_id"]), Meeting.find(model["id"])) + else + safe_join([helpers.format_date(model["start_time"]), helpers.format_time(model["start_time"], include_date: false)], " ") + end + end + + def relative_time + render(OpPrimer::RelativeTimeComponent.new(datetime: model["start_time"], prefix: I18n.t(:label_on))) + end + + def last_edited + safe_join([helpers.format_date(model["updated_at"]), helpers.format_time(model["updated_at"], include_date: false)], " ") + end + + def status + case model["state"] + when "open" + scheme = :success + when "scheduled" + scheme = :secondary + when "cancelled" + scheme = :severe + when "closed" + scheme = :secondary + end + + render(Primer::Beta::Label.new(scheme:)) do + render(Primer::Beta::Text.new) { t("label_meeting_state_#{model['state']}") } + end + end + + def create + if model["state"] != "open" + render(Primer::Beta::Button.new( + scheme: :default, + size: :medium + )) do |_c| + I18n.t("label_recurring_meeting_create") + end + end + end + + def button_links + [ + action_menu + ] + end + + def action_menu + render(Primer::Alpha::ActionMenu.new) do |menu| + menu.with_show_button(icon: "kebab-horizontal", + "aria-label": "More", + scheme: :invisible, + data: { + "test-selector": "more-button" + }) + + ical_action(menu) + + if delete_allowed? + delete_action(menu) + end + end + end + + def ical_action(menu) + menu.with_item(label: I18n.t(:label_icalendar_download), + href: download_ics_meeting_path(Meeting.find(model["id"])), + content_arguments: { + data: { turbo: false } + }) do |item| + item.with_leading_visual_icon(icon: :download) + end + end + + def delete_action(menu) + menu.with_item(label: I18n.t(:label_recurring_meeting_cancel), + scheme: :danger, + href: meeting_path(Meeting.find(model["id"])), + form_arguments: { + method: :delete, data: { confirm: I18n.t("text_are_you_sure"), turbo: false } + }) do |item| + item.with_leading_visual_icon(icon: :trash) + end + end + + def delete_allowed? + User.current.allowed_in_project?(:delete_meetings, Project.find(model["project_id"])) + end + + def copy_allowed? + User.current.allowed_in_project?(:create_meetings, Project.find(model["project_id"])) + end + end +end diff --git a/modules/meeting/app/components/recurring_meetings/show_component.html.erb b/modules/meeting/app/components/recurring_meetings/show_component.html.erb new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/modules/meeting/app/components/recurring_meetings/show_component.rb b/modules/meeting/app/components/recurring_meetings/show_component.rb new file mode 100644 index 000000000000..c2ad5dc03188 --- /dev/null +++ b/modules/meeting/app/components/recurring_meetings/show_component.rb @@ -0,0 +1,41 @@ +#-- 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 RecurringMeetings + class ShowComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + + def initialize(meeting:, project:) + super + + @meeting = meeting + @project = project + end + end +end diff --git a/modules/meeting/app/components/recurring_meetings/table_component.rb b/modules/meeting/app/components/recurring_meetings/table_component.rb new file mode 100644 index 000000000000..3df53f77317c --- /dev/null +++ b/modules/meeting/app/components/recurring_meetings/table_component.rb @@ -0,0 +1,70 @@ +# 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 RecurringMeetings + class TableComponent < ::OpPrimer::BorderBoxTableComponent + # columns :title, :project_name, :start_time, :duration, :location + columns :start_time, :relative_time, :last_edited, :status, :create + + # def sortable? + # true + # end + + # def initial_sort + # %i[start_time asc] + # end + + def has_actions? + true + end + + def header_args(column) + if column == :title + { style: "grid-column: span 2" } + else + super + end + end + + def headers + @headers ||= [ + [:start_time, { caption: I18n.t(:label_meeting_date_and_time) }], + [:relative_time, { caption: I18n.t("recurring_meeting.starts") }], + [:last_edited, { caption: I18n.t(:label_meeting_last_updated) }], + [:status, { caption: Meeting.human_attribute_name(:status) }], + [:create, ""] + ].compact + end + + def columns + @columns ||= headers.map(&:first) + end + end +end diff --git a/modules/meeting/app/controllers/meetings_controller.rb b/modules/meeting/app/controllers/meetings_controller.rb index 3f53d897f31e..deb45a128371 100644 --- a/modules/meeting/app/controllers/meetings_controller.rb +++ b/modules/meeting/app/controllers/meetings_controller.rb @@ -27,7 +27,7 @@ #++ class MeetingsController < ApplicationController - before_action :load_and_authorize_in_optional_project, only: %i[index new new_dialog show create history] + before_action :load_and_authorize_in_optional_project, only: %i[index new new_dialog show create history init] before_action :verify_activities_module_activated, only: %i[history] before_action :determine_date_range, only: %i[history] before_action :determine_author, only: %i[history] @@ -36,9 +36,9 @@ class MeetingsController < ApplicationController before_action :set_activity, only: %i[history] before_action :find_copy_from_meeting, only: %i[create] before_action :convert_params, only: %i[create update update_participants] - before_action :authorize, except: %i[index new create update_title update_details update_participants change_state new_dialog] + before_action :authorize, except: %i[index new create update_title update_details update_participants change_state new_dialog init] before_action :authorize_global, - only: %i[index new create update_title update_details update_participants change_state new_dialog] + only: %i[index new create update_title update_details update_participants change_state new_dialog init] before_action :prevent_template_destruction, only: :destroy helper :watchers @@ -90,6 +90,17 @@ def check_for_updates end end + def init + call = ::Meetings::CopyService + .new(user: current_user, model: @meeting) + .call(attributes: params, attach_to_recurring: true) + if call.success? + redirect_to controller: "recurring_meetings", action: "show", id: @meeting.recurring_meeting_id, status: :see_other + else + # Flash + redirect? + end + end + def create # rubocop:disable Metrics/AbcSize call = if @copy_from @@ -175,9 +186,15 @@ def copy end def destroy + type = @meeting.recurring_meeting_id @meeting.destroy flash[:notice] = I18n.t(:notice_successful_delete) - redirect_to action: "index", project_id: @project + + if type.nil? + redirect_to action: "index", project_id: @project + else + redirect_to controller: "recurring_meetings", action: "show", id: type, status: :see_other + end end def edit diff --git a/modules/meeting/app/controllers/recurring_meetings_controller.rb b/modules/meeting/app/controllers/recurring_meetings_controller.rb index e6f46eb10c4d..486138f97f85 100644 --- a/modules/meeting/app/controllers/recurring_meetings_controller.rb +++ b/modules/meeting/app/controllers/recurring_meetings_controller.rb @@ -27,11 +27,40 @@ def new end def show + @meetings = collect_meetings + respond_to do |format| format.html end end + def collect_meetings + meetings = [] + @recurring_meeting.meetings.each do |meeting| + meetings << meeting unless meeting.template == true + end + + template_attributes = @recurring_meeting.meetings.where(template: true).first.attributes + @recurring_meeting.schedule.all_occurrences.each do |date| + exists = meetings.find { |m| m["start_time"] == date } + unless exists + attributes = template_attributes.dup + attributes["start_time"] = date + attributes["state"] = "scheduled" + attributes["template"] = false + meetings << Meeting.new(**attributes) + # @recurring_meeting.meetings.build(**attributes) + end + end + + # SELECT COALESCE(meetings.start_time, meetings.recurring_date) start_time_test FROM + # (SELECT * FROM (VALUES (to_timestamp('2024-10-06 11:30:00', 'YYYY-MM-DD HH24:MI:SS'), 'ABC'), + # (to_timestamp('2024-10-06 11:30:00', 'YYYY-MM-DD HH24:MI:SS'), 'ABC')) AS t (recurring_date, recurring_title) + # FULL JOIN meetings ON meetings.start_time = t.recurring_date) meetings ORDER BY start_time_test; + + meetings + end + def create call = ::RecurringMeetings::CreateService .new(user: current_user) diff --git a/modules/meeting/app/models/meeting.rb b/modules/meeting/app/models/meeting.rb index 9c13a5a36579..af17ac0cb7d3 100644 --- a/modules/meeting/app/models/meeting.rb +++ b/modules/meeting/app/models/meeting.rb @@ -101,6 +101,8 @@ class Meeting < ApplicationRecord enum state: { open: 0, # 0 -> default, leave values for future states between open and closed + scheduled: 1, + cancelled: 4, closed: 5 } diff --git a/modules/meeting/app/services/meetings/copy_service.rb b/modules/meeting/app/services/meetings/copy_service.rb index f351e61b363a..b5bd5cd65cf9 100644 --- a/modules/meeting/app/services/meetings/copy_service.rb +++ b/modules/meeting/app/services/meetings/copy_service.rb @@ -42,9 +42,13 @@ def initialize(user:, model:, contract_class: Meetings::CreateContract) self.contract_class = contract_class end - def call(send_notifications: nil, save: true, copy_agenda: true, copy_attachments: false, attributes: {}) + def call(send_notifications: nil, save: true, copy_agenda: true, copy_attachments: false, attach_to_recurring: false, attributes: {}) if save - create(meeting, attributes, send_notifications:, copy_agenda:, copy_attachments:) + if attach_to_recurring + create_recurring(meeting, attributes) + else + create(meeting, attributes, send_notifications:, copy_agenda:, copy_attachments:, attach_to_recurring:) + end else build(meeting, attributes) end @@ -52,13 +56,24 @@ def call(send_notifications: nil, save: true, copy_agenda: true, copy_attachment protected - def create(meeting, attribute_overrides, send_notifications:, copy_agenda:, copy_attachments:) + def create(meeting, attribute_overrides, send_notifications:, copy_agenda:, copy_attachments:, attach_to_recurring:) Meetings::CreateService .new(user:, contract_class:) .call(**copied_attributes(meeting, attribute_overrides).merge(send_notifications:).symbolize_keys) .on_success do |call| copy_meeting_agenda(call.result) if copy_agenda copy_meeting_attachment(call.result) if copy_attachments + attach_to_recurring(call.result) if attach_to_recurring + end + end + + def create_recurring(meeting, attributes) + Meetings::CreateService.new(user:, contract_class:) + .call(meeting.attributes.slice(*writable_meeting_attributes(meeting)).merge("start_time" => DateTime.parse(attributes["date"])).merge(send_notifications: false).symbolize_keys) + .on_success do |call| + copy_meeting_agenda(call.result) + copy_meeting_attachment(call.result) + attach_to_recurring(call.result) end end @@ -137,5 +152,10 @@ def copy_structured_meeting_participants(copy) copy.participants << copied_participant end end + + def attach_to_recurring(copy) + copy.recurring_meeting = meeting.recurring_meeting + copy.save! # ? + end end end diff --git a/modules/meeting/app/views/recurring_meetings/index.html.erb b/modules/meeting/app/views/recurring_meetings/index.html.erb index 77e2c7e20d7b..d1329b15f5a1 100644 --- a/modules/meeting/app/views/recurring_meetings/index.html.erb +++ b/modules/meeting/app/views/recurring_meetings/index.html.erb @@ -28,7 +28,7 @@ See COPYRIGHT and LICENSE files for more details. ++#%> <% html_title t(:label_recurring_meeting_plural) %> -<%= render(RecurringMeetings::IndexPageHeaderComponent.new(project: @project)) %> +<%= render(Meetings::IndexPageHeaderComponent.new(project: @project)) %> <% if @recurring_meetings.empty? -%> <%= no_results_box %> diff --git a/modules/meeting/app/views/recurring_meetings/show.html.erb b/modules/meeting/app/views/recurring_meetings/show.html.erb new file mode 100644 index 000000000000..e4159d008d8a --- /dev/null +++ b/modules/meeting/app/views/recurring_meetings/show.html.erb @@ -0,0 +1,38 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) 2012-2024 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. + +++#%> +<% html_title t(:label_recurring_meeting_plural) %> + +<%= render(RecurringMeetings::IndexPageHeaderComponent.new(project: @project, meeting: @recurring_meeting)) %> +<%= render(RecurringMeetings::IndexSubHeaderComponent.new( project: @project)) %> + +<% if @recurring_meeting.nil? -%> + <%= no_results_box %> +<% else -%> + <%= render RecurringMeetings::TableComponent.new(rows: @meetings, current_project: @project) %> +<% end -%> diff --git a/modules/meeting/config/locales/en.yml b/modules/meeting/config/locales/en.yml index 3f19df87cee9..22d886ff907e 100644 --- a/modules/meeting/config/locales/en.yml +++ b/modules/meeting/config/locales/en.yml @@ -128,6 +128,8 @@ en: label_recurring_meeting_new: "New recurring meeting" label_recurring_meeting_plural: "Recurring meetings" label_template: "Template" + label_recurring_meeting_create: "Create from template" + label_recurring_meeting_cancel: "Cancel this occurrence" label_upcoming_meetings: "Upcoming meetings" label_past_meetings: "Past meetings" label_upcoming_meetings_short: "Upcoming" @@ -205,6 +207,7 @@ en: end_after: specific_date: "A specific date" iterations: "A number of occurrences" + starts: "Starts" notice_successful_notification: "Notification sent successfully" notice_timezone_missing: No time zone is set and %{zone} is assumed. To choose your time zone, please click here. @@ -274,6 +277,9 @@ en: label_meeting_state_open_html: "Open" label_meeting_state_closed: "Closed" label_meeting_state_closed_html: "Closed" + label_meeting_state_agenda_created: "Agenda created" + label_meeting_state_scheduled: "Scheduled" + label_meeting_state_cancelled: "Cancelled" label_meeting_reopen_action: "Reopen meeting" label_meeting_close_action: "Close meeting" text_meeting_open_description: "This meeting is open. You can add/remove agenda items and edit them as you please. After the meeting is over, close it to lock it." diff --git a/modules/meeting/config/routes.rb b/modules/meeting/config/routes.rb index d00f7c4e11ba..2749594a440c 100644 --- a/modules/meeting/config/routes.rb +++ b/modules/meeting/config/routes.rb @@ -73,6 +73,7 @@ put :change_state post :notify get :history + get :init end resources :agenda_items, controller: "meeting_agenda_items" do collection do diff --git a/modules/meeting/lib/open_project/meeting/engine.rb b/modules/meeting/lib/open_project/meeting/engine.rb index 2e1060b2fce5..7ad7dd9d36ab 100644 --- a/modules/meeting/lib/open_project/meeting/engine.rb +++ b/modules/meeting/lib/open_project/meeting/engine.rb @@ -40,7 +40,7 @@ class Engine < ::Rails::Engine bundled: true do project_module :meetings do permission :view_meetings, - { meetings: %i[index show check_for_updates download_ics participants_dialog history], + { meetings: %i[index show check_for_updates download_ics participants_dialog history init], meeting_agendas: %i[history show diff], meeting_minutes: %i[history show diff], "meetings/menus": %i[show], From 6cc9cc16e48c2a0995d5deb2a256ec00f562f628 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 13 Nov 2024 13:51:09 +0100 Subject: [PATCH 023/129] Change icon for calendar --- .../components/meetings/side_panel/details_component.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/meeting/app/components/meetings/side_panel/details_component.html.erb b/modules/meeting/app/components/meetings/side_panel/details_component.html.erb index e8e73b8d8f42..a92183142e14 100644 --- a/modules/meeting/app/components/meetings/side_panel/details_component.html.erb +++ b/modules/meeting/app/components/meetings/side_panel/details_component.html.erb @@ -27,7 +27,7 @@ end details.with_row(mt: 2) do - render_meeting_attribute_row(:"git-commit") do + render_meeting_attribute_row(:calendar) do render(Primer::Beta::Text.new) do @meeting.recurring_meeting.human_date_of_week end From 085b5d2d2a1c2515a32503f7f5403b561d1a48f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 13 Nov 2024 14:02:29 +0100 Subject: [PATCH 024/129] remove unused template --- .../views/recurring_meetings/_form.html.erb | 68 ------------------- 1 file changed, 68 deletions(-) delete mode 100644 modules/meeting/app/views/recurring_meetings/_form.html.erb diff --git a/modules/meeting/app/views/recurring_meetings/_form.html.erb b/modules/meeting/app/views/recurring_meetings/_form.html.erb deleted file mode 100644 index d3c9ddbc07c8..000000000000 --- a/modules/meeting/app/views/recurring_meetings/_form.html.erb +++ /dev/null @@ -1,68 +0,0 @@ -<%#-- copyright -OpenProject is an open source project management software. -Copyright (C) 2012-2024 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. - -++#%> - -<%= error_messages_for 'recurring_meeting' %> - -<%= - primer_form_with(url: "/foo") do |form_builder| - render_inline_form(form_builder) do |form| - form.text_field( - name: :title, - input_width: :medium, - placeholder: RecurringMeeting.human_attribute_name(:title), - label: RecurringMeeting.human_attribute_name(:title), - required: true, - autofocus: true - ) - - form.group(layout: :horizontal) do |date_form| - date_form.text_field( - name: :start_date, - full_width: false, - input_width: :medium, - placeholder: RecurringMeeting.human_attribute_name(:start_date), - label: RecurringMeeting.human_attribute_name(:start_date), - required: true - ) - - date_form.text_field( - type: :time, - name: :start_time, - full_width: false, - input_width: :medium, - placeholder: RecurringMeeting.human_attribute_name(:start_time), - label: RecurringMeeting.human_attribute_name(:start_time), - required: true - ) - end - end - end -%> - -<%= render(RecurringMeeting::ScheduleForm.new(f, meeting: @meeting)) %> From 01f2b806a06800d3f1a2977836ebd8e1ef8c3698 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 13 Nov 2024 16:36:31 +0100 Subject: [PATCH 025/129] add template has_one --- modules/meeting/app/models/recurring_meeting.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/modules/meeting/app/models/recurring_meeting.rb b/modules/meeting/app/models/recurring_meeting.rb index 1c5add4e6b90..b1ba7b87a268 100644 --- a/modules/meeting/app/models/recurring_meeting.rb +++ b/modules/meeting/app/models/recurring_meeting.rb @@ -24,6 +24,9 @@ class RecurringMeeting < ApplicationRecord has_many :meetings, inverse_of: :recurring_meeting + has_one :template, -> { where(template: true) }, + class_name: "Meeting" + scope :visible, ->(*args) { includes(:project) .references(:projects) From 9e79f44f7ab968feccf99bccb2ad89379235244c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Thu, 14 Nov 2024 20:45:24 +0100 Subject: [PATCH 026/129] meeting form --- .../meetings/index/form_component.html.erb | 9 ++++----- .../app/components/meetings/index/form_component.rb | 10 +++++++++- .../meetings/side_panel/details_component.html.erb | 9 ++++++++- .../app/controllers/recurring_meetings_controller.rb | 4 +++- modules/meeting/app/forms/meeting/duration.rb | 12 ++++++++++++ modules/meeting/app/forms/meeting/location.rb | 12 ++++++++++++ modules/meeting/config/routes.rb | 3 +++ modules/meeting/lib/open_project/meeting/engine.rb | 1 + 8 files changed, 52 insertions(+), 8 deletions(-) diff --git a/modules/meeting/app/components/meetings/index/form_component.html.erb b/modules/meeting/app/components/meetings/index/form_component.html.erb index 0ffa33325afc..0f545debdedd 100644 --- a/modules/meeting/app/components/meetings/index/form_component.html.erb +++ b/modules/meeting/app/components/meetings/index/form_component.html.erb @@ -3,7 +3,6 @@ primer_form_with( scope: :meeting, model: @meeting, - method: :post, data: { turbo: true, controller: "show-when-value-selected" @@ -12,8 +11,8 @@ id: 'meeting-form' }, url: { - controller: create_controller, - action: 'create', + controller: form_controller, + action: form_action, project_id: @project } ) do |f| @@ -45,11 +44,11 @@ end modal_body.with_row(mt: 3) do - render(Meeting::Duration.new(f)) + render(Meeting::Duration.new(f, meeting: @meeting)) end modal_body.with_row(mt: 3) do - render(Meeting::Location.new(f)) + render(Meeting::Location.new(f, meeting: @meeting)) end if @meeting.is_a?(RecurringMeeting) diff --git a/modules/meeting/app/components/meetings/index/form_component.rb b/modules/meeting/app/components/meetings/index/form_component.rb index c020a40ff029..e3eb299578d6 100644 --- a/modules/meeting/app/components/meetings/index/form_component.rb +++ b/modules/meeting/app/components/meetings/index/form_component.rb @@ -42,7 +42,7 @@ def initialize(meeting:, project:, copy_from: nil) private - def create_controller + def form_controller if @meeting.is_a?(RecurringMeeting) "/recurring_meetings" else @@ -50,6 +50,14 @@ def create_controller end end + def form_action + if @meeting.new_record? + :create + else + :update + end + end + def start_date_initial_value @meeting.start_date.presence || format_time_as_date(@meeting.start_time, format: "%Y-%m-%d") end diff --git a/modules/meeting/app/components/meetings/side_panel/details_component.html.erb b/modules/meeting/app/components/meetings/side_panel/details_component.html.erb index a92183142e14..04cb729d584b 100644 --- a/modules/meeting/app/components/meetings/side_panel/details_component.html.erb +++ b/modules/meeting/app/components/meetings/side_panel/details_component.html.erb @@ -4,11 +4,18 @@ section.with_title { t(:label_meeting_details) } if @meeting.editable? + href = + if @meeting.template? + details_dialog_recurring_meeting_path(@meeting.recurring_meeting) + else + details_dialog_meeting_path(@meeting) + end + section.with_action_icon( icon: :gear, scheme: :invisible, tag: :a, - href: details_dialog_meeting_path(@meeting), + href:, classes: "hide-when-print", data: { controller: 'async-dialog' }, 'aria-label': t(:label_meeting_details_edit), diff --git a/modules/meeting/app/controllers/recurring_meetings_controller.rb b/modules/meeting/app/controllers/recurring_meetings_controller.rb index 486138f97f85..172921b00750 100644 --- a/modules/meeting/app/controllers/recurring_meetings_controller.rb +++ b/modules/meeting/app/controllers/recurring_meetings_controller.rb @@ -7,7 +7,7 @@ class RecurringMeetingsController < ApplicationController before_action :find_meeting, only: %i[show] before_action :find_optional_project, only: %i[index show new create] before_action :authorize_global, only: %i[index new create] - before_action :authorize, only: %i[show] + before_action :authorize, except: %i[index new create] before_action :convert_params, only: %i[create] @@ -61,6 +61,8 @@ def collect_meetings meetings end + def details_dialog; end + def create call = ::RecurringMeetings::CreateService .new(user: current_user) diff --git a/modules/meeting/app/forms/meeting/duration.rb b/modules/meeting/app/forms/meeting/duration.rb index 41a5d303f4db..8e61bd6e91ae 100644 --- a/modules/meeting/app/forms/meeting/duration.rb +++ b/modules/meeting/app/forms/meeting/duration.rb @@ -34,6 +34,7 @@ class Meeting::Duration < ApplicationForm min: 0, max: 24, step: 0.05, + value: @value, placeholder: Meeting.human_attribute_name(:duration), label: Meeting.human_attribute_name(:duration), visually_hide_label: false, @@ -42,4 +43,15 @@ class Meeting::Duration < ApplicationForm caption: I18n.t("text_in_hours") ) end + + def initialize(meeting:) + super() + + @value = + if meeting.is_a?(RecurringMeeting) && meeting.template + meeting.template.duration + else + meeting.duration + end + end end diff --git a/modules/meeting/app/forms/meeting/location.rb b/modules/meeting/app/forms/meeting/location.rb index 40d9f3e882b6..6cad512c0a50 100644 --- a/modules/meeting/app/forms/meeting/location.rb +++ b/modules/meeting/app/forms/meeting/location.rb @@ -30,10 +30,22 @@ class Meeting::Location < ApplicationForm form do |meeting_form| meeting_form.text_field( name: :location, + value: @value, placeholder: Meeting.human_attribute_name(:location), label: Meeting.human_attribute_name(:location), visually_hide_label: false, leading_visual: { icon: :location } ) end + + def initialize(meeting:) + super() + + @value = + if meeting.is_a?(RecurringMeeting) && meeting.template + meeting.template.duration + else + meeting.duration + end + end end diff --git a/modules/meeting/config/routes.rb b/modules/meeting/config/routes.rb index 2749594a440c..4a9585dc414f 100644 --- a/modules/meeting/config/routes.rb +++ b/modules/meeting/config/routes.rb @@ -57,6 +57,9 @@ end resources :recurring_meetings, only: %i[index new create show] do + member do + get :details_dialog + end end resources :meetings do diff --git a/modules/meeting/lib/open_project/meeting/engine.rb b/modules/meeting/lib/open_project/meeting/engine.rb index 7ad7dd9d36ab..72bdf88ab8d0 100644 --- a/modules/meeting/lib/open_project/meeting/engine.rb +++ b/modules/meeting/lib/open_project/meeting/engine.rb @@ -56,6 +56,7 @@ class Engine < ::Rails::Engine permission :edit_meetings, { meetings: %i[edit cancel_edit update update_title details_dialog update_details update_participants], + recurring_meetings: %i[edit cancel_edit update update_title details_dialog update_details], work_package_meetings_tab: %i[add_work_package_to_meeting_dialog add_work_package_to_meeting] }, permissible_on: :project, From c8031d47a851d1086c3f197a380e92d3a491ab02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Fri, 15 Nov 2024 10:59:09 +0100 Subject: [PATCH 027/129] Fix creation of recurring --- .../app/components/meetings/index/form_component.html.erb | 8 ++++---- .../app/services/recurring_meetings/create_service.rb | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/modules/meeting/app/components/meetings/index/form_component.html.erb b/modules/meeting/app/components/meetings/index/form_component.html.erb index 0f545debdedd..8d3ef414c979 100644 --- a/modules/meeting/app/components/meetings/index/form_component.html.erb +++ b/modules/meeting/app/components/meetings/index/form_component.html.erb @@ -71,10 +71,10 @@ data: { value: "iterations", "show-when-value-selected-target": "effect" }) do render(RecurringMeeting::Iterations.new(f)) end - end - - modal_body.with_row do - render(Meeting::Type.new(f)) + else + modal_body.with_row do + render(Meeting::Type.new(f)) + end end if @copy_from diff --git a/modules/meeting/app/services/recurring_meetings/create_service.rb b/modules/meeting/app/services/recurring_meetings/create_service.rb index 1e88adc34e1a..6adda5180cd7 100644 --- a/modules/meeting/app/services/recurring_meetings/create_service.rb +++ b/modules/meeting/app/services/recurring_meetings/create_service.rb @@ -41,7 +41,7 @@ def before_perform(params, _) def after_perform(call) return call unless call.success? - template = create_meeting_template + template = create_meeting_template(call.result) unless template.save call.merge! ServiceResult.failure(result: template, errors: template.errors) end @@ -53,11 +53,11 @@ def extract_template_params(params) params.slice(:start_date, :start_time_hour, :title, :location, :duration) end - def create_meeting_template + def create_meeting_template(recurring_meeting) StructuredMeeting.new(@template_params).tap do |template| - template.project = call.result.project + template.project = recurring_meeting.project template.template = true - template.recurring_meeting = call.result + template.recurring_meeting = recurring_meeting template.author = user end end From 8e81a34012f96743102a779a0e5e8171a9f7b3c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Fri, 15 Nov 2024 11:57:03 +0100 Subject: [PATCH 028/129] working edit --- .../meetings/index/dialog_component.rb | 1 + .../meetings/index/form_component.html.erb | 9 +++- .../meetings/index/form_component.rb | 8 ++++ .../recurring_meetings/base_contract.rb | 4 ++ .../recurring_meetings/create_contract.rb | 4 -- .../recurring_meetings_controller.rb | 37 +++++++++++++-- .../recurring_meetings/create_service.rb | 14 +----- .../recurring_meetings/update_service.rb | 20 ++++++++ .../recurring_meetings/with_template.rb | 47 +++++++++++++++++++ modules/meeting/config/routes.rb | 2 +- 10 files changed, 123 insertions(+), 23 deletions(-) create mode 100644 modules/meeting/app/services/recurring_meetings/with_template.rb diff --git a/modules/meeting/app/components/meetings/index/dialog_component.rb b/modules/meeting/app/components/meetings/index/dialog_component.rb index 0bd67c4dd2b7..41e55092d9e3 100644 --- a/modules/meeting/app/components/meetings/index/dialog_component.rb +++ b/modules/meeting/app/components/meetings/index/dialog_component.rb @@ -53,6 +53,7 @@ def render? def title return I18n.t(:label_meeting_copy) if @copy_from + return I18n.t(:label_meeting_edit) if @meeting.persisted? case @meeting when RecurringMeeting diff --git a/modules/meeting/app/components/meetings/index/form_component.html.erb b/modules/meeting/app/components/meetings/index/form_component.html.erb index 8d3ef414c979..0550aaa45105 100644 --- a/modules/meeting/app/components/meetings/index/form_component.html.erb +++ b/modules/meeting/app/components/meetings/index/form_component.html.erb @@ -3,6 +3,7 @@ primer_form_with( scope: :meeting, model: @meeting, + method: form_method, data: { turbo: true, controller: "show-when-value-selected" @@ -104,11 +105,15 @@ collection.with_component(Primer::Alpha::Dialog::Footer.new) do component_collection do |modal_footer| modal_footer.with_component(Primer::ButtonComponent.new(data: { 'close-dialog-id': "new-meeting-dialog" })) do - I18n.t("button_cancel") + I18n.t(:button_cancel) end modal_footer.with_component(Primer::ButtonComponent.new(scheme: :primary, type: :submit)) do - I18n.t("label_meeting_create") + if @meeting.persisted? + I18n.t(:button_save) + else + I18n.t(:label_meeting_create) + end end end end diff --git a/modules/meeting/app/components/meetings/index/form_component.rb b/modules/meeting/app/components/meetings/index/form_component.rb index e3eb299578d6..149bd8cd0603 100644 --- a/modules/meeting/app/components/meetings/index/form_component.rb +++ b/modules/meeting/app/components/meetings/index/form_component.rb @@ -50,6 +50,14 @@ def form_controller end end + def form_method + if @meeting.new_record? + :post + else + :put + end + end + def form_action if @meeting.new_record? :create diff --git a/modules/meeting/app/contracts/recurring_meetings/base_contract.rb b/modules/meeting/app/contracts/recurring_meetings/base_contract.rb index dc476fed2605..138d93557dcc 100644 --- a/modules/meeting/app/contracts/recurring_meetings/base_contract.rb +++ b/modules/meeting/app/contracts/recurring_meetings/base_contract.rb @@ -42,5 +42,9 @@ def self.model attribute :end_after attribute :end_date attribute :iterations + + # Virtual attributes for the form + attribute :duration + attribute :location end end diff --git a/modules/meeting/app/contracts/recurring_meetings/create_contract.rb b/modules/meeting/app/contracts/recurring_meetings/create_contract.rb index ddccd94c364f..5b32613aa598 100644 --- a/modules/meeting/app/contracts/recurring_meetings/create_contract.rb +++ b/modules/meeting/app/contracts/recurring_meetings/create_contract.rb @@ -30,10 +30,6 @@ module RecurringMeetings class CreateContract < BaseContract validate :user_allowed_to_add - # Virtual attributes for the form - attribute :duration - attribute :location - private def user_allowed_to_add diff --git a/modules/meeting/app/controllers/recurring_meetings_controller.rb b/modules/meeting/app/controllers/recurring_meetings_controller.rb index 172921b00750..46def258ec70 100644 --- a/modules/meeting/app/controllers/recurring_meetings_controller.rb +++ b/modules/meeting/app/controllers/recurring_meetings_controller.rb @@ -4,12 +4,12 @@ class RecurringMeetingsController < ApplicationController include OpTurbo::FlashStreamHelper include OpTurbo::DialogStreamHelper - before_action :find_meeting, only: %i[show] - before_action :find_optional_project, only: %i[index show new create] + before_action :find_meeting, only: %i[show update details_dialog] + before_action :find_optional_project, only: %i[index show new create update details_dialog] before_action :authorize_global, only: %i[index new create] before_action :authorize, except: %i[index new create] - before_action :convert_params, only: %i[create] + before_action :convert_params, only: %i[create update] menu_item :meetings @@ -61,7 +61,12 @@ def collect_meetings meetings end - def details_dialog; end + def details_dialog + respond_with_dialog Meetings::Index::DialogComponent.new( + meeting: @recurring_meeting, + project: @recurring_meeting.project + ) + end def create call = ::RecurringMeetings::CreateService @@ -88,6 +93,30 @@ def create end end + def update + call = ::RecurringMeetings::UpdateService + .new(model: @recurring_meeting, user: current_user) + .call(@converted_params) + + if call.success? + redirect_back(fallback_location: recurring_meeting_path(call.result), status: :see_other, turbo: false) + else + respond_to do |format| + format.turbo_stream do + update_via_turbo_stream( + component: Meetings::Index::FormComponent.new( + meeting: call.result, + project: @project + ), + status: :bad_request + ) + + respond_with_turbo_streams + end + end + end + end + private def find_optional_project diff --git a/modules/meeting/app/services/recurring_meetings/create_service.rb b/modules/meeting/app/services/recurring_meetings/create_service.rb index 6adda5180cd7..4304c891f676 100644 --- a/modules/meeting/app/services/recurring_meetings/create_service.rb +++ b/modules/meeting/app/services/recurring_meetings/create_service.rb @@ -28,15 +28,9 @@ module RecurringMeetings class CreateService < ::BaseServices::Create - protected - - attr_accessor :template_params + include WithTemplate - def before_perform(params, _) - @template_params = extract_template_params(params) - - super - end + protected def after_perform(call) return call unless call.success? @@ -49,10 +43,6 @@ def after_perform(call) call end - def extract_template_params(params) - params.slice(:start_date, :start_time_hour, :title, :location, :duration) - end - def create_meeting_template(recurring_meeting) StructuredMeeting.new(@template_params).tap do |template| template.project = recurring_meeting.project diff --git a/modules/meeting/app/services/recurring_meetings/update_service.rb b/modules/meeting/app/services/recurring_meetings/update_service.rb index 7f510a39bf4d..aa746dc67217 100644 --- a/modules/meeting/app/services/recurring_meetings/update_service.rb +++ b/modules/meeting/app/services/recurring_meetings/update_service.rb @@ -28,5 +28,25 @@ module RecurringMeetings class UpdateService < ::BaseServices::Update + include WithTemplate + + protected + + def after_perform(call) + return call unless call.success? + + update_template(call) + end + + def update_template(call) + recurring_meeting = call.result + template = recurring_meeting.template + + unless template.update(@template_params) + call.merge! ServiceResult.failure(result: template, errors: template.errors) + end + + call + end end end diff --git a/modules/meeting/app/services/recurring_meetings/with_template.rb b/modules/meeting/app/services/recurring_meetings/with_template.rb new file mode 100644 index 000000000000..18eccbbf1cde --- /dev/null +++ b/modules/meeting/app/services/recurring_meetings/with_template.rb @@ -0,0 +1,47 @@ +#-- 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 RecurringMeetings + module WithTemplate + extend ActiveSupport::Concern + + included do + attr_accessor :template_params + + def before_perform(params, _) + @template_params = extract_template_params(params) + + super + end + + def extract_template_params(params) + params.slice(:start_date, :start_time_hour, :title, :location, :duration) + end + end + end +end diff --git a/modules/meeting/config/routes.rb b/modules/meeting/config/routes.rb index 4a9585dc414f..276445ee3622 100644 --- a/modules/meeting/config/routes.rb +++ b/modules/meeting/config/routes.rb @@ -56,7 +56,7 @@ resource :menu, only: %[show] end - resources :recurring_meetings, only: %i[index new create show] do + resources :recurring_meetings do member do get :details_dialog end From 3f4af24e381749777b8828cf151ab4b76cdb08bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Fri, 15 Nov 2024 11:59:16 +0100 Subject: [PATCH 029/129] specify end time --- .../meeting/app/controllers/recurring_meetings_controller.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/modules/meeting/app/controllers/recurring_meetings_controller.rb b/modules/meeting/app/controllers/recurring_meetings_controller.rb index 46def258ec70..5127f25213a3 100644 --- a/modules/meeting/app/controllers/recurring_meetings_controller.rb +++ b/modules/meeting/app/controllers/recurring_meetings_controller.rb @@ -41,7 +41,10 @@ def collect_meetings end template_attributes = @recurring_meeting.meetings.where(template: true).first.attributes - @recurring_meeting.schedule.all_occurrences.each do |date| + @recurring_meeting + .schedule + .occurrences(@recurring_meeting.end_date) + .each do |date| exists = meetings.find { |m| m["start_time"] == date } unless exists attributes = template_attributes.dup From 294424e799862a0bad2504c3186656dfaea4a864 Mon Sep 17 00:00:00 2001 From: Mir Bhatia Date: Tue, 19 Nov 2024 23:12:25 +0100 Subject: [PATCH 030/129] Add header actions --- .../index_page_header_component.html.erb | 22 ++++++++-------- .../index_page_header_component.rb | 2 +- .../recurring_meetings/row_component.rb | 25 +++++++++++++------ .../recurring_meetings/table_component.rb | 3 +-- .../recurring_meetings_controller.rb | 18 ++++++++++--- modules/meeting/config/locales/en.yml | 2 ++ .../lib/open_project/meeting/engine.rb | 5 +++- 7 files changed, 52 insertions(+), 25 deletions(-) diff --git a/modules/meeting/app/components/recurring_meetings/index_page_header_component.html.erb b/modules/meeting/app/components/recurring_meetings/index_page_header_component.html.erb index 6a2c7cd04cbf..c55aabc4b3a1 100644 --- a/modules/meeting/app/components/recurring_meetings/index_page_header_component.html.erb +++ b/modules/meeting/app/components/recurring_meetings/index_page_header_component.html.erb @@ -3,17 +3,17 @@ header.with_description { page_description } header.with_breadcrumbs(breadcrumb_items) if render_create_button? - header.with_action_button(tag: :a, - href: dynamic_path, - scheme: :primary, - mobile_icon: :plus, - mobile_label: label_text, - aria: { label: accessibility_label_text }, - title: accessibility_label_text, - id: id, - test_selector: "add-recurring-meeting-button") do |button| - button.with_leading_visual_icon(icon: :plus) - label_text + header.with_action_menu(menu_arguments: { anchor_align: :end }, + button_arguments: { icon: "op-kebab-vertical", + classes: "hide-when-print", + "aria-label": "Menu" }) do |menu, _button| + menu.with_item(label: I18n.t(:label_recurring_meeting_series_edit), href: edit_recurring_meeting_path(@meeting)) + menu.with_item(label: I18n.t(:label_recurring_meeting_series_delete), + href: recurring_meeting_path(@meeting), + scheme: :danger, + form_arguments: { + method: :delete, data: { confirm: t("text_are_you_sure"), turbo: 'false' } + }) end end end %> diff --git a/modules/meeting/app/components/recurring_meetings/index_page_header_component.rb b/modules/meeting/app/components/recurring_meetings/index_page_header_component.rb index bfb54c682b9d..b889c0fb1a17 100644 --- a/modules/meeting/app/components/recurring_meetings/index_page_header_component.rb +++ b/modules/meeting/app/components/recurring_meetings/index_page_header_component.rb @@ -69,7 +69,7 @@ def page_title end def page_description - "Meeting schedule goes here" + @meeting.schedule.to_s # format? translations? end def breadcrumb_items diff --git a/modules/meeting/app/components/recurring_meetings/row_component.rb b/modules/meeting/app/components/recurring_meetings/row_component.rb index 22ed4ed05ad0..ba8bde9197e2 100644 --- a/modules/meeting/app/components/recurring_meetings/row_component.rb +++ b/modules/meeting/app/components/recurring_meetings/row_component.rb @@ -40,13 +40,16 @@ def column_args(column) def start_time if model["state"] == "open" || model["state"] == "closed" - link_to safe_join([helpers.format_date(model["start_time"]), helpers.format_time(model["start_time"], include_date: false)], " "), - project_meeting_path(Project.find(model["project_id"]), Meeting.find(model["id"])) + link_to start_time_title, project_meeting_path(Project.find(model["project_id"]), Meeting.find(model["id"])) else - safe_join([helpers.format_date(model["start_time"]), helpers.format_time(model["start_time"], include_date: false)], " ") + start_time_title end end + def start_time_title + safe_join([helpers.format_date(model["start_time"]), helpers.format_time(model["start_time"], include_date: false)], " ") + end + def relative_time render(OpPrimer::RelativeTimeComponent.new(datetime: model["start_time"], prefix: I18n.t(:label_on))) end @@ -76,7 +79,9 @@ def create if model["state"] != "open" render(Primer::Beta::Button.new( scheme: :default, - size: :medium + size: :medium, + tag: :a, + href: init_meeting_path(model.recurring_meeting.template, date: model["start_time"]) )) do |_c| I18n.t("label_recurring_meeting_create") end @@ -98,10 +103,12 @@ def action_menu "test-selector": "more-button" }) - ical_action(menu) + if initialized? + ical_action(menu) - if delete_allowed? - delete_action(menu) + if delete_allowed? + delete_action(menu) + end end end end @@ -134,5 +141,9 @@ def delete_allowed? def copy_allowed? User.current.allowed_in_project?(:create_meetings, Project.find(model["project_id"])) end + + def initialized? + model["id"].present? + end end end diff --git a/modules/meeting/app/components/recurring_meetings/table_component.rb b/modules/meeting/app/components/recurring_meetings/table_component.rb index 3df53f77317c..0ec35a8d3ef2 100644 --- a/modules/meeting/app/components/recurring_meetings/table_component.rb +++ b/modules/meeting/app/components/recurring_meetings/table_component.rb @@ -30,13 +30,12 @@ module RecurringMeetings class TableComponent < ::OpPrimer::BorderBoxTableComponent - # columns :title, :project_name, :start_time, :duration, :location columns :start_time, :relative_time, :last_edited, :status, :create # def sortable? # true # end - + # # def initial_sort # %i[start_time asc] # end diff --git a/modules/meeting/app/controllers/recurring_meetings_controller.rb b/modules/meeting/app/controllers/recurring_meetings_controller.rb index 5127f25213a3..da465d048bd5 100644 --- a/modules/meeting/app/controllers/recurring_meetings_controller.rb +++ b/modules/meeting/app/controllers/recurring_meetings_controller.rb @@ -4,8 +4,8 @@ class RecurringMeetingsController < ApplicationController include OpTurbo::FlashStreamHelper include OpTurbo::DialogStreamHelper - before_action :find_meeting, only: %i[show update details_dialog] - before_action :find_optional_project, only: %i[index show new create update details_dialog] + before_action :find_meeting, only: %i[show update details_dialog destroy edit] + before_action :find_optional_project, only: %i[index show new create update details_dialog destroy edit] before_action :authorize_global, only: %i[index new create] before_action :authorize, except: %i[index new create] @@ -51,8 +51,9 @@ def collect_meetings attributes["start_time"] = date attributes["state"] = "scheduled" attributes["template"] = false + attributes["id"] = nil + attributes["start_date"] = DateTime.now.to_s # temp meetings << Meeting.new(**attributes) - # @recurring_meeting.meetings.build(**attributes) end end @@ -96,6 +97,10 @@ def create end end + def edit + redirect_to controller: "meetings", action: "show", id: @recurring_meeting.template, status: :see_other + end + def update call = ::RecurringMeetings::UpdateService .new(model: @recurring_meeting, user: current_user) @@ -120,6 +125,13 @@ def update end end + def destroy + @recurring_meeting.destroy + flash[:notice] = I18n.t(:notice_successful_delete) + + redirect_to action: "index", project_id: @project + end + private def find_optional_project diff --git a/modules/meeting/config/locales/en.yml b/modules/meeting/config/locales/en.yml index 22d886ff907e..ab0fb140133e 100644 --- a/modules/meeting/config/locales/en.yml +++ b/modules/meeting/config/locales/en.yml @@ -130,6 +130,8 @@ en: label_template: "Template" label_recurring_meeting_create: "Create from template" label_recurring_meeting_cancel: "Cancel this occurrence" + label_recurring_meeting_series_edit: "Edit meeting series" + label_recurring_meeting_series_delete: "Delete meeting series" label_upcoming_meetings: "Upcoming meetings" label_past_meetings: "Past meetings" label_upcoming_meetings_short: "Upcoming" diff --git a/modules/meeting/lib/open_project/meeting/engine.rb b/modules/meeting/lib/open_project/meeting/engine.rb index 72bdf88ab8d0..4fb05e6eb962 100644 --- a/modules/meeting/lib/open_project/meeting/engine.rb +++ b/modules/meeting/lib/open_project/meeting/engine.rb @@ -62,7 +62,10 @@ class Engine < ::Rails::Engine permissible_on: :project, require: :member permission :delete_meetings, - { meetings: [:destroy] }, + { + meetings: [:destroy], + recurring_meetings: [:destroy] + }, permissible_on: :project, require: :member permission :meetings_send_invite, From fc7dd185ecad79316cbea75d795f03332f7e673e Mon Sep 17 00:00:00 2001 From: Mir Bhatia Date: Wed, 20 Nov 2024 13:47:00 +0100 Subject: [PATCH 031/129] Add initial recurring meeting frequency label --- modules/meeting/app/components/meetings/row_component.rb | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/modules/meeting/app/components/meetings/row_component.rb b/modules/meeting/app/components/meetings/row_component.rb index 8878632c2d2e..fdcd110677ff 100644 --- a/modules/meeting/app/components/meetings/row_component.rb +++ b/modules/meeting/app/components/meetings/row_component.rb @@ -43,7 +43,7 @@ def project_name end def title - link_to model.title, project_meeting_path(model.project, model) + safe_join([(link_to model.title, project_meeting_path(model.project, model)), recurring_label], " ") end def start_time @@ -120,6 +120,12 @@ def delete_action(menu) end end + def recurring_label + if model.recurring_meeting.present? + render(Primer::Beta::Label.new) { model.recurring_meeting.human_frequency } + end + end + def delete_allowed? User.current.allowed_in_project?(:delete_meetings, model.project) end From a33ed25f8998278ebc998454eb4801892b49f813 Mon Sep 17 00:00:00 2001 From: Mir Bhatia Date: Wed, 20 Nov 2024 17:26:40 +0100 Subject: [PATCH 032/129] Add index page --- .../app/components/meetings/row_component.rb | 32 +++++++++++++++---- .../components/meetings/table_component.rb | 10 ++++-- ...rb => show_page_header_component.html.erb} | 0 ...onent.rb => show_page_header_component.rb} | 2 +- ...> show_page_sub_header_component.html.erb} | 0 ...t.rb => show_page_sub_header_component.rb} | 2 +- .../views/recurring_meetings/index.html.erb | 4 +-- .../views/recurring_meetings/show.html.erb | 4 +-- 8 files changed, 39 insertions(+), 15 deletions(-) rename modules/meeting/app/components/recurring_meetings/{index_page_header_component.html.erb => show_page_header_component.html.erb} (100%) rename modules/meeting/app/components/recurring_meetings/{index_page_header_component.rb => show_page_header_component.rb} (97%) rename modules/meeting/app/components/recurring_meetings/{index_sub_header_component.html.erb => show_page_sub_header_component.html.erb} (100%) rename modules/meeting/app/components/recurring_meetings/{index_sub_header_component.rb => show_page_sub_header_component.rb} (96%) diff --git a/modules/meeting/app/components/meetings/row_component.rb b/modules/meeting/app/components/meetings/row_component.rb index fdcd110677ff..8fd1053799b4 100644 --- a/modules/meeting/app/components/meetings/row_component.rb +++ b/modules/meeting/app/components/meetings/row_component.rb @@ -43,11 +43,19 @@ def project_name end def title - safe_join([(link_to model.title, project_meeting_path(model.project, model)), recurring_label], " ") + if recurring? + link_to model.title, recurring_meeting_path(model) + else + safe_join([(link_to model.title, project_meeting_path(model.project, model)), recurring_label], " ") + end end def start_time - safe_join([helpers.format_date(model.start_time), helpers.format_time(model.start_time, include_date: false)], " ") + if recurring? + helpers.format_time(model.start_time, include_date: false) + else + safe_join([helpers.format_date(model.start_time), helpers.format_time(model.start_time, include_date: false)], " ") + end end def duration @@ -55,11 +63,17 @@ def duration end def location - helpers.auto_link(model.location, + helpers.auto_link(recurring? ? model.template.location : model.location, link: :all, html: { target: "_blank" }) end + def frequency + return unless recurring? + + model.human_frequency + end + def button_links [ action_menu @@ -74,11 +88,11 @@ def action_menu data: { "test-selector": "more-button" }) - if copy_allowed? + if copy_allowed? && !recurring? copy_action(menu) end - ical_action(menu) + ical_action(menu) unless recurring? if delete_allowed? delete_action(menu) @@ -121,7 +135,9 @@ def delete_action(menu) end def recurring_label - if model.recurring_meeting.present? + if recurring? + render(Primer::Beta::Label.new) { model.human_frequency } + elsif model.recurring_meeting.present? render(Primer::Beta::Label.new) { model.recurring_meeting.human_frequency } end end @@ -133,5 +149,9 @@ def delete_allowed? def copy_allowed? User.current.allowed_in_project?(:create_meetings, model.project) end + + def recurring? + model.is_a?(RecurringMeeting) + end end end diff --git a/modules/meeting/app/components/meetings/table_component.rb b/modules/meeting/app/components/meetings/table_component.rb index 0759ad0e5f63..1a53046fab4b 100644 --- a/modules/meeting/app/components/meetings/table_component.rb +++ b/modules/meeting/app/components/meetings/table_component.rb @@ -32,7 +32,7 @@ module Meetings class TableComponent < ::OpPrimer::BorderBoxTableComponent options :current_project # used to determine if displaying the projects column - columns :title, :project_name, :start_time, :duration, :location + columns :title, :project_name, :start_time, :duration, :location, :frequency def sortable? true @@ -57,8 +57,10 @@ def header_args(column) def headers @headers ||= [ [:title, { caption: Meeting.human_attribute_name(:title) }], + recurring? ? [:frequency, { caption: I18n.t("activerecord.attributes.recurring_meeting.frequency") }] : nil, + [:start_time, + { caption: recurring? ? I18n.t("activerecord.attributes.meeting.start_time") : I18n.t(:label_meeting_date_and_time) }], current_project.blank? ? [:project_name, { caption: Meeting.human_attribute_name(:project) }] : nil, - [:start_time, { caption: I18n.t(:label_meeting_date_and_time) }], [:duration, { caption: Meeting.human_attribute_name(:duration) }], [:location, { caption: Meeting.human_attribute_name(:location) }] ].compact @@ -67,5 +69,9 @@ def headers def columns @columns ||= headers.map(&:first) end + + def recurring? + model.first.is_a?(RecurringMeeting) + end end end diff --git a/modules/meeting/app/components/recurring_meetings/index_page_header_component.html.erb b/modules/meeting/app/components/recurring_meetings/show_page_header_component.html.erb similarity index 100% rename from modules/meeting/app/components/recurring_meetings/index_page_header_component.html.erb rename to modules/meeting/app/components/recurring_meetings/show_page_header_component.html.erb diff --git a/modules/meeting/app/components/recurring_meetings/index_page_header_component.rb b/modules/meeting/app/components/recurring_meetings/show_page_header_component.rb similarity index 97% rename from modules/meeting/app/components/recurring_meetings/index_page_header_component.rb rename to modules/meeting/app/components/recurring_meetings/show_page_header_component.rb index b889c0fb1a17..8eec8d940d1c 100644 --- a/modules/meeting/app/components/recurring_meetings/index_page_header_component.rb +++ b/modules/meeting/app/components/recurring_meetings/show_page_header_component.rb @@ -29,7 +29,7 @@ # ++ module RecurringMeetings - class IndexPageHeaderComponent < ApplicationComponent + class ShowPageHeaderComponent < ApplicationComponent include OpPrimer::ComponentHelpers include ApplicationHelper diff --git a/modules/meeting/app/components/recurring_meetings/index_sub_header_component.html.erb b/modules/meeting/app/components/recurring_meetings/show_page_sub_header_component.html.erb similarity index 100% rename from modules/meeting/app/components/recurring_meetings/index_sub_header_component.html.erb rename to modules/meeting/app/components/recurring_meetings/show_page_sub_header_component.html.erb diff --git a/modules/meeting/app/components/recurring_meetings/index_sub_header_component.rb b/modules/meeting/app/components/recurring_meetings/show_page_sub_header_component.rb similarity index 96% rename from modules/meeting/app/components/recurring_meetings/index_sub_header_component.rb rename to modules/meeting/app/components/recurring_meetings/show_page_sub_header_component.rb index eb028901f8a0..c8d58110b1c7 100644 --- a/modules/meeting/app/components/recurring_meetings/index_sub_header_component.rb +++ b/modules/meeting/app/components/recurring_meetings/show_page_sub_header_component.rb @@ -30,7 +30,7 @@ module RecurringMeetings # rubocop:disable OpenProject/AddPreviewForViewComponent - class IndexSubHeaderComponent < ApplicationComponent + class ShowPageSubHeaderComponent < ApplicationComponent # rubocop:enable OpenProject/AddPreviewForViewComponent include ApplicationHelper diff --git a/modules/meeting/app/views/recurring_meetings/index.html.erb b/modules/meeting/app/views/recurring_meetings/index.html.erb index d1329b15f5a1..8d4f0af4b8d5 100644 --- a/modules/meeting/app/views/recurring_meetings/index.html.erb +++ b/modules/meeting/app/views/recurring_meetings/index.html.erb @@ -33,7 +33,5 @@ See COPYRIGHT and LICENSE files for more details. <% if @recurring_meetings.empty? -%> <%= no_results_box %> <% else -%> - <% @recurring_meetings.each do |meeting| %> - <%= link_to meeting.title, recurring_meeting_path(meeting) %> - <% end %> + <%= render Meetings::TableComponent.new(rows: @recurring_meetings, current_project: @project) %> <% end -%> diff --git a/modules/meeting/app/views/recurring_meetings/show.html.erb b/modules/meeting/app/views/recurring_meetings/show.html.erb index e4159d008d8a..f05dc65c9cda 100644 --- a/modules/meeting/app/views/recurring_meetings/show.html.erb +++ b/modules/meeting/app/views/recurring_meetings/show.html.erb @@ -28,8 +28,8 @@ See COPYRIGHT and LICENSE files for more details. ++#%> <% html_title t(:label_recurring_meeting_plural) %> -<%= render(RecurringMeetings::IndexPageHeaderComponent.new(project: @project, meeting: @recurring_meeting)) %> -<%= render(RecurringMeetings::IndexSubHeaderComponent.new( project: @project)) %> +<%= render(RecurringMeetings::ShowPageHeaderComponent.new(project: @project, meeting: @recurring_meeting)) %> +<%= render(RecurringMeetings::ShowPageSubHeaderComponent.new( project: @project)) %> <% if @recurring_meeting.nil? -%> <%= no_results_box %> From 004d8c0d32f3e4d8eb2a22b3cb7ceb0b0029eddf Mon Sep 17 00:00:00 2001 From: Mir Bhatia Date: Wed, 20 Nov 2024 18:19:05 +0100 Subject: [PATCH 033/129] Add soft delete and restore for occurrences --- .../recurring_meetings/row_component.rb | 19 ++++++++++++++++-- .../meeting_agenda_items/create_contract.rb | 1 + .../app/controllers/meetings_controller.rb | 20 +++++++++++++++++-- modules/meeting/app/models/meeting.rb | 3 +++ .../models/queries/meetings/meeting_query.rb | 1 + modules/meeting/config/locales/en.yml | 1 + modules/meeting/config/routes.rb | 1 + .../lib/open_project/meeting/engine.rb | 2 +- 8 files changed, 43 insertions(+), 5 deletions(-) diff --git a/modules/meeting/app/components/recurring_meetings/row_component.rb b/modules/meeting/app/components/recurring_meetings/row_component.rb index ba8bde9197e2..987a1deb60d7 100644 --- a/modules/meeting/app/components/recurring_meetings/row_component.rb +++ b/modules/meeting/app/components/recurring_meetings/row_component.rb @@ -76,7 +76,7 @@ def status end def create - if model["state"] != "open" + if model["state"] == "scheduled" render(Primer::Beta::Button.new( scheme: :default, size: :medium, @@ -103,13 +103,17 @@ def action_menu "test-selector": "more-button" }) - if initialized? + if initialized? && !cancelled? ical_action(menu) if delete_allowed? delete_action(menu) end end + + if cancelled? + restore_action(menu) + end end end @@ -134,6 +138,13 @@ def delete_action(menu) end end + def restore_action(menu) + menu.with_item(label: I18n.t(:label_recurring_meeting_restore), + href: restore_meeting_path(Meeting.find(model["id"]))) do |item| + item.with_leading_visual_icon(icon: :history) + end + end + def delete_allowed? User.current.allowed_in_project?(:delete_meetings, Project.find(model["project_id"])) end @@ -145,5 +156,9 @@ def copy_allowed? def initialized? model["id"].present? end + + def cancelled? + model["state"] == "cancelled" + end end end diff --git a/modules/meeting/app/contracts/meeting_agenda_items/create_contract.rb b/modules/meeting/app/contracts/meeting_agenda_items/create_contract.rb index fa1e7ab69c5c..ff0ecac37477 100644 --- a/modules/meeting/app/contracts/meeting_agenda_items/create_contract.rb +++ b/modules/meeting/app/contracts/meeting_agenda_items/create_contract.rb @@ -36,6 +36,7 @@ def self.assignable_meetings(user) StructuredMeeting .open .not_templated + .not_cancelled .visible(user) end diff --git a/modules/meeting/app/controllers/meetings_controller.rb b/modules/meeting/app/controllers/meetings_controller.rb index deb45a128371..784477dee91c 100644 --- a/modules/meeting/app/controllers/meetings_controller.rb +++ b/modules/meeting/app/controllers/meetings_controller.rb @@ -74,7 +74,11 @@ def show format.html do html_title "#{t(:label_meeting)}: #{@meeting.title}" if @meeting.is_a?(StructuredMeeting) - render(Meetings::ShowComponent.new(meeting: @meeting, project: @project), layout: true) + if @meeting.state == "cancelled" + render_404 + else + render(Meetings::ShowComponent.new(meeting: @meeting, project: @project), layout: true) + end elsif @meeting.agenda.present? && @meeting.agenda.locked? params[:tab] ||= "minutes" end @@ -187,7 +191,13 @@ def copy def destroy type = @meeting.recurring_meeting_id - @meeting.destroy + + if type.nil? + @meeting.destroy + else + @meeting.update_attribute :state, :cancelled + end + flash[:notice] = I18n.t(:notice_successful_delete) if type.nil? @@ -197,6 +207,12 @@ def destroy end end + def restore + @meeting.update_attribute :state, :open + + redirect_to controller: "recurring_meetings", action: "show", id: @meeting.recurring_meeting_id, status: :see_other + end + def edit respond_to do |format| format.turbo_stream do diff --git a/modules/meeting/app/models/meeting.rb b/modules/meeting/app/models/meeting.rb index af17ac0cb7d3..f84731773d10 100644 --- a/modules/meeting/app/models/meeting.rb +++ b/modules/meeting/app/models/meeting.rb @@ -55,6 +55,9 @@ class Meeting < ApplicationRecord scope :templated, -> { where(template: true) } scope :not_templated, -> { where(template: false) } + scope :cancelled, -> { where(state: :cancelled) } + scope :not_cancelled, -> { where.not(id: cancelled) } + scope :from_tomorrow, -> { where(["start_time >= ?", Date.tomorrow.beginning_of_day]) } scope :from_today, -> { where(["start_time >= ?", Time.zone.today.beginning_of_day]) } scope :with_users_by_date, -> { diff --git a/modules/meeting/app/models/queries/meetings/meeting_query.rb b/modules/meeting/app/models/queries/meetings/meeting_query.rb index 229a4c180982..4d01cc87ac29 100644 --- a/modules/meeting/app/models/queries/meetings/meeting_query.rb +++ b/modules/meeting/app/models/queries/meetings/meeting_query.rb @@ -43,6 +43,7 @@ def results def default_scope Meeting .not_templated + .not_cancelled .visible(user) end end diff --git a/modules/meeting/config/locales/en.yml b/modules/meeting/config/locales/en.yml index ab0fb140133e..117ebd2f86a8 100644 --- a/modules/meeting/config/locales/en.yml +++ b/modules/meeting/config/locales/en.yml @@ -130,6 +130,7 @@ en: label_template: "Template" label_recurring_meeting_create: "Create from template" label_recurring_meeting_cancel: "Cancel this occurrence" + label_recurring_meeting_restore: "Restore this occurrence" label_recurring_meeting_series_edit: "Edit meeting series" label_recurring_meeting_series_delete: "Delete meeting series" label_upcoming_meetings: "Upcoming meetings" diff --git a/modules/meeting/config/routes.rb b/modules/meeting/config/routes.rb index 276445ee3622..502d81cfd34e 100644 --- a/modules/meeting/config/routes.rb +++ b/modules/meeting/config/routes.rb @@ -77,6 +77,7 @@ post :notify get :history get :init + get :restore end resources :agenda_items, controller: "meeting_agenda_items" do collection do diff --git a/modules/meeting/lib/open_project/meeting/engine.rb b/modules/meeting/lib/open_project/meeting/engine.rb index 4fb05e6eb962..55d2e8cf1a81 100644 --- a/modules/meeting/lib/open_project/meeting/engine.rb +++ b/modules/meeting/lib/open_project/meeting/engine.rb @@ -48,7 +48,7 @@ class Engine < ::Rails::Engine recurring_meetings: %i[index show new create] }, permissible_on: :project permission :create_meetings, - { meetings: %i[new create copy new_dialog], + { meetings: %i[new create copy new_dialog restore], "meetings/menus": %i[show] }, permissible_on: :project, require: :member, From 01274c4b3512d7de7d4e59866c48221d5bec83f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Thu, 21 Nov 2024 10:59:23 +0100 Subject: [PATCH 034/129] Refine implementation of scheduled meetings using skeletons --- .../recurring_meetings/row_component.rb | 73 +++++++++++-------- .../recurring_meetings_controller.rb | 39 ++++------ modules/meeting/app/models/meeting.rb | 4 + .../meeting/app/models/recurring_meeting.rb | 16 ++++ .../app/models/recurring_meetings/skeleton.rb | 31 ++++++++ 5 files changed, 106 insertions(+), 57 deletions(-) create mode 100644 modules/meeting/app/models/recurring_meetings/skeleton.rb diff --git a/modules/meeting/app/components/recurring_meetings/row_component.rb b/modules/meeting/app/components/recurring_meetings/row_component.rb index 987a1deb60d7..7c727d3ba53a 100644 --- a/modules/meeting/app/components/recurring_meetings/row_component.rb +++ b/modules/meeting/app/components/recurring_meetings/row_component.rb @@ -30,6 +30,14 @@ module RecurringMeetings class RowComponent < ::OpPrimer::BorderBoxRowComponent + def meeting + model + end + + def instantiated? + meeting.is_a?(Meeting) + end + def column_args(column) if column == :title { style: "grid-column: span 2" } @@ -39,52 +47,59 @@ def column_args(column) end def start_time - if model["state"] == "open" || model["state"] == "closed" - link_to start_time_title, project_meeting_path(Project.find(model["project_id"]), Meeting.find(model["id"])) + if instantiated? + link_to start_time_title, meeting_path(meeting) else start_time_title end end def start_time_title - safe_join([helpers.format_date(model["start_time"]), helpers.format_time(model["start_time"], include_date: false)], " ") + helpers.format_time(meeting.start_time, include_date: true) end def relative_time - render(OpPrimer::RelativeTimeComponent.new(datetime: model["start_time"], prefix: I18n.t(:label_on))) + render(OpPrimer::RelativeTimeComponent.new(datetime: meeting.start_time, prefix: I18n.t(:label_on))) end def last_edited - safe_join([helpers.format_date(model["updated_at"]), helpers.format_time(model["updated_at"], include_date: false)], " ") + return unless instantiated? + + helpers.format_time(meeting.updated_at, include_date: true) end def status - case model["state"] - when "open" - scheme = :success - when "scheduled" - scheme = :secondary - when "cancelled" - scheme = :severe - when "closed" - scheme = :secondary - end + state = instantiated? ? model.state : :scheduled + scheme = status_scheme(state) render(Primer::Beta::Label.new(scheme:)) do - render(Primer::Beta::Text.new) { t("label_meeting_state_#{model['state']}") } + render(Primer::Beta::Text.new) { t("label_meeting_state_#{state}") } + end + end + + def status_scheme(state) + case state + when "open" + :success + when "cancelled" + :severe + else + :secondary end end def create - if model["state"] == "scheduled" - render(Primer::Beta::Button.new( - scheme: :default, - size: :medium, - tag: :a, - href: init_meeting_path(model.recurring_meeting.template, date: model["start_time"]) - )) do |_c| - I18n.t("label_recurring_meeting_create") - end + return if instantiated? + + render( + Primer::Beta::Button.new( + scheme: :default, + size: :medium, + tag: :a, + href: init_meeting_path(model.recurring_meeting.id, start_time: model.start_time) + ) + ) do |_c| + I18n.t("label_recurring_meeting_create") end end @@ -103,7 +118,7 @@ def action_menu "test-selector": "more-button" }) - if initialized? && !cancelled? + if instantiated? && !cancelled? ical_action(menu) if delete_allowed? @@ -153,12 +168,8 @@ def copy_allowed? User.current.allowed_in_project?(:create_meetings, Project.find(model["project_id"])) end - def initialized? - model["id"].present? - end - def cancelled? - model["state"] == "cancelled" + instantiated? && model.cancelled? end end end diff --git a/modules/meeting/app/controllers/recurring_meetings_controller.rb b/modules/meeting/app/controllers/recurring_meetings_controller.rb index da465d048bd5..2569199ffcaa 100644 --- a/modules/meeting/app/controllers/recurring_meetings_controller.rb +++ b/modules/meeting/app/controllers/recurring_meetings_controller.rb @@ -27,42 +27,29 @@ def new end def show - @meetings = collect_meetings + @meetings = collect_meetings(true) respond_to do |format| format.html end end - def collect_meetings - meetings = [] - @recurring_meeting.meetings.each do |meeting| - meetings << meeting unless meeting.template == true - end + def collect_meetings(upcoming) + meetings = @recurring_meeting + .instances(upcoming:) + .index_by(&:start_date) - template_attributes = @recurring_meeting.meetings.where(template: true).first.attributes @recurring_meeting - .schedule - .occurrences(@recurring_meeting.end_date) - .each do |date| - exists = meetings.find { |m| m["start_time"] == date } - unless exists - attributes = template_attributes.dup - attributes["start_time"] = date - attributes["state"] = "scheduled" - attributes["template"] = false - attributes["id"] = nil - attributes["start_date"] = DateTime.now.to_s # temp - meetings << Meeting.new(**attributes) - end + .scheduled_occurrences(count: meetings.count + 5, upcoming:) + .map do |occurrence| + date = occurrence.to_date + meetings[date.to_s] || skeleton_meeting(date) end + end - # SELECT COALESCE(meetings.start_time, meetings.recurring_date) start_time_test FROM - # (SELECT * FROM (VALUES (to_timestamp('2024-10-06 11:30:00', 'YYYY-MM-DD HH24:MI:SS'), 'ABC'), - # (to_timestamp('2024-10-06 11:30:00', 'YYYY-MM-DD HH24:MI:SS'), 'ABC')) AS t (recurring_date, recurring_title) - # FULL JOIN meetings ON meetings.start_time = t.recurring_date) meetings ORDER BY start_time_test; - - meetings + def skeleton_meeting(date) + start_time = @recurring_meeting.start_time.change(year: date.year, month: date.month, day: date.day) + RecurringMeetings::Skeleton.new(start_time:, recurring_meeting: @recurring_meeting) end def details_dialog diff --git a/modules/meeting/app/models/meeting.rb b/modules/meeting/app/models/meeting.rb index f84731773d10..57e938abf85c 100644 --- a/modules/meeting/app/models/meeting.rb +++ b/modules/meeting/app/models/meeting.rb @@ -60,6 +60,10 @@ class Meeting < ApplicationRecord scope :from_tomorrow, -> { where(["start_time >= ?", Date.tomorrow.beginning_of_day]) } scope :from_today, -> { where(["start_time >= ?", Time.zone.today.beginning_of_day]) } + + scope :upcoming, -> { where("start_time + (interval '1 hour' * duration) >= ?", Time.current) } + scope :past, -> { where("start_time + (interval '1 hour' * duration) < ?", Time.current) } + scope :with_users_by_date, -> { order("#{Meeting.table_name}.title ASC") .includes({ participants: :user }, :author) diff --git a/modules/meeting/app/models/recurring_meeting.rb b/modules/meeting/app/models/recurring_meeting.rb index b1ba7b87a268..cbf19ff8e898 100644 --- a/modules/meeting/app/models/recurring_meeting.rb +++ b/modules/meeting/app/models/recurring_meeting.rb @@ -58,6 +58,22 @@ def schedule end end + def scheduled_occurrences(count:, upcoming: true) + if upcoming + schedule.next_occurrences(count, Time.current) + else + schedule.previous_occurrences(count, Time.current) + end + end + + def instances(upcoming: true) + direction = upcoming ? :upcoming : :past + + meetings + .not_templated + .public_send(direction) + end + private def frequency_rule diff --git a/modules/meeting/app/models/recurring_meetings/skeleton.rb b/modules/meeting/app/models/recurring_meetings/skeleton.rb new file mode 100644 index 000000000000..97ca9174fc47 --- /dev/null +++ b/modules/meeting/app/models/recurring_meetings/skeleton.rb @@ -0,0 +1,31 @@ +#-- 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 RecurringMeetings + Skeleton = Data.define(:start_time, :recurring_meeting) +end From 4937ee1d17e84849a7862122df6bc3b5f51b3e14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Thu, 21 Nov 2024 11:09:32 +0100 Subject: [PATCH 035/129] Add template link --- .../show_page_header_component.html.erb | 16 ++++++++++++++-- modules/meeting/config/locales/en.yml | 1 + 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/modules/meeting/app/components/recurring_meetings/show_page_header_component.html.erb b/modules/meeting/app/components/recurring_meetings/show_page_header_component.html.erb index c55aabc4b3a1..0156033717c1 100644 --- a/modules/meeting/app/components/recurring_meetings/show_page_header_component.html.erb +++ b/modules/meeting/app/components/recurring_meetings/show_page_header_component.html.erb @@ -2,13 +2,25 @@ header.with_title { page_title } header.with_description { page_description } header.with_breadcrumbs(breadcrumb_items) + + header.with_action_button( + tag: :a, + mobile_label: I18n.t("recurring_meeting.template.label_edit_template"), + mobile_icon: :pencil, + size: :medium, + href: meeting_path(@meeting.template) + ) do |button| + button.with_leading_visual_icon(icon: :pencil) + I18n.t("recurring_meeting.template.label_edit_template") + end + if render_create_button? header.with_action_menu(menu_arguments: { anchor_align: :end }, button_arguments: { icon: "op-kebab-vertical", classes: "hide-when-print", "aria-label": "Menu" }) do |menu, _button| - menu.with_item(label: I18n.t(:label_recurring_meeting_series_edit), href: edit_recurring_meeting_path(@meeting)) - menu.with_item(label: I18n.t(:label_recurring_meeting_series_delete), + menu.with_item(label: I18n.t(:label_recurring_meeting_series_edit), href: edit_recurring_meeting_path(@meeting)) + menu.with_item(label: I18n.t(:label_recurring_meeting_series_delete), href: recurring_meeting_path(@meeting), scheme: :danger, form_arguments: { diff --git a/modules/meeting/config/locales/en.yml b/modules/meeting/config/locales/en.yml index 117ebd2f86a8..72a70db83cdc 100644 --- a/modules/meeting/config/locales/en.yml +++ b/modules/meeting/config/locales/en.yml @@ -196,6 +196,7 @@ en: recurring_meeting: template: + label_edit_template: "Edit template" banner_html: > You are currently editing a template of a meeting series: %{link}. Every new instance of a meeting in this series will use this template. From 003989434f51d28335aa5acaf0e505627f1cff0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Thu, 21 Nov 2024 11:35:00 +0100 Subject: [PATCH 036/129] Paginate --- .../show_page_sub_header_component.html.erb | 22 ++++++++++++------- .../show_page_sub_header_component.rb | 6 +++-- .../recurring_meetings_controller.rb | 18 +++++++++++---- .../meeting/app/models/recurring_meeting.rb | 18 ++++++++++----- .../views/recurring_meetings/show.html.erb | 2 +- 5 files changed, 45 insertions(+), 21 deletions(-) diff --git a/modules/meeting/app/components/recurring_meetings/show_page_sub_header_component.html.erb b/modules/meeting/app/components/recurring_meetings/show_page_sub_header_component.html.erb index a7f0ed672dd1..bb7af0046c89 100644 --- a/modules/meeting/app/components/recurring_meetings/show_page_sub_header_component.html.erb +++ b/modules/meeting/app/components/recurring_meetings/show_page_sub_header_component.html.erb @@ -3,12 +3,18 @@ "application-target": "dynamic", "filter--filters-form-output-format-value": "json", })) do |subheader| - - # subheader.with_filter_component do - # render(Meetings::MeetingFilterButtonComponent.new(query: @query, project: @project)) - # end - - # subheader.with_bottom_pane_component(mt: 0) do - # render(Meetings::MeetingFiltersComponent.new(query: @query, project: @project)) - # end + subheader.with_filter_component do + render(Primer::Alpha::SegmentedControl.new("aria-label": I18n.t(:label_filter_plural))) do |control| + control.with_item(tag: :a, + href: recurring_meeting_path(@meeting, direction: :past), + label: t(:label_past_meetings_short), + title: t(:label_past_meetings), + selected: @params[:direction] == "past") + control.with_item(tag: :a, + href: recurring_meeting_path(@meeting), + label: t(:label_upcoming_meetings_short), + title: t(:label_upcoming_meetings), + selected: @params[:direction] != "past") + end + end end %> diff --git a/modules/meeting/app/components/recurring_meetings/show_page_sub_header_component.rb b/modules/meeting/app/components/recurring_meetings/show_page_sub_header_component.rb index c8d58110b1c7..60c17bf60ac1 100644 --- a/modules/meeting/app/components/recurring_meetings/show_page_sub_header_component.rb +++ b/modules/meeting/app/components/recurring_meetings/show_page_sub_header_component.rb @@ -34,10 +34,12 @@ class ShowPageSubHeaderComponent < ApplicationComponent # rubocop:enable OpenProject/AddPreviewForViewComponent include ApplicationHelper - def initialize(query: nil, project: nil) + def initialize(meeting:, project: nil, params:) super - @query = query + + @meeting = meeting @project = project + @params = params end def dynamic_path diff --git a/modules/meeting/app/controllers/recurring_meetings_controller.rb b/modules/meeting/app/controllers/recurring_meetings_controller.rb index 2569199ffcaa..828a2572fb7d 100644 --- a/modules/meeting/app/controllers/recurring_meetings_controller.rb +++ b/modules/meeting/app/controllers/recurring_meetings_controller.rb @@ -1,5 +1,6 @@ class RecurringMeetingsController < ApplicationController include Layout + include PaginationHelper include OpTurbo::ComponentStream include OpTurbo::FlashStreamHelper include OpTurbo::DialogStreamHelper @@ -27,20 +28,29 @@ def new end def show - @meetings = collect_meetings(true) + @direction = params[:direction] + if params[:direction] == "past" + @meetings = @recurring_meeting + .instances(upcoming: false) + .page(page_param) + .per_page(per_page_param) + else + @meetings = upcoming_meetings + @total_count = @recurring_meeting.remaining_occurrences.count - @meetings.count + end respond_to do |format| format.html end end - def collect_meetings(upcoming) + def upcoming_meetings meetings = @recurring_meeting - .instances(upcoming:) + .instances(upcoming: true) .index_by(&:start_date) @recurring_meeting - .scheduled_occurrences(count: meetings.count + 5, upcoming:) + .scheduled_occurrences(limit: meetings.count + 5) .map do |occurrence| date = occurrence.to_date meetings[date.to_s] || skeleton_meeting(date) diff --git a/modules/meeting/app/models/recurring_meeting.rb b/modules/meeting/app/models/recurring_meeting.rb index cbf19ff8e898..f4ffb19821bf 100644 --- a/modules/meeting/app/models/recurring_meeting.rb +++ b/modules/meeting/app/models/recurring_meeting.rb @@ -53,16 +53,22 @@ def human_date_of_week end def schedule - IceCube::Schedule.new(start_time, end_time: end_date).tap do |s| - s.add_recurrence_rule count_rule(frequency_rule) + @schedule ||= begin + IceCube::Schedule.new(start_time, end_time: end_date).tap do |s| + s.add_recurrence_rule count_rule(frequency_rule) + end end end - def scheduled_occurrences(count:, upcoming: true) - if upcoming - schedule.next_occurrences(count, Time.current) + def scheduled_occurrences(limit:) + schedule.next_occurrences(limit, Time.current) + end + + def remaining_occurrences + if end_date.present? + schedule.occurrences_between(Time.current, end_date) else - schedule.previous_occurrences(count, Time.current) + schedule.remaining_occurrences(Time.current) end end diff --git a/modules/meeting/app/views/recurring_meetings/show.html.erb b/modules/meeting/app/views/recurring_meetings/show.html.erb index f05dc65c9cda..21fcdc45647c 100644 --- a/modules/meeting/app/views/recurring_meetings/show.html.erb +++ b/modules/meeting/app/views/recurring_meetings/show.html.erb @@ -29,7 +29,7 @@ See COPYRIGHT and LICENSE files for more details. <% html_title t(:label_recurring_meeting_plural) %> <%= render(RecurringMeetings::ShowPageHeaderComponent.new(project: @project, meeting: @recurring_meeting)) %> -<%= render(RecurringMeetings::ShowPageSubHeaderComponent.new( project: @project)) %> +<%= render(RecurringMeetings::ShowPageSubHeaderComponent.new(project: @project, meeting: @recurring_meeting, params:)) %> <% if @recurring_meeting.nil? -%> <%= no_results_box %> From 16cfc237bb8cf32fe5227962531262242c2083b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Thu, 21 Nov 2024 22:02:51 +0100 Subject: [PATCH 037/129] Move init of meeting to recurring --- .../meetings/header_component.html.erb | 2 +- .../components/meetings/header_component.rb | 8 +++ .../recurring_meetings/row_component.rb | 3 +- .../app/contracts/meetings/create_contract.rb | 11 +++ .../app/controllers/meetings_controller.rb | 17 +---- .../recurring_meetings_controller.rb | 69 ++++++++++++++----- .../meeting/app/models/recurring_meeting.rb | 1 + .../app/services/meetings/copy_service.rb | 26 +------ modules/meeting/config/routes.rb | 2 +- .../lib/open_project/meeting/engine.rb | 9 ++- 10 files changed, 87 insertions(+), 61 deletions(-) diff --git a/modules/meeting/app/components/meetings/header_component.html.erb b/modules/meeting/app/components/meetings/header_component.html.erb index 540fa30f5719..16a5e58ad92e 100644 --- a/modules/meeting/app/components/meetings/header_component.html.erb +++ b/modules/meeting/app/components/meetings/header_component.html.erb @@ -75,7 +75,7 @@ item.with_leading_visual_icon(icon: :clock) # or :check TBD end - menu.with_item(label: t("label_meeting_delete"), + menu.with_item(label: delete_label, scheme: :danger, href: meeting_path(@meeting), form_arguments: { diff --git a/modules/meeting/app/components/meetings/header_component.rb b/modules/meeting/app/components/meetings/header_component.rb index 926d31270a0a..1fb6b2fd46b7 100644 --- a/modules/meeting/app/components/meetings/header_component.rb +++ b/modules/meeting/app/components/meetings/header_component.rb @@ -88,5 +88,13 @@ def parent_element { href: home_path, text: helpers.organization_name } end end + + def delete_label + if @meeting.recurring_meeting.present? + I18n.t("label_recurring_meeting_cancel") + else + I18n.t("label_meeting_delete") + end + end end end diff --git a/modules/meeting/app/components/recurring_meetings/row_component.rb b/modules/meeting/app/components/recurring_meetings/row_component.rb index 7c727d3ba53a..0098123ab8f5 100644 --- a/modules/meeting/app/components/recurring_meetings/row_component.rb +++ b/modules/meeting/app/components/recurring_meetings/row_component.rb @@ -96,7 +96,8 @@ def create scheme: :default, size: :medium, tag: :a, - href: init_meeting_path(model.recurring_meeting.id, start_time: model.start_time) + data: { "turbo-method": "post"}, + href: init_recurring_meeting_path(model.recurring_meeting.id, start_time: model.start_time) ) ) do |_c| I18n.t("label_recurring_meeting_create") diff --git a/modules/meeting/app/contracts/meetings/create_contract.rb b/modules/meeting/app/contracts/meetings/create_contract.rb index 6591590c8020..23c0b57d12d0 100644 --- a/modules/meeting/app/contracts/meetings/create_contract.rb +++ b/modules/meeting/app/contracts/meetings/create_contract.rb @@ -29,8 +29,11 @@ module Meetings class CreateContract < BaseContract attribute :type + attribute :recurring_meeting_id + validate :user_allowed_to_add validate :type_in_allowed + validate :recurring_meeting_visible private @@ -45,5 +48,13 @@ def user_allowed_to_add errors.add :base, :error_unauthorized end end + + def recurring_meeting_visible + return if model.recurring_meeting.nil? + + unless user.allowed_in_project?(:view_meetings, model.recurring_meeting.project) + errors.add :base, :error_unauthorized + end + end end end diff --git a/modules/meeting/app/controllers/meetings_controller.rb b/modules/meeting/app/controllers/meetings_controller.rb index 784477dee91c..a9736edac2a0 100644 --- a/modules/meeting/app/controllers/meetings_controller.rb +++ b/modules/meeting/app/controllers/meetings_controller.rb @@ -27,7 +27,7 @@ #++ class MeetingsController < ApplicationController - before_action :load_and_authorize_in_optional_project, only: %i[index new new_dialog show create history init] + before_action :load_and_authorize_in_optional_project, only: %i[index new new_dialog show create history] before_action :verify_activities_module_activated, only: %i[history] before_action :determine_date_range, only: %i[history] before_action :determine_author, only: %i[history] @@ -36,9 +36,9 @@ class MeetingsController < ApplicationController before_action :set_activity, only: %i[history] before_action :find_copy_from_meeting, only: %i[create] before_action :convert_params, only: %i[create update update_participants] - before_action :authorize, except: %i[index new create update_title update_details update_participants change_state new_dialog init] + before_action :authorize, except: %i[index new create update_title update_details update_participants change_state new_dialog] before_action :authorize_global, - only: %i[index new create update_title update_details update_participants change_state new_dialog init] + only: %i[index new create update_title update_details update_participants change_state new_dialog] before_action :prevent_template_destruction, only: :destroy helper :watchers @@ -94,17 +94,6 @@ def check_for_updates end end - def init - call = ::Meetings::CopyService - .new(user: current_user, model: @meeting) - .call(attributes: params, attach_to_recurring: true) - if call.success? - redirect_to controller: "recurring_meetings", action: "show", id: @meeting.recurring_meeting_id, status: :see_other - else - # Flash + redirect? - end - end - def create # rubocop:disable Metrics/AbcSize call = if @copy_from diff --git a/modules/meeting/app/controllers/recurring_meetings_controller.rb b/modules/meeting/app/controllers/recurring_meetings_controller.rb index 828a2572fb7d..fe8b4d905f6b 100644 --- a/modules/meeting/app/controllers/recurring_meetings_controller.rb +++ b/modules/meeting/app/controllers/recurring_meetings_controller.rb @@ -5,7 +5,7 @@ class RecurringMeetingsController < ApplicationController include OpTurbo::FlashStreamHelper include OpTurbo::DialogStreamHelper - before_action :find_meeting, only: %i[show update details_dialog destroy edit] + before_action :find_meeting, only: %i[show update details_dialog destroy edit init] before_action :find_optional_project, only: %i[index show new create update details_dialog destroy edit] before_action :authorize_global, only: %i[index new create] before_action :authorize, except: %i[index new create] @@ -21,13 +21,19 @@ def index else RecurringMeeting.visible end + + respond_to do |format| + format.html do + render :index, locals: { menu_name: project_or_global_menu } + end + end end def new @recurring_meeting = RecurringMeeting.new(project: @project) end - def show + def show # rubocop:disable Metrics/AbcSize @direction = params[:direction] if params[:direction] == "past" @meetings = @recurring_meeting @@ -40,28 +46,27 @@ def show end respond_to do |format| - format.html + format.html do + render :show, locals: { menu_name: project_or_global_menu } + end end end - def upcoming_meetings - meetings = @recurring_meeting - .instances(upcoming: true) - .index_by(&:start_date) - - @recurring_meeting - .scheduled_occurrences(limit: meetings.count + 5) - .map do |occurrence| - date = occurrence.to_date - meetings[date.to_s] || skeleton_meeting(date) + def init + call = ::Meetings::CopyService + .new(user: current_user, model: @recurring_meeting.template) + .call(attributes: init_params, + copy_agenda: true, + copy_attachments: true, + send_notifications: false) + if call.success? + redirect_to project_meeting_path(call.result.project, call.result), status: :see_other + else + flash[:error] = call.message + redirect_to action: :show, id: @recurring_meeting end end - def skeleton_meeting(date) - start_time = @recurring_meeting.start_time.change(year: date.year, month: date.month, day: date.day) - RecurringMeetings::Skeleton.new(start_time:, recurring_meeting: @recurring_meeting) - end - def details_dialog respond_with_dialog Meetings::Index::DialogComponent.new( meeting: @recurring_meeting, @@ -131,6 +136,34 @@ def destroy private + def init_params + { + start_time: DateTime.parse(params[:start_time]), + recurring_meeting: @recurring_meeting + } + end + + def upcoming_meetings + meetings = @recurring_meeting + .instances(upcoming: true) + .index_by(&:start_date) + + merged = @recurring_meeting + .scheduled_occurrences(limit: 5) + .map do |occurrence| + date = occurrence.to_date + meetings.delete(date.to_s) || skeleton_meeting(date) + end + + # Ensure we keep any remaining future meetings that exceed the limit + merged + meetings.values.sort_by(&:start_date) + end + + def skeleton_meeting(date) + start_time = @recurring_meeting.start_time.change(year: date.year, month: date.month, day: date.day) + RecurringMeetings::Skeleton.new(start_time:, recurring_meeting: @recurring_meeting) + end + def find_optional_project @project = Project.find(params[:project_id]) if params[:project_id].present? rescue ActiveRecord::RecordNotFound diff --git a/modules/meeting/app/models/recurring_meeting.rb b/modules/meeting/app/models/recurring_meeting.rb index f4ffb19821bf..d596b5f21c35 100644 --- a/modules/meeting/app/models/recurring_meeting.rb +++ b/modules/meeting/app/models/recurring_meeting.rb @@ -78,6 +78,7 @@ def instances(upcoming: true) meetings .not_templated .public_send(direction) + .order(start_time: :asc) end private diff --git a/modules/meeting/app/services/meetings/copy_service.rb b/modules/meeting/app/services/meetings/copy_service.rb index b5bd5cd65cf9..f351e61b363a 100644 --- a/modules/meeting/app/services/meetings/copy_service.rb +++ b/modules/meeting/app/services/meetings/copy_service.rb @@ -42,13 +42,9 @@ def initialize(user:, model:, contract_class: Meetings::CreateContract) self.contract_class = contract_class end - def call(send_notifications: nil, save: true, copy_agenda: true, copy_attachments: false, attach_to_recurring: false, attributes: {}) + def call(send_notifications: nil, save: true, copy_agenda: true, copy_attachments: false, attributes: {}) if save - if attach_to_recurring - create_recurring(meeting, attributes) - else - create(meeting, attributes, send_notifications:, copy_agenda:, copy_attachments:, attach_to_recurring:) - end + create(meeting, attributes, send_notifications:, copy_agenda:, copy_attachments:) else build(meeting, attributes) end @@ -56,24 +52,13 @@ def call(send_notifications: nil, save: true, copy_agenda: true, copy_attachment protected - def create(meeting, attribute_overrides, send_notifications:, copy_agenda:, copy_attachments:, attach_to_recurring:) + def create(meeting, attribute_overrides, send_notifications:, copy_agenda:, copy_attachments:) Meetings::CreateService .new(user:, contract_class:) .call(**copied_attributes(meeting, attribute_overrides).merge(send_notifications:).symbolize_keys) .on_success do |call| copy_meeting_agenda(call.result) if copy_agenda copy_meeting_attachment(call.result) if copy_attachments - attach_to_recurring(call.result) if attach_to_recurring - end - end - - def create_recurring(meeting, attributes) - Meetings::CreateService.new(user:, contract_class:) - .call(meeting.attributes.slice(*writable_meeting_attributes(meeting)).merge("start_time" => DateTime.parse(attributes["date"])).merge(send_notifications: false).symbolize_keys) - .on_success do |call| - copy_meeting_agenda(call.result) - copy_meeting_attachment(call.result) - attach_to_recurring(call.result) end end @@ -152,10 +137,5 @@ def copy_structured_meeting_participants(copy) copy.participants << copied_participant end end - - def attach_to_recurring(copy) - copy.recurring_meeting = meeting.recurring_meeting - copy.save! # ? - end end end diff --git a/modules/meeting/config/routes.rb b/modules/meeting/config/routes.rb index 502d81cfd34e..1bb29a50bff7 100644 --- a/modules/meeting/config/routes.rb +++ b/modules/meeting/config/routes.rb @@ -59,6 +59,7 @@ resources :recurring_meetings do member do get :details_dialog + post :init end end @@ -76,7 +77,6 @@ put :change_state post :notify get :history - get :init get :restore end resources :agenda_items, controller: "meeting_agenda_items" do diff --git a/modules/meeting/lib/open_project/meeting/engine.rb b/modules/meeting/lib/open_project/meeting/engine.rb index 55d2e8cf1a81..5f5111d0650b 100644 --- a/modules/meeting/lib/open_project/meeting/engine.rb +++ b/modules/meeting/lib/open_project/meeting/engine.rb @@ -40,7 +40,7 @@ class Engine < ::Rails::Engine bundled: true do project_module :meetings do permission :view_meetings, - { meetings: %i[index show check_for_updates download_ics participants_dialog history init], + { meetings: %i[index show check_for_updates download_ics participants_dialog history], meeting_agendas: %i[history show diff], meeting_minutes: %i[history show diff], "meetings/menus": %i[show], @@ -48,8 +48,11 @@ class Engine < ::Rails::Engine recurring_meetings: %i[index show new create] }, permissible_on: :project permission :create_meetings, - { meetings: %i[new create copy new_dialog restore], - "meetings/menus": %i[show] }, + { + meetings: %i[new create copy new_dialog restore], + recurring_meetings: %i[new create copy init], + "meetings/menus": %i[show] + }, permissible_on: :project, require: :member, contract_actions: { meetings: %i[create] } From 9e549f77a4d0f228c572f05f18d0ab840fb38367 Mon Sep 17 00:00:00 2001 From: Mir Bhatia Date: Fri, 22 Nov 2024 11:18:50 +0100 Subject: [PATCH 038/129] Add schedule in words --- .../side_panel/details_component.html.erb | 2 +- .../show_page_header_component.rb | 2 +- .../meeting/app/models/recurring_meeting.rb | 39 +++++++++++++++---- modules/meeting/config/locales/en.yml | 5 +++ 4 files changed, 38 insertions(+), 10 deletions(-) diff --git a/modules/meeting/app/components/meetings/side_panel/details_component.html.erb b/modules/meeting/app/components/meetings/side_panel/details_component.html.erb index 04cb729d584b..ebda288d5af7 100644 --- a/modules/meeting/app/components/meetings/side_panel/details_component.html.erb +++ b/modules/meeting/app/components/meetings/side_panel/details_component.html.erb @@ -36,7 +36,7 @@ details.with_row(mt: 2) do render_meeting_attribute_row(:calendar) do render(Primer::Beta::Text.new) do - @meeting.recurring_meeting.human_date_of_week + @meeting.recurring_meeting.human_day_of_week end end end if @meeting.recurring_meeting.frequency != "daily" diff --git a/modules/meeting/app/components/recurring_meetings/show_page_header_component.rb b/modules/meeting/app/components/recurring_meetings/show_page_header_component.rb index 8eec8d940d1c..a0e641a09665 100644 --- a/modules/meeting/app/components/recurring_meetings/show_page_header_component.rb +++ b/modules/meeting/app/components/recurring_meetings/show_page_header_component.rb @@ -69,7 +69,7 @@ def page_title end def page_description - @meeting.schedule.to_s # format? translations? + @meeting.schedule_in_words end def breadcrumb_items diff --git a/modules/meeting/app/models/recurring_meeting.rb b/modules/meeting/app/models/recurring_meeting.rb index d596b5f21c35..e6b8d7b1aad2 100644 --- a/modules/meeting/app/models/recurring_meeting.rb +++ b/modules/meeting/app/models/recurring_meeting.rb @@ -37,7 +37,6 @@ class RecurringMeeting < ApplicationRecord # so it can be passed to the template on save virtual_attribute :location do nil - end virtual_attribute :duration do nil @@ -47,19 +46,43 @@ def human_frequency I18n.t("recurring_meeting.frequency.#{frequency}") end - def human_date_of_week - day_of_the_week = I18n.l(start_time, format: "%A") - I18n.t("recurring_meeting.frequency.every_weekday", day_of_the_week:) + def human_day_of_week + I18n.t("recurring_meeting.frequency.every_weekday", day_of_the_week: weekday) + end + + def weekday + I18n.l(start_time, format: "%A") + end + + def month + I18n.l(start_time, format: "%B") + end + + def date + start_time.day.ordinalize end def schedule - @schedule ||= begin - IceCube::Schedule.new(start_time, end_time: end_date).tap do |s| - s.add_recurrence_rule count_rule(frequency_rule) - end + @schedule ||= IceCube::Schedule.new(start_time, end_time: end_date).tap do |s| + s.add_recurrence_rule count_rule(frequency_rule) end end + def schedule_in_words + base = case frequency + when "daily" + human_frequency + when "weekly", "biweekly" + I18n.t("recurring_meeting.in_words.weekly", frequency: human_frequency, weekday:) + when "monthly" + I18n.t("recurring_meeting.in_words.monthly", frequency: human_frequency, date:) + when "yearly" + I18n.t("recurring_meeting.in_words.yearly", frequency: human_frequency, date:, month:) + end + + I18n.t("recurring_meeting.in_words.full", base:, time: format_time(start_time, include_date: false), end_date:) + end + def scheduled_occurrences(limit:) schedule.next_occurrences(limit, Time.current) end diff --git a/modules/meeting/config/locales/en.yml b/modules/meeting/config/locales/en.yml index 72a70db83cdc..575bed9f2df7 100644 --- a/modules/meeting/config/locales/en.yml +++ b/modules/meeting/config/locales/en.yml @@ -212,6 +212,11 @@ en: specific_date: "A specific date" iterations: "A number of occurrences" starts: "Starts" + in_words: + weekly: "%{frequency} on %{weekday}" + monthly: "%{frequency} on the %{date}" + yearly: "%{frequency} on the %{date} of %{month}" + full: "%{base} at %{time}, ends on %{end_date}" notice_successful_notification: "Notification sent successfully" notice_timezone_missing: No time zone is set and %{zone} is assumed. To choose your time zone, please click here. From a7ee620440a8a4399f5daffb62877de97d0791f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Fri, 22 Nov 2024 11:15:34 +0100 Subject: [PATCH 039/129] Add show series action item --- .../app/components/meetings/row_component.rb | 16 ++++++++++++++++ modules/meeting/config/locales/en.yml | 1 + 2 files changed, 17 insertions(+) diff --git a/modules/meeting/app/components/meetings/row_component.rb b/modules/meeting/app/components/meetings/row_component.rb index 8fd1053799b4..f315ad0ba83e 100644 --- a/modules/meeting/app/components/meetings/row_component.rb +++ b/modules/meeting/app/components/meetings/row_component.rb @@ -88,6 +88,11 @@ def action_menu data: { "test-selector": "more-button" }) + + if recurring_instance? + show_series_action(menu) + end + if copy_allowed? && !recurring? copy_action(menu) end @@ -100,6 +105,13 @@ def action_menu end end + def show_series_action(menu) + menu.with_item(label: I18n.t(:label_view_meeting_series), + href: recurring_meeting_path(model.recurring_meeting_id)) do |item| + item.with_leading_visual_icon(icon: :eye) + end + end + def copy_action(menu) menu.with_item(label: I18n.t(:label_meeting_copy), href: copy_meeting_path(model), @@ -150,6 +162,10 @@ def copy_allowed? User.current.allowed_in_project?(:create_meetings, model.project) end + def recurring_instance? + model.recurring_meeting_id.present? + end + def recurring? model.is_a?(RecurringMeeting) end diff --git a/modules/meeting/config/locales/en.yml b/modules/meeting/config/locales/en.yml index 575bed9f2df7..5fcf4eb54407 100644 --- a/modules/meeting/config/locales/en.yml +++ b/modules/meeting/config/locales/en.yml @@ -145,6 +145,7 @@ en: label_notify: "Send for review" label_icalendar: "Send iCalendar" label_icalendar_download: "Download iCalendar event" + label_view_meeting_series: "View meeting series" label_version: "Version" label_time_zone: "Time zone" label_start_date: "Start date" From 577b306adbd8724f38da327d59e6c66b732caf80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Fri, 22 Nov 2024 11:33:17 +0100 Subject: [PATCH 040/129] Implement changed show page of occurrence --- .../components/meetings/header_component.html.erb | 5 ++++- .../app/components/meetings/header_component.rb | 3 +++ .../meetings/header_infoline_component.html.erb | 12 ++++++++---- .../components/meetings/header_infoline_component.rb | 1 + .../meetings/side_panel/details_component.html.erb | 10 ++++++++++ .../meetings/side_panel/details_component.rb | 1 + modules/meeting/config/locales/en.yml | 2 ++ 7 files changed, 29 insertions(+), 5 deletions(-) diff --git a/modules/meeting/app/components/meetings/header_component.html.erb b/modules/meeting/app/components/meetings/header_component.html.erb index 16a5e58ad92e..bed81ee17629 100644 --- a/modules/meeting/app/components/meetings/header_component.html.erb +++ b/modules/meeting/app/components/meetings/header_component.html.erb @@ -21,6 +21,9 @@ placeholder: Meeting.human_attribute_name(:title),) if @meeting.template? "#{@meeting.title} (#{I18n.t(:label_template)})" + elsif @series.present? + concat render(Primer::Beta::Text.new) { format_date(@meeting.start_time) } + concat render(Primer::Beta::Text.new(color: :subtle)) { " (#{@series.title})" } else @meeting.title end @@ -38,7 +41,7 @@ data: { 'turbo-stream': true } }) do |item| item.with_leading_visual_icon(icon: :pencil) - end if @meeting.editable? + end if @meeting.editable? && !@series unless @meeting.template? menu.with_item(label: t(:button_copy), diff --git a/modules/meeting/app/components/meetings/header_component.rb b/modules/meeting/app/components/meetings/header_component.rb index 1fb6b2fd46b7..8ed9ec71dbd5 100644 --- a/modules/meeting/app/components/meetings/header_component.rb +++ b/modules/meeting/app/components/meetings/header_component.rb @@ -32,6 +32,7 @@ class HeaderComponent < ApplicationComponent include OpTurbo::Streamable include OpPrimer::ComponentHelpers include Primer::FetchOrFallbackHelper + include Redmine::I18n STATE_DEFAULT = :show STATE_EDIT = :edit @@ -70,6 +71,8 @@ def breadcrumb_items def meeting_element if @meeting.templated? I18n.t(:label_template) + elsif @series.present? + format_date(@meeting.start_time) else @meeting.title end diff --git a/modules/meeting/app/components/meetings/header_infoline_component.html.erb b/modules/meeting/app/components/meetings/header_infoline_component.html.erb index d5ac7c726ff7..defc4b1cf52a 100644 --- a/modules/meeting/app/components/meetings/header_infoline_component.html.erb +++ b/modules/meeting/app/components/meetings/header_infoline_component.html.erb @@ -1,8 +1,12 @@ <%= render(Primer::BaseComponent.new(tag: :div)) do %> - <%= t("label_meeting_created_by") %> - <%= render(Primer::Beta::Link.new(href: user_path(@meeting.author), - underline: false, - target: "_blank")) { @meeting.author.name } %>. + <% if @series %> + <%= t("recurring_meeting.occurrence.infoline", title: @series.title) %> + <% else %> + <%= t(:label_meeting_created_by) %> + <%= render(Primer::Beta::Link.new(href: user_path(@meeting.author), + underline: false, + target: "_blank")) { @meeting.author.name } %>. + <% end %> <%= t("label_meeting_last_updated") %> <%= render(OpPrimer::RelativeTimeComponent.new(datetime: last_updated_at, prefix: I18n.t(:label_on))) %>. <% end %> diff --git a/modules/meeting/app/components/meetings/header_infoline_component.rb b/modules/meeting/app/components/meetings/header_infoline_component.rb index 25032fd9ac57..3e152948e790 100644 --- a/modules/meeting/app/components/meetings/header_infoline_component.rb +++ b/modules/meeting/app/components/meetings/header_infoline_component.rb @@ -31,6 +31,7 @@ class HeaderInfolineComponent < ApplicationComponent def initialize(meeting) super @meeting = meeting + @series = meeting.recurring_meeting end def last_updated_at diff --git a/modules/meeting/app/components/meetings/side_panel/details_component.html.erb b/modules/meeting/app/components/meetings/side_panel/details_component.html.erb index ebda288d5af7..a0cc27b31703 100644 --- a/modules/meeting/app/components/meetings/side_panel/details_component.html.erb +++ b/modules/meeting/app/components/meetings/side_panel/details_component.html.erb @@ -41,6 +41,16 @@ end end if @meeting.recurring_meeting.frequency != "daily" else + if @series.present? + details.with_row(mb: 2) do + render_meeting_attribute_row(:iterations) do + render(Primer::Beta::Link.new(href: recurring_meeting_path(@series), target: "_blank")) do + @series.title + end + end + end + end + details.with_row do render_meeting_attribute_row(:calendar) do render(Primer::Beta::Text.new) do diff --git a/modules/meeting/app/components/meetings/side_panel/details_component.rb b/modules/meeting/app/components/meetings/side_panel/details_component.rb index fa4282bee4f3..26527bbca4b8 100644 --- a/modules/meeting/app/components/meetings/side_panel/details_component.rb +++ b/modules/meeting/app/components/meetings/side_panel/details_component.rb @@ -36,6 +36,7 @@ def initialize(meeting:) super @meeting = meeting + @series = meeting.recurring_meeting end private diff --git a/modules/meeting/config/locales/en.yml b/modules/meeting/config/locales/en.yml index 5fcf4eb54407..b49ee514f457 100644 --- a/modules/meeting/config/locales/en.yml +++ b/modules/meeting/config/locales/en.yml @@ -196,6 +196,8 @@ en: empty_text: "Drag items here or create a new one" recurring_meeting: + occurrence: + infoline: "This meeting is part of a recurring meeting series." template: label_edit_template: "Edit template" banner_html: > From ba7c051b0e454b2439f603977e1e8dd274148e4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Fri, 22 Nov 2024 15:43:32 +0100 Subject: [PATCH 041/129] Add interval, fewer frequencies --- .../meetings/index/form_component.html.erb | 4 ++ .../recurring_meetings/base_contract.rb | 1 + .../app/forms/recurring_meeting/frequency.rb | 2 +- .../app/forms/recurring_meeting/interval.rb | 38 +++++++++++++++++++ .../meeting/app/models/recurring_meeting.rb | 6 +-- modules/meeting/config/locales/en.yml | 8 ++-- ...43600_add_interval_to_recurring_meeting.rb | 6 +++ 7 files changed, 57 insertions(+), 8 deletions(-) create mode 100644 modules/meeting/app/forms/recurring_meeting/interval.rb create mode 100644 modules/meeting/db/migrate/20241122143600_add_interval_to_recurring_meeting.rb diff --git a/modules/meeting/app/components/meetings/index/form_component.html.erb b/modules/meeting/app/components/meetings/index/form_component.html.erb index 0550aaa45105..3aba053ac1af 100644 --- a/modules/meeting/app/components/meetings/index/form_component.html.erb +++ b/modules/meeting/app/components/meetings/index/form_component.html.erb @@ -57,6 +57,10 @@ render(RecurringMeeting::Frequency.new(f)) end + modal_body.with_row(mt: 3) do + render(RecurringMeeting::Interval.new(f)) + end + modal_body.with_row(mt: 3) do render(RecurringMeeting::EndAfter.new(f)) end diff --git a/modules/meeting/app/contracts/recurring_meetings/base_contract.rb b/modules/meeting/app/contracts/recurring_meetings/base_contract.rb index 138d93557dcc..56316cc19d61 100644 --- a/modules/meeting/app/contracts/recurring_meetings/base_contract.rb +++ b/modules/meeting/app/contracts/recurring_meetings/base_contract.rb @@ -42,6 +42,7 @@ def self.model attribute :end_after attribute :end_date attribute :iterations + attribute :interval # Virtual attributes for the form attribute :duration diff --git a/modules/meeting/app/forms/recurring_meeting/frequency.rb b/modules/meeting/app/forms/recurring_meeting/frequency.rb index a960efc1257a..17f507d3c420 100644 --- a/modules/meeting/app/forms/recurring_meeting/frequency.rb +++ b/modules/meeting/app/forms/recurring_meeting/frequency.rb @@ -32,7 +32,7 @@ class RecurringMeeting::Frequency < ApplicationForm name: "frequency", label: I18n.t("activerecord.attributes.recurring_meeting.frequency"), ) do |list| - %i[daily weekly monthly yearly].each do |value| + RecurringMeeting.frequencies.keys.each do |value| label = I18n.t(:"recurring_meeting.frequency.#{value}") list.option(label:, value:) end diff --git a/modules/meeting/app/forms/recurring_meeting/interval.rb b/modules/meeting/app/forms/recurring_meeting/interval.rb new file mode 100644 index 000000000000..9d598246dd6b --- /dev/null +++ b/modules/meeting/app/forms/recurring_meeting/interval.rb @@ -0,0 +1,38 @@ +#-- 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. +#++ + +class RecurringMeeting::Interval < ApplicationForm + form do |meeting_form| + meeting_form.text_field( + name: :interval, + type: :number, + label: I18n.t("activerecord.attributes.recurring_meeting.interval"), + caption: I18n.t("recurring_meeting.interval.instructions") + ) + end +end diff --git a/modules/meeting/app/models/recurring_meeting.rb b/modules/meeting/app/models/recurring_meeting.rb index e6b8d7b1aad2..b61361db7bae 100644 --- a/modules/meeting/app/models/recurring_meeting.rb +++ b/modules/meeting/app/models/recurring_meeting.rb @@ -11,10 +11,8 @@ class RecurringMeeting < ApplicationRecord enum frequency: { daily: 0, - weekly: 1, - biweekly: 2, - monthly: 3, - yearly: 4 + work_days: 1, + weekly: 2 }.freeze, _prefix: true enum end_after: { diff --git a/modules/meeting/config/locales/en.yml b/modules/meeting/config/locales/en.yml index b49ee514f457..3971443ee405 100644 --- a/modules/meeting/config/locales/en.yml +++ b/modules/meeting/config/locales/en.yml @@ -63,6 +63,7 @@ en: title: "Title" recurring_meeting: frequency: "Frequency" + interval: "Interval" end_after: "End after" iterations: "Occurrences" errors: @@ -196,6 +197,9 @@ en: empty_text: "Drag items here or create a new one" recurring_meeting: + interval: + instructions: > + Enter the number of days or weeks between each occurrence. occurrence: infoline: "This meeting is part of a recurring meeting series." template: @@ -207,10 +211,8 @@ en: frequency: every_weekday: "Every %{day_of_the_week}" daily: "Daily" + work_days: "Work days" weekly: "Weekly" - biweekly: "Bi-weekly (Every 2 weeks)" - monthly: "Monthly" - yearly: "Yearly" end_after: specific_date: "A specific date" iterations: "A number of occurrences" diff --git a/modules/meeting/db/migrate/20241122143600_add_interval_to_recurring_meeting.rb b/modules/meeting/db/migrate/20241122143600_add_interval_to_recurring_meeting.rb new file mode 100644 index 000000000000..678abd8040c3 --- /dev/null +++ b/modules/meeting/db/migrate/20241122143600_add_interval_to_recurring_meeting.rb @@ -0,0 +1,6 @@ +class AddIntervalToRecurringMeeting < ActiveRecord::Migration[7.1] + def change + add_column :recurring_meetings, :interval, :integer, + default: 1, null: false + end +end From 6e84e3737a7e41d77c96de01a688a16446174c4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Fri, 22 Nov 2024 16:10:47 +0100 Subject: [PATCH 042/129] Add interval to schedule --- app/models/setting/aliases.rb | 8 +++++ .../recurring_meetings_controller.rb | 2 +- .../meeting/app/models/recurring_meeting.rb | 30 ++++++++++++------- modules/meeting/config/locales/en.yml | 2 +- 4 files changed, 29 insertions(+), 13 deletions(-) diff --git a/app/models/setting/aliases.rb b/app/models/setting/aliases.rb index 98aa5bbbf4fd..154aabab71db 100644 --- a/app/models/setting/aliases.rb +++ b/app/models/setting/aliases.rb @@ -51,5 +51,13 @@ def host_without_protocol def optional_port_from_host_name Setting.host_name&.split(":")&.[](1) end + + ## + # Get the names of working days + # @return [Array] the names of the working days + def working_day_names + weekdays = %i[monday tuesday wednesday thursday friday saturday sunday] + Setting.working_days.map { |day| weekdays[day - 1] } + end end end diff --git a/modules/meeting/app/controllers/recurring_meetings_controller.rb b/modules/meeting/app/controllers/recurring_meetings_controller.rb index fe8b4d905f6b..95e20edf3811 100644 --- a/modules/meeting/app/controllers/recurring_meetings_controller.rb +++ b/modules/meeting/app/controllers/recurring_meetings_controller.rb @@ -189,7 +189,7 @@ def recurring_meeting_params params .require(:meeting) .permit(:title, :location, :start_time_hour, :duration, :start_date, - :frequency, :end_after, :end_date, :iterations) + :interval, :frequency, :end_after, :end_date, :iterations) end def find_copy_from_meeting diff --git a/modules/meeting/app/models/recurring_meeting.rb b/modules/meeting/app/models/recurring_meeting.rb index b61361db7bae..14e27d42dd52 100644 --- a/modules/meeting/app/models/recurring_meeting.rb +++ b/modules/meeting/app/models/recurring_meeting.rb @@ -11,7 +11,7 @@ class RecurringMeeting < ApplicationRecord enum frequency: { daily: 0, - work_days: 1, + working_days: 1, weekly: 2 }.freeze, _prefix: true @@ -63,20 +63,17 @@ def date def schedule @schedule ||= IceCube::Schedule.new(start_time, end_time: end_date).tap do |s| s.add_recurrence_rule count_rule(frequency_rule) + exclude_non_working_days(s) if frequency_working_days? end end def schedule_in_words base = case frequency - when "daily" - human_frequency - when "weekly", "biweekly" - I18n.t("recurring_meeting.in_words.weekly", frequency: human_frequency, weekday:) - when "monthly" - I18n.t("recurring_meeting.in_words.monthly", frequency: human_frequency, date:) - when "yearly" - I18n.t("recurring_meeting.in_words.yearly", frequency: human_frequency, date:, month:) - end + when "daily" + human_frequency + else + I18n.t("recurring_meeting.in_words.weekly", frequency: human_frequency, weekday:) + end I18n.t("recurring_meeting.in_words.full", base:, time: format_time(start_time, include_date: false), end_date:) end @@ -105,7 +102,18 @@ def instances(upcoming: true) private def frequency_rule - IceCube::Rule.public_send(frequency) + case frequency + when "daily" + IceCube::Rule.daily(interval) + when "working_days" + IceCube::Rule + .weekly(interval) + .day(*Setting.working_day_names) + when "weekly" + IceCube::Rule.weekly(interval) + else + raise NotImplementedError + end end def count_rule(rule) diff --git a/modules/meeting/config/locales/en.yml b/modules/meeting/config/locales/en.yml index 3971443ee405..4ac961896281 100644 --- a/modules/meeting/config/locales/en.yml +++ b/modules/meeting/config/locales/en.yml @@ -211,7 +211,7 @@ en: frequency: every_weekday: "Every %{day_of_the_week}" daily: "Daily" - work_days: "Work days" + working_days: "Working days" weekly: "Weekly" end_after: specific_date: "A specific date" From fd060ff51b6d26b4f0a82a3ede8cc583f3a0d116 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Fri, 22 Nov 2024 16:41:01 +0100 Subject: [PATCH 043/129] Try to add non working days --- modules/meeting/app/models/recurring_meeting.rb | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/modules/meeting/app/models/recurring_meeting.rb b/modules/meeting/app/models/recurring_meeting.rb index 14e27d42dd52..e1e6a786f5a7 100644 --- a/modules/meeting/app/models/recurring_meeting.rb +++ b/modules/meeting/app/models/recurring_meeting.rb @@ -101,6 +101,15 @@ def instances(upcoming: true) private + def exclude_non_working_days(schedule) + NonWorkingDay + .where(date: start_date...) + .pluck(:date) + .each do |date| + schedule.add_exception_time(date) + end + end + def frequency_rule case frequency when "daily" From fe845015147c31275edab147a980c32460a81cee Mon Sep 17 00:00:00 2001 From: Mir Bhatia Date: Fri, 22 Nov 2024 16:53:54 +0100 Subject: [PATCH 044/129] Update meetings index to display recurring occurrences correctly --- .../app/components/meetings/row_component.rb | 67 ++++++++++++------- modules/meeting/config/locales/en.yml | 6 ++ 2 files changed, 49 insertions(+), 24 deletions(-) diff --git a/modules/meeting/app/components/meetings/row_component.rb b/modules/meeting/app/components/meetings/row_component.rb index f315ad0ba83e..596f8a184361 100644 --- a/modules/meeting/app/components/meetings/row_component.rb +++ b/modules/meeting/app/components/meetings/row_component.rb @@ -45,11 +45,20 @@ def project_name def title if recurring? link_to model.title, recurring_meeting_path(model) + elsif recurring_meeting.present? + occurrence_title else - safe_join([(link_to model.title, project_meeting_path(model.project, model)), recurring_label], " ") + link_to model.title, project_meeting_path(model.project, model) end end + def occurrence_title + safe_join( + [(link_to model.title, project_meeting_path(model.project, model)), + (link_to recurring_label, recurring_meeting_path(recurring_meeting))], " " + ) + end + def start_time if recurring? helpers.format_time(model.start_time, include_date: false) @@ -89,30 +98,29 @@ def action_menu "test-selector": "more-button" }) - if recurring_instance? - show_series_action(menu) - end - - if copy_allowed? && !recurring? + if recurring? + nil + elsif recurring_meeting.present? + view_meeting_series(menu) + else copy_action(menu) end ical_action(menu) unless recurring? - - if delete_allowed? - delete_action(menu) - end + delete_action(menu) end end - def show_series_action(menu) - menu.with_item(label: I18n.t(:label_view_meeting_series), - href: recurring_meeting_path(model.recurring_meeting_id)) do |item| - item.with_leading_visual_icon(icon: :eye) + def view_meeting_series(menu) + menu.with_item(label: I18n.t(:label_recurring_meeting_view), + href: recurring_meeting_path(recurring_meeting)) do |item| + item.with_leading_visual_icon(icon: :iterations) end end def copy_action(menu) + return unless copy_allowed? + menu.with_item(label: I18n.t(:label_meeting_copy), href: copy_meeting_path(model), content_arguments: { @@ -136,21 +144,30 @@ def ical_action(menu) end def delete_action(menu) - menu.with_item(label: I18n.t(:label_meeting_delete), + return unless delete_allowed? + + menu.with_item(label: recurring_meeting.present? ? I18n.t(:label_recurring_meeting_delete) : I18n.t(:label_meeting_delete), scheme: :danger, href: meeting_path(model), form_arguments: { - method: :delete, data: { confirm: I18n.t("text_are_you_sure"), turbo: false } + method: :delete, data: { confirm: delete_confirm_message, turbo: false } }) do |item| item.with_leading_visual_icon(icon: :trash) end end + def delete_confirm_message + if recurring_meeting.present? + I18n.t(:label_recurring_meeting_delete_confirmation, name: recurring_meeting.title) + else + I18n.t("text_are_you_sure") + end + end + def recurring_label - if recurring? - render(Primer::Beta::Label.new) { model.human_frequency } - elsif model.recurring_meeting.present? - render(Primer::Beta::Label.new) { model.recurring_meeting.human_frequency } + render(Primer::BaseComponent.new(tag: :span, color: :muted)) do + concat render(Primer::Beta::Octicon.new(icon: :iterations, mr: 1, ml: 1)) + concat render(Primer::Beta::Text.new(font_weight: :bold, font_size: :small)) { recurring_meeting.human_frequency } end end @@ -162,12 +179,14 @@ def copy_allowed? User.current.allowed_in_project?(:create_meetings, model.project) end - def recurring_instance? - model.recurring_meeting_id.present? - end - def recurring? model.is_a?(RecurringMeeting) end + + def recurring_meeting + return if recurring? + + model.recurring_meeting + end end end diff --git a/modules/meeting/config/locales/en.yml b/modules/meeting/config/locales/en.yml index 4ac961896281..507acf821efb 100644 --- a/modules/meeting/config/locales/en.yml +++ b/modules/meeting/config/locales/en.yml @@ -129,8 +129,14 @@ en: label_recurring_meeting_new: "New recurring meeting" label_recurring_meeting_plural: "Recurring meetings" label_template: "Template" + label_recurring_meeting_view: "View meeting series" label_recurring_meeting_create: "Create from template" label_recurring_meeting_cancel: "Cancel this occurrence" + label_recurring_meeting_delete: "Delete occurrence" + label_recurring_meeting_delete_confirmation: > + This meeting is part of a series called %{name}. + This will only delete this particular occurrence and not the entire series. + Do you want to continue? label_recurring_meeting_restore: "Restore this occurrence" label_recurring_meeting_series_edit: "Edit meeting series" label_recurring_meeting_series_delete: "Delete meeting series" From c15a148677cb2b979b497af3fde1341f0b099fd6 Mon Sep 17 00:00:00 2001 From: Mir Bhatia Date: Fri, 22 Nov 2024 17:49:57 +0100 Subject: [PATCH 045/129] Update schedule in words --- .../meeting/app/models/recurring_meeting.rb | 26 ++++++++++++------- modules/meeting/config/locales/en.yml | 10 ++++--- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/modules/meeting/app/models/recurring_meeting.rb b/modules/meeting/app/models/recurring_meeting.rb index e1e6a786f5a7..66a5c337934b 100644 --- a/modules/meeting/app/models/recurring_meeting.rb +++ b/modules/meeting/app/models/recurring_meeting.rb @@ -52,10 +52,6 @@ def weekday I18n.l(start_time, format: "%A") end - def month - I18n.l(start_time, format: "%B") - end - def date start_time.day.ordinalize end @@ -67,13 +63,23 @@ def schedule end end - def schedule_in_words + def schedule_in_words # rubocop:disable Metrics/AbcSize base = case frequency - when "daily" - human_frequency - else - I18n.t("recurring_meeting.in_words.weekly", frequency: human_frequency, weekday:) - end + when "daily" + interval == 1 ? human_frequency : I18n.t("recurring_meeting.in_words.daily_interval", interval: interval.ordinalize) + when "working_days" + if interval == 1 + I18n.t("recurring_meeting.in_words.working_days") + else + I18n.t("recurring_meeting.in_words.working_days_interval", interval: interval.ordinalize) + end + when "weekly" + if interval == 1 + I18n.t("recurring_meeting.in_words.weekly", weekday:) + else + I18n.t("recurring_meeting.in_words.weekly_interval", interval: interval.ordinalize, weekday:) + end + end I18n.t("recurring_meeting.in_words.full", base:, time: format_time(start_time, include_date: false), end_date:) end diff --git a/modules/meeting/config/locales/en.yml b/modules/meeting/config/locales/en.yml index 507acf821efb..000fbd3b85ce 100644 --- a/modules/meeting/config/locales/en.yml +++ b/modules/meeting/config/locales/en.yml @@ -217,16 +217,18 @@ en: frequency: every_weekday: "Every %{day_of_the_week}" daily: "Daily" - working_days: "Working days" + working_days: "Working Days" weekly: "Weekly" end_after: specific_date: "A specific date" iterations: "A number of occurrences" starts: "Starts" in_words: - weekly: "%{frequency} on %{weekday}" - monthly: "%{frequency} on the %{date}" - yearly: "%{frequency} on the %{date} of %{month}" + daily_interval: "Every %{interval} day" + working_days: "Every working day" + working_days_interval: "Every %{interval} working day" + weekly: "Weekly on %{weekday}" + weekly_interval: "Every %{interval} week on %{weekday}" full: "%{base} at %{time}, ends on %{end_date}" notice_successful_notification: "Notification sent successfully" From cb3c5b6d88149247dc54935b7272c8ff751a5c82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Sat, 23 Nov 2024 12:07:42 +0100 Subject: [PATCH 046/129] Fix exception rule --- .../app/components/recurring_meetings/row_component.rb | 9 ++++++--- .../app/controllers/recurring_meetings_controller.rb | 5 ++++- modules/meeting/app/models/recurring_meeting.rb | 2 +- .../meeting/app/models/recurring_meetings/skeleton.rb | 2 +- modules/meeting/config/locales/en.yml | 1 + 5 files changed, 13 insertions(+), 6 deletions(-) diff --git a/modules/meeting/app/components/recurring_meetings/row_component.rb b/modules/meeting/app/components/recurring_meetings/row_component.rb index 0098123ab8f5..6f859f6fd231 100644 --- a/modules/meeting/app/components/recurring_meetings/row_component.rb +++ b/modules/meeting/app/components/recurring_meetings/row_component.rb @@ -34,6 +34,10 @@ def meeting model end + def schedule + meeting.schedule + end + def instantiated? meeting.is_a?(Meeting) end @@ -69,11 +73,10 @@ def last_edited end def status - state = instantiated? ? model.state : :scheduled - scheme = status_scheme(state) + scheme = status_scheme(model.state) render(Primer::Beta::Label.new(scheme:)) do - render(Primer::Beta::Text.new) { t("label_meeting_state_#{state}") } + render(Primer::Beta::Text.new) { t("label_meeting_state_#{model.state}") } end end diff --git a/modules/meeting/app/controllers/recurring_meetings_controller.rb b/modules/meeting/app/controllers/recurring_meetings_controller.rb index 95e20edf3811..5d03a08e7b54 100644 --- a/modules/meeting/app/controllers/recurring_meetings_controller.rb +++ b/modules/meeting/app/controllers/recurring_meetings_controller.rb @@ -161,7 +161,10 @@ def upcoming_meetings def skeleton_meeting(date) start_time = @recurring_meeting.start_time.change(year: date.year, month: date.month, day: date.day) - RecurringMeetings::Skeleton.new(start_time:, recurring_meeting: @recurring_meeting) + occurring = @recurring_meeting.schedule.occurring_at?(date) + RecurringMeetings::Skeleton.new(start_time:, + state: occurring ? :scheduled : :skipped, + recurring_meeting: @recurring_meeting) end def find_optional_project diff --git a/modules/meeting/app/models/recurring_meeting.rb b/modules/meeting/app/models/recurring_meeting.rb index 66a5c337934b..ef5474b37776 100644 --- a/modules/meeting/app/models/recurring_meeting.rb +++ b/modules/meeting/app/models/recurring_meeting.rb @@ -112,7 +112,7 @@ def exclude_non_working_days(schedule) .where(date: start_date...) .pluck(:date) .each do |date| - schedule.add_exception_time(date) + schedule.add_exception_time(date.to_time(:utc)) end end diff --git a/modules/meeting/app/models/recurring_meetings/skeleton.rb b/modules/meeting/app/models/recurring_meetings/skeleton.rb index 97ca9174fc47..44598b25492f 100644 --- a/modules/meeting/app/models/recurring_meetings/skeleton.rb +++ b/modules/meeting/app/models/recurring_meetings/skeleton.rb @@ -27,5 +27,5 @@ #++ module RecurringMeetings - Skeleton = Data.define(:start_time, :recurring_meeting) + Skeleton = Data.define(:start_time, :recurring_meeting, :state) end diff --git a/modules/meeting/config/locales/en.yml b/modules/meeting/config/locales/en.yml index 000fbd3b85ce..36d90f7610b0 100644 --- a/modules/meeting/config/locales/en.yml +++ b/modules/meeting/config/locales/en.yml @@ -302,6 +302,7 @@ en: label_meeting_state_agenda_created: "Agenda created" label_meeting_state_scheduled: "Scheduled" label_meeting_state_cancelled: "Cancelled" + label_meeting_state_skipped: "Skipped" label_meeting_reopen_action: "Reopen meeting" label_meeting_close_action: "Close meeting" text_meeting_open_description: "This meeting is open. You can add/remove agenda items and edit them as you please. After the meeting is over, close it to lock it." From ba927df6b9ad41ea97ce484c367c8bd992d58f87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Sat, 23 Nov 2024 12:23:00 +0100 Subject: [PATCH 047/129] Fix occurrence for working days --- .../app/components/recurring_meetings/row_component.rb | 6 ++++-- .../app/controllers/recurring_meetings_controller.rb | 3 +-- modules/meeting/app/models/recurring_meeting.rb | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/modules/meeting/app/components/recurring_meetings/row_component.rb b/modules/meeting/app/components/recurring_meetings/row_component.rb index 6f859f6fd231..3b8458ea61ec 100644 --- a/modules/meeting/app/components/recurring_meetings/row_component.rb +++ b/modules/meeting/app/components/recurring_meetings/row_component.rb @@ -74,8 +74,8 @@ def last_edited def status scheme = status_scheme(model.state) - - render(Primer::Beta::Label.new(scheme:)) do + + render(Primer::Beta::Label.new(title:, scheme:)) do render(Primer::Beta::Text.new) { t("label_meeting_state_#{model.state}") } end end @@ -86,6 +86,8 @@ def status_scheme(state) :success when "cancelled" :severe + when "skipped" + :attention else :secondary end diff --git a/modules/meeting/app/controllers/recurring_meetings_controller.rb b/modules/meeting/app/controllers/recurring_meetings_controller.rb index 5d03a08e7b54..19fa45774e34 100644 --- a/modules/meeting/app/controllers/recurring_meetings_controller.rb +++ b/modules/meeting/app/controllers/recurring_meetings_controller.rb @@ -161,9 +161,8 @@ def upcoming_meetings def skeleton_meeting(date) start_time = @recurring_meeting.start_time.change(year: date.year, month: date.month, day: date.day) - occurring = @recurring_meeting.schedule.occurring_at?(date) RecurringMeetings::Skeleton.new(start_time:, - state: occurring ? :scheduled : :skipped, + state: "scheduled", recurring_meeting: @recurring_meeting) end diff --git a/modules/meeting/app/models/recurring_meeting.rb b/modules/meeting/app/models/recurring_meeting.rb index ef5474b37776..3c61702d79ac 100644 --- a/modules/meeting/app/models/recurring_meeting.rb +++ b/modules/meeting/app/models/recurring_meeting.rb @@ -57,7 +57,7 @@ def date end def schedule - @schedule ||= IceCube::Schedule.new(start_time, end_time: end_date).tap do |s| + @schedule ||= IceCube::Schedule.new(start_date.to_time(:utc), end_time: end_date).tap do |s| s.add_recurrence_rule count_rule(frequency_rule) exclude_non_working_days(s) if frequency_working_days? end From 1e3083dd48ffec51976de0e0e3d11953428da236 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Sat, 23 Nov 2024 12:51:29 +0100 Subject: [PATCH 048/129] Hide interval when selecting working_days --- .../show-when-value-selected.controller.ts | 15 +++++++++++--- .../meetings/index/form_component.html.erb | 20 ++++++++++++++++--- .../recurring_meetings/row_component.rb | 2 +- .../app/forms/recurring_meeting/end_after.rb | 1 + .../app/forms/recurring_meeting/frequency.rb | 4 ++++ .../app/forms/recurring_meeting/interval.rb | 2 +- 6 files changed, 36 insertions(+), 8 deletions(-) diff --git a/frontend/src/stimulus/controllers/show-when-value-selected.controller.ts b/frontend/src/stimulus/controllers/show-when-value-selected.controller.ts index 6c28e08c22c4..cfb31519fd5f 100644 --- a/frontend/src/stimulus/controllers/show-when-value-selected.controller.ts +++ b/frontend/src/stimulus/controllers/show-when-value-selected.controller.ts @@ -14,9 +14,18 @@ export default class OpShowWhenValueSelectedController extends ApplicationContro } private toggleDisabled(evt:InputEvent):void { - const value = (evt.target as HTMLInputElement).value; - this.effectTargets.forEach((el) => { - el.hidden = !(el.dataset.value === value); + const input = evt.target as HTMLInputElement; + const targetName = input.dataset.targetName; + + this + .effectTargets + .filter((el) => targetName === el.dataset.targetName) + .forEach((el) => { + if (el.dataset.notValue) { + el.hidden = el.dataset.notValue === input.value; + } else { + el.hidden = !(el.dataset.value === input.value); + } }); } } diff --git a/modules/meeting/app/components/meetings/index/form_component.html.erb b/modules/meeting/app/components/meetings/index/form_component.html.erb index 3aba053ac1af..622ff1dc12cf 100644 --- a/modules/meeting/app/components/meetings/index/form_component.html.erb +++ b/modules/meeting/app/components/meetings/index/form_component.html.erb @@ -57,7 +57,14 @@ render(RecurringMeeting::Frequency.new(f)) end - modal_body.with_row(mt: 3) do + modal_body.with_row( + mt: 3, + hidden: @meeting.frequency_working_days?, + data: { + target_name: "frequency", + not_value: "working_days", + "show-when-value-selected-target": "effect" + }) do render(RecurringMeeting::Interval.new(f)) end @@ -66,14 +73,21 @@ end modal_body.with_row(mt: 3, - data: { value: "specific_date", "show-when-value-selected-target": "effect" } + data: { + value: "specific_date", + target_name: "end_after", + "show-when-value-selected-target": "effect" } ) do render(RecurringMeeting::SpecificDate.new(f)) end modal_body.with_row(mt: 3, hidden: true, - data: { value: "iterations", "show-when-value-selected-target": "effect" }) do + data: { + value: "iterations", + target_name: "end_after", + "show-when-value-selected-target": "effect" + }) do render(RecurringMeeting::Iterations.new(f)) end else diff --git a/modules/meeting/app/components/recurring_meetings/row_component.rb b/modules/meeting/app/components/recurring_meetings/row_component.rb index 3b8458ea61ec..00f75c2fcefe 100644 --- a/modules/meeting/app/components/recurring_meetings/row_component.rb +++ b/modules/meeting/app/components/recurring_meetings/row_component.rb @@ -74,7 +74,7 @@ def last_edited def status scheme = status_scheme(model.state) - + render(Primer::Beta::Label.new(title:, scheme:)) do render(Primer::Beta::Text.new) { t("label_meeting_state_#{model.state}") } end diff --git a/modules/meeting/app/forms/recurring_meeting/end_after.rb b/modules/meeting/app/forms/recurring_meeting/end_after.rb index 9442cc6b7727..7b4725341663 100644 --- a/modules/meeting/app/forms/recurring_meeting/end_after.rb +++ b/modules/meeting/app/forms/recurring_meeting/end_after.rb @@ -32,6 +32,7 @@ class RecurringMeeting::EndAfter < ApplicationForm name: "end_after", label: I18n.t("activerecord.attributes.recurring_meeting.end_after"), data: { + target_name: "end_after", "show-when-value-selected-target": "cause" } ) do |list| diff --git a/modules/meeting/app/forms/recurring_meeting/frequency.rb b/modules/meeting/app/forms/recurring_meeting/frequency.rb index 17f507d3c420..c8282039f0e4 100644 --- a/modules/meeting/app/forms/recurring_meeting/frequency.rb +++ b/modules/meeting/app/forms/recurring_meeting/frequency.rb @@ -31,6 +31,10 @@ class RecurringMeeting::Frequency < ApplicationForm meeting_form.select_list( name: "frequency", label: I18n.t("activerecord.attributes.recurring_meeting.frequency"), + data: { + target_name: "frequency", + "show-when-value-selected-target": "cause" + } ) do |list| RecurringMeeting.frequencies.keys.each do |value| label = I18n.t(:"recurring_meeting.frequency.#{value}") diff --git a/modules/meeting/app/forms/recurring_meeting/interval.rb b/modules/meeting/app/forms/recurring_meeting/interval.rb index 9d598246dd6b..d660a509e69f 100644 --- a/modules/meeting/app/forms/recurring_meeting/interval.rb +++ b/modules/meeting/app/forms/recurring_meeting/interval.rb @@ -32,7 +32,7 @@ class RecurringMeeting::Interval < ApplicationForm name: :interval, type: :number, label: I18n.t("activerecord.attributes.recurring_meeting.interval"), - caption: I18n.t("recurring_meeting.interval.instructions") + caption: I18n.t("recurring_meeting.interval.instructions"), ) end end From 4b22d3afbf8905a7cff7ed3d52f28191dbb72966 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Sat, 23 Nov 2024 13:01:27 +0100 Subject: [PATCH 049/129] Document show-when-value-selected --- lookbook/docs/patterns/02-forms.md.erb | 97 +++++++++++++++++++++++--- 1 file changed, 86 insertions(+), 11 deletions(-) diff --git a/lookbook/docs/patterns/02-forms.md.erb b/lookbook/docs/patterns/02-forms.md.erb index 1b395a4fd5b6..b9e78cfddd9c 100644 --- a/lookbook/docs/patterns/02-forms.md.erb +++ b/lookbook/docs/patterns/02-forms.md.erb @@ -91,6 +91,81 @@ allowing to put some content in between: This is the regular way of using Primer forms. + + +### Basic Interactivity + +In many cases, you want to show or hide a certain field based on the value of another field. For this purpose, there is the Stimulus controller `show-when-value-selected`. + +To use it, pass the controller to the form, and then use `cause` and `effect` targets together with a `data-value`. + +Basic example: + +```ruby +primer_form_with( + ... + data: { controller: "show-when-value-selected" } +) do |f| + render_inline_form(f) do |my_form| + my_form.select_list( + name: "frequency", + label: "Choose frequency", + data: { + target_name: "frequency", + "show-when-value-selected-target": "cause" + } + ) do |list| + list.option(label: "Foo", value: "foo") + list.option(label: "Bar", value: "bar") + list.option(label: "Third option", value: "other") + end + + # Will be shown when "Foo" is selected + my_form.text_field( + name: :interval, + type: :number, + data: { + target_name: "frequency", + value: "foo" + "show-when-value-selected-target": "cause" + } + end + + # Will be shown when "Bar" is selected + my_form.text_field( + name: :random, + type: :text, + data: { + target_name: "frequency", + value: "bar" + "show-when-value-selected-target": "effect" + } + end + + # Will be shown when not "other" is selected + my_form.text_field( + name: :random, + type: :text, + hidden: @my_object.state == 'other' + data: { + target_name: "frequency", + not_value: "other" + "show-when-value-selected-target": "effect" + } + end +end +``` + +Important data inputs: + +- `"show-when-value-selected-target": "cause"` marks a field as the emitter of a change. Whenever this input changes, the other fields visibility will be toggled. +- `"show-when-value-selected-target": "effect"` is the input that will get hidden or shown depending on the selected value +- `data: { value: 'XYZ'}` or `data-value="XYZ"` the value to be checked against the cause +- `data: { not_value: 'XYZ'}` or `data-not-value="XYZ"` the value to be checked against the cause, resulting in it being hidden if the selected value is NOT the given one. +- data: `{ target_name: "abc"}` or `data-target-name="abc"` allows you to define multiple sets of cause/effect handlers + + + ### Accessing the form model When defining a form, the model sometimes needs to be accessed, for instance to remove or add some fields depending on the state of the model. @@ -308,22 +383,22 @@ administration pages. So far, the following helpers are available: * `text_field(name:, **options)`: renders a text field for the setting called - `name`, automatically setting the label, value, and disabled state from the - setting's attributes. + `name`, automatically setting the label, value, and disabled state from the + setting's attributes. * `check_box(name:, **options)`: renders a checkbox for the setting called - `name`, automatically setting the label, checked state, and disabled state - from the setting's attributes. + `name`, automatically setting the label, checked state, and disabled state + from the setting's attributes. * `radio_button_group(name:, values:, button_options: {}, **options)`: renders - a radio button group for the setting called `name` and radio button for each - element of `values`, automatically setting the label, checked state, html - caption, and disabled state from the setting's attributes. + a radio button group for the setting called `name` and radio button for each + element of `values`, automatically setting the label, checked state, html + caption, and disabled state from the setting's attributes. * `submit`: renders a submit button with the label "Save" and the primary - scheme. + scheme. * `form`: the form builder instance if you need to render some form elements - normally handled by the settings form decorator in another way than intended. - Any call to a method that is not defined on the settings form decorator will - be forwarded to this form builder instance so its usage is transparent. + normally handled by the settings form decorator in another way than intended. + Any call to a method that is not defined on the settings form decorator will + be forwarded to this form builder instance so its usage is transparent. From b709bfe483fd5ae85cd6e3d0c5e9bbc46a26f8bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Sat, 23 Nov 2024 13:02:20 +0100 Subject: [PATCH 050/129] Set interval to 1 when changing to working_days --- .../recurring_meetings/set_attributes_service.rb | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/modules/meeting/app/services/recurring_meetings/set_attributes_service.rb b/modules/meeting/app/services/recurring_meetings/set_attributes_service.rb index d2d764f60c18..9f232da2b303 100644 --- a/modules/meeting/app/services/recurring_meetings/set_attributes_service.rb +++ b/modules/meeting/app/services/recurring_meetings/set_attributes_service.rb @@ -28,6 +28,18 @@ module RecurringMeetings class SetAttributesService < ::BaseServices::SetAttributes + private + + def set_attributes(params) + super + + model.change_by_system do + if model.frequency_working_days? + model.interval = 1 + end + end + end + def set_default_attributes(_params) model.change_by_system do model.author = user From cfb91a98b47bceea94bfc24ae5258d32868556c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Mon, 25 Nov 2024 10:13:08 +0100 Subject: [PATCH 051/129] Fix dialog --- .../meetings/index/dialog_component.html.erb | 30 ++- .../meetings/index/form_component.html.erb | 206 ++++++++---------- 2 files changed, 122 insertions(+), 114 deletions(-) diff --git a/modules/meeting/app/components/meetings/index/dialog_component.html.erb b/modules/meeting/app/components/meetings/index/dialog_component.html.erb index 24d5eff880fc..7eafcf9ffac2 100644 --- a/modules/meeting/app/components/meetings/index/dialog_component.html.erb +++ b/modules/meeting/app/components/meetings/index/dialog_component.html.erb @@ -6,6 +6,34 @@ data: { 'keep-open-on-submit': true } )) do |dialog| dialog.with_header(variant: :large) - render(Meetings::Index::FormComponent.new(meeting: @meeting, project: @project, copy_from: @copy_from)) + dialog.with_body do + render(Meetings::Index::FormComponent.new(meeting: @meeting, + project: @project, + copy_from: @copy_from)) + end + + dialog.with_footer do + component_collection do |modal_footer| + modal_footer.with_component( + Primer::ButtonComponent.new( + data: { 'close-dialog-id': "new-meeting-dialog" } + )) do + I18n.t(:button_cancel) + end + + modal_footer.with_component( + Primer::ButtonComponent.new( + scheme: :primary, + form: 'meeting-form', + type: :submit + )) do + if @meeting.persisted? + I18n.t(:button_save) + else + I18n.t(:label_meeting_create) + end + end + end + end end %> diff --git a/modules/meeting/app/components/meetings/index/form_component.html.erb b/modules/meeting/app/components/meetings/index/form_component.html.erb index 622ff1dc12cf..d84b7fb9fdbf 100644 --- a/modules/meeting/app/components/meetings/index/form_component.html.erb +++ b/modules/meeting/app/components/meetings/index/form_component.html.erb @@ -17,122 +17,102 @@ project_id: @project } ) do |f| - component_collection do |collection| - collection.with_component(Primer::Alpha::Dialog::Body.new) do - flex_layout(mb: 3) do |modal_body| - if @meeting.errors[:base].present? - modal_body.with_row do - render(Primer::Alpha::Banner.new(mb: 3, icon: :stop, scheme: :danger)) { @meeting.errors[:base].join("\n") } - end - end - - if @project.nil? - modal_body.with_row(mt: 3) do - render(Meeting::ProjectAutocompleter.new(f)) - end - end - - modal_body.with_row(mt: 3) do - render(Meeting::Title.new(f)) - end - - modal_body.with_row(mt: 3) do - render(Meeting::StartDate.new(f, initial_value: start_date_initial_value)) - end - - modal_body.with_row(mt: 3) do - render(Meeting::StartTime.new(f, initial_value: start_time_initial_value)) - end - - modal_body.with_row(mt: 3) do - render(Meeting::Duration.new(f, meeting: @meeting)) - end - - modal_body.with_row(mt: 3) do - render(Meeting::Location.new(f, meeting: @meeting)) - end - - if @meeting.is_a?(RecurringMeeting) - modal_body.with_row(mt: 3) do - render(RecurringMeeting::Frequency.new(f)) - end - - modal_body.with_row( - mt: 3, - hidden: @meeting.frequency_working_days?, - data: { - target_name: "frequency", - not_value: "working_days", - "show-when-value-selected-target": "effect" - }) do - render(RecurringMeeting::Interval.new(f)) - end - - modal_body.with_row(mt: 3) do - render(RecurringMeeting::EndAfter.new(f)) - end - - modal_body.with_row(mt: 3, - data: { - value: "specific_date", - target_name: "end_after", - "show-when-value-selected-target": "effect" } - ) do - render(RecurringMeeting::SpecificDate.new(f)) - end - - modal_body.with_row(mt: 3, - hidden: true, - data: { - value: "iterations", - target_name: "end_after", - "show-when-value-selected-target": "effect" - }) do - render(RecurringMeeting::Iterations.new(f)) - end - else - modal_body.with_row do - render(Meeting::Type.new(f)) - end - end - - if @copy_from - modal_body.with_row do - render(Meeting::CopiedFrom.new(f, id: @copy_from.id)) - end - - modal_body.with_row(mt: 3) do - render(Meeting::CopyItems.new(f)) - end - - modal_body.with_row(mt: 3) do - render(Meeting::CopyParticipants.new(f)) - end - - modal_body.with_row(mt: 3) do - render(Meeting::CopyAttachments.new(f)) - end - - modal_body.with_row(mt: 3) do - render(Meeting::EmailParticipants.new(f)) - end - end + flex_layout(mb: 3) do |modal_body| + if @meeting.errors[:base].present? + modal_body.with_row do + render(Primer::Alpha::Banner.new(mb: 3, icon: :stop, scheme: :danger)) { @meeting.errors[:base].join("\n") } end end - collection.with_component(Primer::Alpha::Dialog::Footer.new) do - component_collection do |modal_footer| - modal_footer.with_component(Primer::ButtonComponent.new(data: { 'close-dialog-id': "new-meeting-dialog" })) do - I18n.t(:button_cancel) - end - - modal_footer.with_component(Primer::ButtonComponent.new(scheme: :primary, type: :submit)) do - if @meeting.persisted? - I18n.t(:button_save) - else - I18n.t(:label_meeting_create) - end - end + if @project.nil? + modal_body.with_row(mt: 3) do + render(Meeting::ProjectAutocompleter.new(f)) + end + end + + modal_body.with_row(mt: 3) do + render(Meeting::Title.new(f)) + end + + modal_body.with_row(mt: 3) do + render(Meeting::StartDate.new(f, initial_value: start_date_initial_value)) + end + + modal_body.with_row(mt: 3) do + render(Meeting::StartTime.new(f, initial_value: start_time_initial_value)) + end + + modal_body.with_row(mt: 3) do + render(Meeting::Duration.new(f, meeting: @meeting)) + end + + modal_body.with_row(mt: 3) do + render(Meeting::Location.new(f, meeting: @meeting)) + end + + if @meeting.is_a?(RecurringMeeting) + modal_body.with_row(mt: 3) do + render(RecurringMeeting::Frequency.new(f)) + end + + modal_body.with_row( + mt: 3, + hidden: @meeting.frequency_working_days?, + data: { + target_name: "frequency", + not_value: "working_days", + "show-when-value-selected-target": "effect" + }) do + render(RecurringMeeting::Interval.new(f)) + end + + modal_body.with_row(mt: 3) do + render(RecurringMeeting::EndAfter.new(f)) + end + + modal_body.with_row(mt: 3, + data: { + value: "specific_date", + target_name: "end_after", + "show-when-value-selected-target": "effect" } + ) do + render(RecurringMeeting::SpecificDate.new(f)) + end + + modal_body.with_row(mt: 3, + hidden: true, + data: { + value: "iterations", + target_name: "end_after", + "show-when-value-selected-target": "effect" + }) do + render(RecurringMeeting::Iterations.new(f)) + end + else + modal_body.with_row do + render(Meeting::Type.new(f)) + end + end + + if @copy_from + modal_body.with_row do + render(Meeting::CopiedFrom.new(f, id: @copy_from.id)) + end + + modal_body.with_row(mt: 3) do + render(Meeting::CopyItems.new(f)) + end + + modal_body.with_row(mt: 3) do + render(Meeting::CopyParticipants.new(f)) + end + + modal_body.with_row(mt: 3) do + render(Meeting::CopyAttachments.new(f)) + end + + modal_body.with_row(mt: 3) do + render(Meeting::EmailParticipants.new(f)) end end end From b47d2abd717a68dca8ba158f9bb4a8f77db49dec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Mon, 25 Nov 2024 10:18:12 +0100 Subject: [PATCH 052/129] Adapt to mobile table --- app/components/op_primer/border_box_table_component.rb | 4 ++-- .../app/components/recurring_meetings/table_component.rb | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/app/components/op_primer/border_box_table_component.rb b/app/components/op_primer/border_box_table_component.rb index 9aed4a5d22b0..dde4ae8f02ce 100644 --- a/app/components/op_primer/border_box_table_component.rb +++ b/app/components/op_primer/border_box_table_component.rb @@ -43,7 +43,7 @@ class << self # # This results in the description columns to be hidden on mobile def mobile_columns(*names) - return @mobile_columns || columns if names.empty? + return Array(@mobile_columns || columns) if names.empty? @mobile_columns = names.map(&:to_sym) end @@ -54,7 +54,7 @@ def mobile_columns(*names) # # This results in the description columns to be hidden on mobile def mobile_labels(*names) - return @mobile_labels if names.empty? + return Array(@mobile_labels) if names.empty? @mobile_labels = names.map(&:to_sym) end diff --git a/modules/meeting/app/components/recurring_meetings/table_component.rb b/modules/meeting/app/components/recurring_meetings/table_component.rb index 0ec35a8d3ef2..eb4ade4561ac 100644 --- a/modules/meeting/app/components/recurring_meetings/table_component.rb +++ b/modules/meeting/app/components/recurring_meetings/table_component.rb @@ -52,13 +52,17 @@ def header_args(column) end end + def mobile_title + I18n.t(:label_recurring_meeting_plural) + end + def headers @headers ||= [ [:start_time, { caption: I18n.t(:label_meeting_date_and_time) }], [:relative_time, { caption: I18n.t("recurring_meeting.starts") }], [:last_edited, { caption: I18n.t(:label_meeting_last_updated) }], [:status, { caption: Meeting.human_attribute_name(:status) }], - [:create, ""] + [:create, { caption: "" }] ].compact end From d7302114b2745bdd39b5981f5eb7c2961e665bff Mon Sep 17 00:00:00 2001 From: Mir Bhatia Date: Mon, 25 Nov 2024 12:47:18 +0100 Subject: [PATCH 053/129] Update type filter --- modules/meeting/app/menus/meetings/menu.rb | 10 +++---- modules/meeting/app/models/meeting.rb | 3 +++ .../queries/meetings/filters/type_filter.rb | 27 +++++++------------ modules/meeting/config/locales/en.yml | 1 + 4 files changed, 18 insertions(+), 23 deletions(-) diff --git a/modules/meeting/app/menus/meetings/menu.rb b/modules/meeting/app/menus/meetings/menu.rb index bbcc3c3bd5f0..7eca1c2a095a 100644 --- a/modules/meeting/app/menus/meetings/menu.rb +++ b/modules/meeting/app/menus/meetings/menu.rb @@ -54,12 +54,10 @@ def top_level_menu_items def recurring_menu_item return unless OpenProject::FeatureDecisions.recurring_meetings_active? - href = polymorphic_path([@project, :recurring_meetings]) - OpenProject::Menu::MenuItem.new( - title: I18n.t("label_recurring_meeting_plural"), - href:, - selected: params[:current_href] == href - ) + recurring_filter = [{ type: { operator: "=", values: ["t"] } }].to_json + + menu_item(title: I18n.t("label_recurring_meeting_plural"), + query_params: { filters: recurring_filter, sort: "start_time" }) end def involvement_sidebar_menu_items diff --git a/modules/meeting/app/models/meeting.rb b/modules/meeting/app/models/meeting.rb index 57e938abf85c..c8ab0250f578 100644 --- a/modules/meeting/app/models/meeting.rb +++ b/modules/meeting/app/models/meeting.rb @@ -58,6 +58,9 @@ class Meeting < ApplicationRecord scope :cancelled, -> { where(state: :cancelled) } scope :not_cancelled, -> { where.not(id: cancelled) } + scope :not_recurring, -> { where(recurring_meeting_id: nil) } + scope :recurring, -> { where.not(id: not_recurring) } + scope :from_tomorrow, -> { where(["start_time >= ?", Date.tomorrow.beginning_of_day]) } scope :from_today, -> { where(["start_time >= ?", Time.zone.today.beginning_of_day]) } diff --git a/modules/meeting/app/models/queries/meetings/filters/type_filter.rb b/modules/meeting/app/models/queries/meetings/filters/type_filter.rb index a409647be091..2437988096ee 100644 --- a/modules/meeting/app/models/queries/meetings/filters/type_filter.rb +++ b/modules/meeting/app/models/queries/meetings/filters/type_filter.rb @@ -27,32 +27,25 @@ #++ class Queries::Meetings::Filters::TypeFilter < Queries::Meetings::Filters::MeetingFilter - def allowed_values - allowed = [ - [I18n.t("meeting.types.classic"), "Meeting"], - [I18n.t("meeting.types.structured"), "DynamicMeeting"] - ] + include Queries::Filters::Shared::BooleanFilter - if OpenProject::FeatureDecisions.recurring_meetings_active? - allowed + [[I18n.t("meeting.types.recurring"), "RecurringMeeting"]] - else - allowed - end + def self.key + :type end - def type - :list + def human_name + I18n.t("label_recurring_meeting_part_of") end - def self.key - :type + def available? + OpenProject::FeatureDecisions.recurring_meetings_active? end def apply_to(query_scope) - if operator == "=" - query_scope.where(type: values) + if allowed_values.first.intersect?(values) + query_scope.recurring else - query_scope.where.not(type: values) + query_scope.not_recurring end end end diff --git a/modules/meeting/config/locales/en.yml b/modules/meeting/config/locales/en.yml index 246f7b5db799..fcf8fac1ba0c 100644 --- a/modules/meeting/config/locales/en.yml +++ b/modules/meeting/config/locales/en.yml @@ -126,6 +126,7 @@ en: label_meeting_date_and_time: "Date and time" label_meeting_diff: "Diff" label_recurring_meeting: "Recurring meeting" + label_recurring_meeting_part_of: "Part of a meeting series" label_recurring_meeting_new: "New recurring meeting" label_recurring_meeting_plural: "Recurring meetings" label_template: "Template" From 7195fb35cf771c796b9fb1bff6fb67ecad10442c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Tue, 26 Nov 2024 08:35:28 +0100 Subject: [PATCH 054/129] Rename TypeFilter -> RecurringFilter --- .../app/components/meetings/meeting_filters_component.rb | 2 +- modules/meeting/app/models/queries/meetings.rb | 2 +- .../meetings/filters/{type_filter.rb => recurring_filter.rb} | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) rename modules/meeting/app/models/queries/meetings/filters/{type_filter.rb => recurring_filter.rb} (94%) diff --git a/modules/meeting/app/components/meetings/meeting_filters_component.rb b/modules/meeting/app/components/meetings/meeting_filters_component.rb index 0266367a801f..f5da1cbdfb06 100644 --- a/modules/meeting/app/components/meetings/meeting_filters_component.rb +++ b/modules/meeting/app/components/meetings/meeting_filters_component.rb @@ -65,7 +65,7 @@ def allowed_filter?(filter) Queries::Meetings::Filters::AuthorFilter, Queries::Meetings::Filters::InvitedUserFilter, Queries::Meetings::Filters::TimeFilter, - Queries::Meetings::Filters::TypeFilter + Queries::Meetings::Filters::RecurringFilter ] if project.nil? diff --git a/modules/meeting/app/models/queries/meetings.rb b/modules/meeting/app/models/queries/meetings.rb index 0ce63c6a0ead..9a10278fbdf6 100644 --- a/modules/meeting/app/models/queries/meetings.rb +++ b/modules/meeting/app/models/queries/meetings.rb @@ -34,6 +34,6 @@ module Queries::Meetings filter Filters::InvitedUserFilter filter Filters::AuthorFilter filter Filters::DatesIntervalFilter - filter Filters::TypeFilter + filter Filters::RecurringFilter end end diff --git a/modules/meeting/app/models/queries/meetings/filters/type_filter.rb b/modules/meeting/app/models/queries/meetings/filters/recurring_filter.rb similarity index 94% rename from modules/meeting/app/models/queries/meetings/filters/type_filter.rb rename to modules/meeting/app/models/queries/meetings/filters/recurring_filter.rb index 2437988096ee..01279912b04d 100644 --- a/modules/meeting/app/models/queries/meetings/filters/type_filter.rb +++ b/modules/meeting/app/models/queries/meetings/filters/recurring_filter.rb @@ -26,7 +26,7 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class Queries::Meetings::Filters::TypeFilter < Queries::Meetings::Filters::MeetingFilter +class Queries::Meetings::Filters::RecurringFilter < Queries::Meetings::Filters::MeetingFilter include Queries::Filters::Shared::BooleanFilter def self.key From 91f28b263a6de5659f92546dda4c44228d516c3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Tue, 26 Nov 2024 09:59:08 +0100 Subject: [PATCH 055/129] Start date group --- .../meetings/index/form_component.html.erb | 6 +-- .../meetings/index/form_component.rb | 8 ---- .../app/controllers/meetings_controller.rb | 2 +- .../meeting/app/forms/meeting/start_date.rb | 46 ------------------- .../meeting/{start_time.rb => time_group.rb} | 42 +++++++++++------ 5 files changed, 31 insertions(+), 73 deletions(-) delete mode 100644 modules/meeting/app/forms/meeting/start_date.rb rename modules/meeting/app/forms/meeting/{start_time.rb => time_group.rb} (54%) diff --git a/modules/meeting/app/components/meetings/index/form_component.html.erb b/modules/meeting/app/components/meetings/index/form_component.html.erb index d84b7fb9fdbf..e90dce40628e 100644 --- a/modules/meeting/app/components/meetings/index/form_component.html.erb +++ b/modules/meeting/app/components/meetings/index/form_component.html.erb @@ -35,11 +35,7 @@ end modal_body.with_row(mt: 3) do - render(Meeting::StartDate.new(f, initial_value: start_date_initial_value)) - end - - modal_body.with_row(mt: 3) do - render(Meeting::StartTime.new(f, initial_value: start_time_initial_value)) + render(Meeting::TimeGroup.new(f, meeting: @meeting)) end modal_body.with_row(mt: 3) do diff --git a/modules/meeting/app/components/meetings/index/form_component.rb b/modules/meeting/app/components/meetings/index/form_component.rb index 149bd8cd0603..aaf81f9b69f9 100644 --- a/modules/meeting/app/components/meetings/index/form_component.rb +++ b/modules/meeting/app/components/meetings/index/form_component.rb @@ -65,13 +65,5 @@ def form_action :update end end - - def start_date_initial_value - @meeting.start_date.presence || format_time_as_date(@meeting.start_time, format: "%Y-%m-%d") - end - - def start_time_initial_value - @meeting.start_time_hour.presence || format_time(@meeting.start_time, include_date: false, format: "%H:%M") - end end end diff --git a/modules/meeting/app/controllers/meetings_controller.rb b/modules/meeting/app/controllers/meetings_controller.rb index a9736edac2a0..41f60ff89532 100644 --- a/modules/meeting/app/controllers/meetings_controller.rb +++ b/modules/meeting/app/controllers/meetings_controller.rb @@ -146,7 +146,7 @@ def create # rubocop:disable Metrics/AbcSize def new_dialog respond_with_dialog Meetings::Index::DialogComponent.new( meeting: @meeting, - project: @project, + project: @project ) end diff --git a/modules/meeting/app/forms/meeting/start_date.rb b/modules/meeting/app/forms/meeting/start_date.rb deleted file mode 100644 index ff5824535374..000000000000 --- a/modules/meeting/app/forms/meeting/start_date.rb +++ /dev/null @@ -1,46 +0,0 @@ -#-- 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. -#++ - -class Meeting::StartDate < ApplicationForm - form do |meeting_form| - meeting_form.text_field( - name: :start_date, - type: "date", - value: @initial_value, - placeholder: Meeting.human_attribute_name(:start_date), - label: Meeting.human_attribute_name(:start_date), - leading_visual: { icon: :calendar }, - required: true, - autofocus: false - ) - end - - def initialize(initial_value: DateTime.now.strftime("%Y-%m-%d")) - @initial_value = initial_value - end -end diff --git a/modules/meeting/app/forms/meeting/start_time.rb b/modules/meeting/app/forms/meeting/time_group.rb similarity index 54% rename from modules/meeting/app/forms/meeting/start_time.rb rename to modules/meeting/app/forms/meeting/time_group.rb index 36b43092839e..20aee89aeb3b 100644 --- a/modules/meeting/app/forms/meeting/start_time.rb +++ b/modules/meeting/app/forms/meeting/time_group.rb @@ -26,23 +26,39 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class Meeting::StartTime < ApplicationForm +class Meeting::TimeGroup < ApplicationForm include Redmine::I18n form do |meeting_form| - meeting_form.text_field( - name: :start_time_hour, - type: "time", - value: @initial_value, - placeholder: Meeting.human_attribute_name(:start_time), - label: Meeting.human_attribute_name(:start_time), - leading_visual: { icon: :clock }, - required: true, - caption: formatted_time_zone_offset - ) + meeting_form.group(layout: :horizontal) do |group| + group.text_field( + name: :start_date, + type: "date", + value: @initial_date, + placeholder: Meeting.human_attribute_name(:start_date), + label: Meeting.human_attribute_name(:start_date), + leading_visual: { icon: :calendar }, + required: true, + autofocus: false + ) + + group.text_field( + name: :start_time_hour, + type: "time", + value: @initial_time, + placeholder: Meeting.human_attribute_name(:start_time), + label: Meeting.human_attribute_name(:start_time), + leading_visual: { icon: :clock }, + required: true, + caption: formatted_time_zone_offset + ) + end end - def initialize(initial_value: DateTime.now.strftime("%H:%M")) - @initial_value = initial_value + def initialize(meeting:) + super() + + @initial_time = meeting.start_time_hour.presence || format_time(meeting.start_time, include_date: false, format: "%H:%M") + @initial_date = meeting.start_date.presence || format_time_as_date(meeting.start_time, format: "%Y-%m-%d") end end From 454c846b7f31a0f9dba9412e3d34bba201b68735 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Tue, 26 Nov 2024 14:31:34 +0100 Subject: [PATCH 056/129] Combined Filter component --- .../combined_filter_component.html.erb | 24 ++++++++ .../meetings/combined_filter_component.rb | 57 +++++++++++++++++++ .../index_sub_header_component.html.erb | 2 +- .../meetings/index_sub_header_component.rb | 3 +- .../meeting_filter_button_component.rb | 1 + .../meetings/meeting_filters_component.rb | 1 - .../app/controllers/meetings_controller.rb | 19 ++++--- modules/meeting/app/menus/meetings/menu.rb | 14 ++--- .../queries/meetings/filters/time_filter.rb | 9 ++- .../meeting/app/views/meetings/index.html.erb | 2 +- modules/meeting/config/locales/en.yml | 4 +- 11 files changed, 113 insertions(+), 23 deletions(-) create mode 100644 modules/meeting/app/components/meetings/combined_filter_component.html.erb create mode 100644 modules/meeting/app/components/meetings/combined_filter_component.rb diff --git a/modules/meeting/app/components/meetings/combined_filter_component.html.erb b/modules/meeting/app/components/meetings/combined_filter_component.html.erb new file mode 100644 index 000000000000..8fde66db7c6a --- /dev/null +++ b/modules/meeting/app/components/meetings/combined_filter_component.html.erb @@ -0,0 +1,24 @@ +<%= + flex_layout do |flex| + flex.with_column do + render(Meetings::MeetingFilterButtonComponent.new(query: @query, project: @project)) + end + + flex.with_column(ml: 1) do + render(Primer::Alpha::SegmentedControl.new("aria-label": I18n.t(:label_meeting_date_time))) do |control| + control.with_item(tag: :a, + icon: :"arrow-right", + href: dynamic_path, + label: t(:label_upcoming_meetings_short), + title: t(:label_upcoming_meetings), + selected: upcoming_query?) + control.with_item(tag: :a, + icon: :history, + href: dynamic_path(upcoming: false), + label: t(:label_past_meetings_short), + title: t(:label_past_meetings), + selected: !upcoming_query?) + end + end + end +%> diff --git a/modules/meeting/app/components/meetings/combined_filter_component.rb b/modules/meeting/app/components/meetings/combined_filter_component.rb new file mode 100644 index 000000000000..6997f57e3e64 --- /dev/null +++ b/modules/meeting/app/components/meetings/combined_filter_component.rb @@ -0,0 +1,57 @@ +#-- 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 Meetings + class CombinedFilterComponent < ApplicationComponent + include ApplicationHelper + include OpTurbo::Streamable + include OpPrimer::ComponentHelpers + include Redmine::I18n + + def initialize(query:, project: nil, params:) + super() + + @query = query + @project = project + @params = params + end + + def dynamic_path(upcoming: true) + polymorphic_path([@project, :meetings], current_params.merge(upcoming:)) + end + + def upcoming_query? + filter = @query.filters.find { |f| f.name == :time } + filter ? !filter.past? : true + end + + def current_params + @current_params ||= params.slice(:filters, :page, :per_page).permit! + end + end +end diff --git a/modules/meeting/app/components/meetings/index_sub_header_component.html.erb b/modules/meeting/app/components/meetings/index_sub_header_component.html.erb index d53adc2338ec..70644cb58e25 100644 --- a/modules/meeting/app/components/meetings/index_sub_header_component.html.erb +++ b/modules/meeting/app/components/meetings/index_sub_header_component.html.erb @@ -4,7 +4,7 @@ "filter--filters-form-output-format-value": "json", })) do |subheader| subheader.with_filter_component do - render(Meetings::MeetingFilterButtonComponent.new(query: @query, project: @project)) + render(Meetings::CombinedFilterComponent.new(query: @query, project: @project, params: @params)) end if render_create_button? diff --git a/modules/meeting/app/components/meetings/index_sub_header_component.rb b/modules/meeting/app/components/meetings/index_sub_header_component.rb index 1540b148b4ff..59ff8672bef0 100644 --- a/modules/meeting/app/components/meetings/index_sub_header_component.rb +++ b/modules/meeting/app/components/meetings/index_sub_header_component.rb @@ -34,10 +34,11 @@ class IndexSubHeaderComponent < ApplicationComponent # rubocop:enable OpenProject/AddPreviewForViewComponent include ApplicationHelper - def initialize(query:, project: nil) + def initialize(query:, project: nil, params:) super @query = query @project = project + @params = params end def render_create_button? diff --git a/modules/meeting/app/components/meetings/meeting_filter_button_component.rb b/modules/meeting/app/components/meetings/meeting_filter_button_component.rb index 3e39c65271ae..f4dd1e3ecd19 100644 --- a/modules/meeting/app/components/meetings/meeting_filter_button_component.rb +++ b/modules/meeting/app/components/meetings/meeting_filter_button_component.rb @@ -36,6 +36,7 @@ def filters_count @filters_count ||= begin count = super count -= 1 if project.present? + count -= 1 if query.filters.find { |f| f.name == :time } count end diff --git a/modules/meeting/app/components/meetings/meeting_filters_component.rb b/modules/meeting/app/components/meetings/meeting_filters_component.rb index f5da1cbdfb06..d65bd9861611 100644 --- a/modules/meeting/app/components/meetings/meeting_filters_component.rb +++ b/modules/meeting/app/components/meetings/meeting_filters_component.rb @@ -64,7 +64,6 @@ def allowed_filter?(filter) Queries::Meetings::Filters::AttendedUserFilter, Queries::Meetings::Filters::AuthorFilter, Queries::Meetings::Filters::InvitedUserFilter, - Queries::Meetings::Filters::TimeFilter, Queries::Meetings::Filters::RecurringFilter ] diff --git a/modules/meeting/app/controllers/meetings_controller.rb b/modules/meeting/app/controllers/meetings_controller.rb index 41f60ff89532..b75a89ba7873 100644 --- a/modules/meeting/app/controllers/meetings_controller.rb +++ b/modules/meeting/app/controllers/meetings_controller.rb @@ -336,19 +336,24 @@ def load_query current_user ).call(params) - query = apply_default_filter_if_none_given(query) - - if @project - query.where("project_id", "=", @project.id) - end + apply_default_filter_if_none_given(query) + apply_time_filter(query) + query.where("project_id", "=", @project.id) if @project query end + def apply_time_filter(query) + if params[:upcoming] == "false" + query.where("time", "=", Queries::Meetings::Filters::TimeFilter::PAST_VALUE) + else + query.where("time", "=", Queries::Meetings::Filters::TimeFilter::FUTURE_VALUE) + end + end + def apply_default_filter_if_none_given(query) - return query if query.filters.any? + return if query.filters.any? - query.where("time", "=", Queries::Meetings::Filters::TimeFilter::FUTURE_VALUE) query.where("invited_user_id", "=", [User.current.id.to_s]) end diff --git a/modules/meeting/app/menus/meetings/menu.rb b/modules/meeting/app/menus/meetings/menu.rb index 7eca1c2a095a..6fad5e9af862 100644 --- a/modules/meeting/app/menus/meetings/menu.rb +++ b/modules/meeting/app/menus/meetings/menu.rb @@ -39,15 +39,13 @@ def menu_items end def top_level_menu_items - upcoming_filter = [{ time: { operator: "=", values: ["future"] } }].to_json - past_filter = [{ time: { operator: "=", values: ["past"] } }].to_json + all_filter = [{ invited_user_id: { operator: "*", values: [] } }].to_json [ - menu_item(title: I18n.t(:label_upcoming_meetings), - query_params: { filters: upcoming_filter, sort: "start_time" }), - menu_item(title: I18n.t(:label_past_meetings), - query_params: { filters: past_filter, sort: "start_time:desc" }), - recurring_menu_item + menu_item(title: I18n.t(:label_my_meetings)), + recurring_menu_item, + menu_item(title: I18n.t(:label_all_meetings), + query_params: { filters: all_filter }) ].compact end @@ -62,7 +60,7 @@ def recurring_menu_item def involvement_sidebar_menu_items [ - menu_item(title: I18n.t(:label_upcoming_invitations)), + menu_item(title: I18n.t(:label_invitations)), menu_item(title: I18n.t(:label_past_invitations), query_params: { filters: past_filter, sort: "start_time:desc" }), menu_item(title: I18n.t(:label_attendee), diff --git a/modules/meeting/app/models/queries/meetings/filters/time_filter.rb b/modules/meeting/app/models/queries/meetings/filters/time_filter.rb index d0c3faf168bb..8ac8caa5f607 100644 --- a/modules/meeting/app/models/queries/meetings/filters/time_filter.rb +++ b/modules/meeting/app/models/queries/meetings/filters/time_filter.rb @@ -39,11 +39,14 @@ def allowed_values ] end + def past? + values.first == PAST_VALUE + end + def where - case values.first - when PAST_VALUE + if past? '"meetings"."start_time" < NOW()' - when FUTURE_VALUE + else '"meetings"."start_time" + "meetings"."duration" * interval \'1 hour\' > NOW()' end end diff --git a/modules/meeting/app/views/meetings/index.html.erb b/modules/meeting/app/views/meetings/index.html.erb index 0e712130e9e5..25b173cf481c 100644 --- a/modules/meeting/app/views/meetings/index.html.erb +++ b/modules/meeting/app/views/meetings/index.html.erb @@ -29,7 +29,7 @@ See COPYRIGHT and LICENSE files for more details. <% html_title t(:label_meeting_plural) %> <%= render(Meetings::IndexPageHeaderComponent.new(project: @project)) %> -<%= render(Meetings::IndexSubHeaderComponent.new(query: @query, project: @project)) %> +<%= render(Meetings::IndexSubHeaderComponent.new(query: @query, project: @project, params:)) %> <% if @meetings.empty? -%> <%= no_results_box %> diff --git a/modules/meeting/config/locales/en.yml b/modules/meeting/config/locales/en.yml index fcf8fac1ba0c..29462b2f1fd1 100644 --- a/modules/meeting/config/locales/en.yml +++ b/modules/meeting/config/locales/en.yml @@ -141,12 +141,14 @@ en: label_recurring_meeting_restore: "Restore this occurrence" label_recurring_meeting_series_edit: "Edit meeting series" label_recurring_meeting_series_delete: "Delete meeting series" + label_my_meetings: "My meetings" + label_all_meetings: "All meetings" label_upcoming_meetings: "Upcoming meetings" label_past_meetings: "Past meetings" label_upcoming_meetings_short: "Upcoming" label_past_meetings_short: "Past" label_involvement: "Involvement" - label_upcoming_invitations: "Upcoming invitations" + label_invitations: "Invitations" label_past_invitations: "Past invitations" label_attendee: "Attendee" label_author: "Creator" From 9c7a4eb532a1914c69ea79ef9918548213719187 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Tue, 26 Nov 2024 16:24:43 +0100 Subject: [PATCH 057/129] Updated menu --- .../filters/strategies/integer_list_optional.rb | 1 + modules/meeting/app/menus/meetings/menu.rb | 9 +++++---- .../queries/meetings/filters/invited_user_filter.rb | 10 +++++----- modules/meeting/config/locales/en.yml | 2 +- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/app/models/queries/filters/strategies/integer_list_optional.rb b/app/models/queries/filters/strategies/integer_list_optional.rb index df55d434c6c3..f7a0ff61c3d1 100644 --- a/app/models/queries/filters/strategies/integer_list_optional.rb +++ b/app/models/queries/filters/strategies/integer_list_optional.rb @@ -33,6 +33,7 @@ class IntegerListOptional < ::Queries::Filters::Strategies::Integer def operator_map super_value = super.dup super_value["="] = ::Queries::Operators::EqualsOr + super_value["*"] = ::Queries::Operators::All super_value end diff --git a/modules/meeting/app/menus/meetings/menu.rb b/modules/meeting/app/menus/meetings/menu.rb index 6fad5e9af862..2dc5ecae6210 100644 --- a/modules/meeting/app/menus/meetings/menu.rb +++ b/modules/meeting/app/menus/meetings/menu.rb @@ -59,11 +59,12 @@ def recurring_menu_item end def involvement_sidebar_menu_items + invitation_filter = [{ invited_user_id: { operator: "=", values: [User.current.id.to_s] } }].to_json + [ - menu_item(title: I18n.t(:label_invitations)), - menu_item(title: I18n.t(:label_past_invitations), - query_params: { filters: past_filter, sort: "start_time:desc" }), - menu_item(title: I18n.t(:label_attendee), + menu_item(title: I18n.t(:label_invitations), + query_params: { filters: invitation_filter, sort: "start_time" }), + menu_item(title: I18n.t(:label_attended), query_params: { filters: attendee_filter }), menu_item(title: I18n.t(:label_author), query_params: { filters: author_filter }) diff --git a/modules/meeting/app/models/queries/meetings/filters/invited_user_filter.rb b/modules/meeting/app/models/queries/meetings/filters/invited_user_filter.rb index 04caa0b2772f..906406a4fbd4 100644 --- a/modules/meeting/app/models/queries/meetings/filters/invited_user_filter.rb +++ b/modules/meeting/app/models/queries/meetings/filters/invited_user_filter.rb @@ -40,7 +40,11 @@ def type_strategy end def where - "meeting_participants.user_id IN (#{values.join(',')}) AND meeting_participants.invited" + operator_strategy.sql_for_field( + values, + MeetingParticipant.table_name, + "user_id" + ) end def joins @@ -50,8 +54,4 @@ def joins def self.key :invited_user_id end - - def available_operators - [::Queries::Operators::Equals] - end end diff --git a/modules/meeting/config/locales/en.yml b/modules/meeting/config/locales/en.yml index 29462b2f1fd1..e9b4ce669376 100644 --- a/modules/meeting/config/locales/en.yml +++ b/modules/meeting/config/locales/en.yml @@ -150,7 +150,7 @@ en: label_involvement: "Involvement" label_invitations: "Invitations" label_past_invitations: "Past invitations" - label_attendee: "Attendee" + label_attended: "Attended" label_author: "Creator" label_notify: "Send for review" label_icalendar: "Send iCalendar" From 8145ff04e62230ff488c29f9429bc5f89e366af0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Tue, 26 Nov 2024 16:30:56 +0100 Subject: [PATCH 058/129] Update label created by me --- modules/meeting/app/menus/meetings/menu.rb | 2 +- modules/meeting/config/locales/en.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/meeting/app/menus/meetings/menu.rb b/modules/meeting/app/menus/meetings/menu.rb index 2dc5ecae6210..134f87e44b3f 100644 --- a/modules/meeting/app/menus/meetings/menu.rb +++ b/modules/meeting/app/menus/meetings/menu.rb @@ -66,7 +66,7 @@ def involvement_sidebar_menu_items query_params: { filters: invitation_filter, sort: "start_time" }), menu_item(title: I18n.t(:label_attended), query_params: { filters: attendee_filter }), - menu_item(title: I18n.t(:label_author), + menu_item(title: I18n.t(:label_created_by_me), query_params: { filters: author_filter }) ] end diff --git a/modules/meeting/config/locales/en.yml b/modules/meeting/config/locales/en.yml index e9b4ce669376..9468a6824d1c 100644 --- a/modules/meeting/config/locales/en.yml +++ b/modules/meeting/config/locales/en.yml @@ -151,7 +151,7 @@ en: label_invitations: "Invitations" label_past_invitations: "Past invitations" label_attended: "Attended" - label_author: "Creator" + label_created_by_me: "Created by me" label_notify: "Send for review" label_icalendar: "Send iCalendar" label_icalendar_download: "Download iCalendar event" From ea01f5fe12b035e7e2fc9d039830b792af90a112 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 27 Nov 2024 08:59:27 +0100 Subject: [PATCH 059/129] Add validation on end_date > start_date --- modules/meeting/app/models/recurring_meeting.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/modules/meeting/app/models/recurring_meeting.rb b/modules/meeting/app/models/recurring_meeting.rb index 3c61702d79ac..cc313edecfde 100644 --- a/modules/meeting/app/models/recurring_meeting.rb +++ b/modules/meeting/app/models/recurring_meeting.rb @@ -8,6 +8,9 @@ class RecurringMeeting < ApplicationRecord validates_presence_of :start_time, :title, :frequency, :end_after validates_presence_of :end_date, if: -> { end_after_specific_date? } validates_numericality_of :iterations, if: -> { end_after_iterations? } + validates :end_date, + date: { after: :start_date }, + if: -> { end_after_specific_date? } enum frequency: { daily: 0, From 68a360c75ef588b8a44e0b2d27b66c21d9b7b756 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 27 Nov 2024 09:46:29 +0100 Subject: [PATCH 060/129] Implement sorting through query --- .../components/meetings/table_component.rb | 6 +--- .../app/controllers/meetings_controller.rb | 6 ++-- .../meeting/app/models/queries/meetings.rb | 2 ++ .../queries/meetings/orders/default_order.rb | 35 +++++++++++++++++++ 4 files changed, 42 insertions(+), 7 deletions(-) create mode 100644 modules/meeting/app/models/queries/meetings/orders/default_order.rb diff --git a/modules/meeting/app/components/meetings/table_component.rb b/modules/meeting/app/components/meetings/table_component.rb index 62e69e52bb80..1e6f2e597163 100644 --- a/modules/meeting/app/components/meetings/table_component.rb +++ b/modules/meeting/app/components/meetings/table_component.rb @@ -39,11 +39,7 @@ class TableComponent < ::OpPrimer::BorderBoxTableComponent mobile_labels :project_name def sortable? - true - end - - def initial_sort - %i[start_time asc] + false end def has_actions? diff --git a/modules/meeting/app/controllers/meetings_controller.rb b/modules/meeting/app/controllers/meetings_controller.rb index b75a89ba7873..09619e86d2d2 100644 --- a/modules/meeting/app/controllers/meetings_controller.rb +++ b/modules/meeting/app/controllers/meetings_controller.rb @@ -337,17 +337,19 @@ def load_query ).call(params) apply_default_filter_if_none_given(query) - apply_time_filter(query) + apply_time_filter_and_sort(query) query.where("project_id", "=", @project.id) if @project query end - def apply_time_filter(query) + def apply_time_filter_and_sort(query) if params[:upcoming] == "false" query.where("time", "=", Queries::Meetings::Filters::TimeFilter::PAST_VALUE) + query.order(start_time: :desc) else query.where("time", "=", Queries::Meetings::Filters::TimeFilter::FUTURE_VALUE) + query.order(start_time: :asc) end end diff --git a/modules/meeting/app/models/queries/meetings.rb b/modules/meeting/app/models/queries/meetings.rb index 9a10278fbdf6..e70e0704e12b 100644 --- a/modules/meeting/app/models/queries/meetings.rb +++ b/modules/meeting/app/models/queries/meetings.rb @@ -35,5 +35,7 @@ module Queries::Meetings filter Filters::AuthorFilter filter Filters::DatesIntervalFilter filter Filters::RecurringFilter + + order Orders::DefaultOrder end end diff --git a/modules/meeting/app/models/queries/meetings/orders/default_order.rb b/modules/meeting/app/models/queries/meetings/orders/default_order.rb new file mode 100644 index 000000000000..bc791c63d74e --- /dev/null +++ b/modules/meeting/app/models/queries/meetings/orders/default_order.rb @@ -0,0 +1,35 @@ +#-- 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. +#++ + +class Queries::Meetings::Orders::DefaultOrder < Queries::Orders::Base + self.model = Meetings + + def self.key + /\A(id|start_time)\z/ + end +end From 67063d09c71cd2ba612f274dd90996be0c06fd94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 27 Nov 2024 11:06:10 +0100 Subject: [PATCH 061/129] Render all meeting series in the sidebar --- modules/meeting/app/menus/meetings/menu.rb | 25 +++++++++++++++++++++- modules/meeting/config/locales/en.yml | 1 + 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/modules/meeting/app/menus/meetings/menu.rb b/modules/meeting/app/menus/meetings/menu.rb index 134f87e44b3f..2e5ad433d1d9 100644 --- a/modules/meeting/app/menus/meetings/menu.rb +++ b/modules/meeting/app/menus/meetings/menu.rb @@ -34,8 +34,9 @@ def initialize(params:, project: nil) def menu_items [ OpenProject::Menu::MenuGroup.new(header: nil, children: top_level_menu_items), + meeting_series_menu_group, OpenProject::Menu::MenuGroup.new(header: I18n.t(:label_involvement), children: involvement_sidebar_menu_items) - ] + ].compact end def top_level_menu_items @@ -49,6 +50,28 @@ def top_level_menu_items ].compact end + def meeting_series_menu_group + return unless OpenProject::FeatureDecisions.recurring_meetings_active? + + OpenProject::Menu::MenuGroup.new(header: I18n.t(:label_meeting_series), children: meeting_series_menu_items) + end + + def meeting_series_menu_items + series = RecurringMeeting.visible + + if project + series = series.where(project_id: project.id) + end + + series.pluck(:id, :title) + .map do |id, title| + href = polymorphic_path([project, :recurring_meeting], { id: }) + OpenProject::Menu::MenuItem.new(title:, + selected: params[:current_href] == href, + href:) + end + end + def recurring_menu_item return unless OpenProject::FeatureDecisions.recurring_meetings_active? diff --git a/modules/meeting/config/locales/en.yml b/modules/meeting/config/locales/en.yml index 9468a6824d1c..af6a10f4ab14 100644 --- a/modules/meeting/config/locales/en.yml +++ b/modules/meeting/config/locales/en.yml @@ -156,6 +156,7 @@ en: label_icalendar: "Send iCalendar" label_icalendar_download: "Download iCalendar event" label_view_meeting_series: "View meeting series" + label_meeting_series: "Meeting series" label_version: "Version" label_time_zone: "Time zone" label_start_date: "Start date" From ac73ea3d8e7aef6fd7117cc1ec04e6c4de3b7439 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 27 Nov 2024 11:09:21 +0100 Subject: [PATCH 062/129] Select my meetings only when href passed --- app/menus/submenu.rb | 5 +++-- modules/meeting/app/menus/meetings/menu.rb | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/app/menus/submenu.rb b/app/menus/submenu.rb index f1653935796e..6bd27e89a4a5 100644 --- a/app/menus/submenu.rb +++ b/app/menus/submenu.rb @@ -108,12 +108,13 @@ def query_params(id) { query_id: id } end - def menu_item(title:, icon_key: nil, count: nil, show_enterprise_icon: false, query_params: {}) + def menu_item(title:, icon_key: nil, count: nil, show_enterprise_icon: false, + query_params: {}, selected: selected?(query_params)) OpenProject::Menu::MenuItem.new(title:, href: query_path(query_params), icon: icon_map.fetch(icon_key, icon_key), count:, - selected: selected?(query_params), + selected:, favored: favored?(query_params), show_enterprise_icon:) end diff --git a/modules/meeting/app/menus/meetings/menu.rb b/modules/meeting/app/menus/meetings/menu.rb index 2e5ad433d1d9..a1de94d18fb0 100644 --- a/modules/meeting/app/menus/meetings/menu.rb +++ b/modules/meeting/app/menus/meetings/menu.rb @@ -41,9 +41,10 @@ def menu_items def top_level_menu_items all_filter = [{ invited_user_id: { operator: "*", values: [] } }].to_json + my_meetings_href = polymorphic_path([project, :meetings]) [ - menu_item(title: I18n.t(:label_my_meetings)), + menu_item(title: I18n.t(:label_my_meetings), selected: params[:current_href] == my_meetings_href), recurring_menu_item, menu_item(title: I18n.t(:label_all_meetings), query_params: { filters: all_filter }) From 932ff900246862ee91c6ca11c3c9771cc9c46991 Mon Sep 17 00:00:00 2001 From: Mir Bhatia Date: Wed, 27 Nov 2024 17:50:31 +0100 Subject: [PATCH 063/129] WIP Add initial specs --- .../show_page_header_component.html.erb | 5 +- .../recurring_meetings_controller.rb | 1 + .../recurring_meeting_crud_spec.rb | 143 ++++++++++++++++++ .../spec/support/pages/meetings/index.rb | 4 + .../support/pages/recurring_meeting/show.rb | 82 ++++++++++ 5 files changed, 234 insertions(+), 1 deletion(-) create mode 100644 modules/meeting/spec/features/recurring_meetings/recurring_meeting_crud_spec.rb create mode 100644 modules/meeting/spec/support/pages/recurring_meeting/show.rb diff --git a/modules/meeting/app/components/recurring_meetings/show_page_header_component.html.erb b/modules/meeting/app/components/recurring_meetings/show_page_header_component.html.erb index 0156033717c1..2aa28d54b494 100644 --- a/modules/meeting/app/components/recurring_meetings/show_page_header_component.html.erb +++ b/modules/meeting/app/components/recurring_meetings/show_page_header_component.html.erb @@ -18,7 +18,10 @@ header.with_action_menu(menu_arguments: { anchor_align: :end }, button_arguments: { icon: "op-kebab-vertical", classes: "hide-when-print", - "aria-label": "Menu" }) do |menu, _button| + "aria-label": "Menu", + data: { + "test-selector": "action-menu" + }}) do |menu, _button| menu.with_item(label: I18n.t(:label_recurring_meeting_series_edit), href: edit_recurring_meeting_path(@meeting)) menu.with_item(label: I18n.t(:label_recurring_meeting_series_delete), href: recurring_meeting_path(@meeting), diff --git a/modules/meeting/app/controllers/recurring_meetings_controller.rb b/modules/meeting/app/controllers/recurring_meetings_controller.rb index 19fa45774e34..0111822fe040 100644 --- a/modules/meeting/app/controllers/recurring_meetings_controller.rb +++ b/modules/meeting/app/controllers/recurring_meetings_controller.rb @@ -80,6 +80,7 @@ def create .call(@converted_params) if call.success? + flash[:notice] = I18n.t(:notice_successful_create).html_safe redirect_to status: :see_other, action: :show, id: call.result else respond_to do |format| diff --git a/modules/meeting/spec/features/recurring_meetings/recurring_meeting_crud_spec.rb b/modules/meeting/spec/features/recurring_meetings/recurring_meeting_crud_spec.rb new file mode 100644 index 000000000000..d5c74e11a65a --- /dev/null +++ b/modules/meeting/spec/features/recurring_meetings/recurring_meeting_crud_spec.rb @@ -0,0 +1,143 @@ +#-- 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" + +require_relative "../../support/pages/structured_meeting/show" +require_relative "../../support/pages/recurring_meeting/show" +require_relative "../../support/pages/meetings/index" + +RSpec.describe "Recurring meetings CRUD", + :js, + :with_cuprite do + include Components::Autocompleter::NgSelectAutocompleteHelpers + + shared_let(:project) { create(:project, enabled_module_names: %w[meetings]) } + shared_let(:user) do + create(:user, + lastname: "First", + member_with_permissions: { project => %i[view_meetings create_meetings edit_meetings delete_meetings] }).tap do |u| + u.pref[:time_zone] = "Etc/UTC" + + u.save! + end + end + shared_let(:other_user) do + create(:user, + lastname: "Second", + member_with_permissions: { project => %i[view_meetings] }) + end + shared_let(:no_member_user) do + create(:user, + lastname: "Third") + end + + let(:current_user) { user } + let(:meeting) { RecurringMeeting.order(id: :asc).last } + let(:show_page) { Pages::RecurringMeeting::Show.new(meeting) } + let(:meetings_page) { Pages::Meetings::Index.new(project:) } + + before do + login_as current_user + meetings_page.visit! + expect(page).to have_current_path(meetings_page.path) # rubocop:disable RSpec/ExpectInHook + meetings_page.click_on "add-meeting-button" + meetings_page.click_on "Recurring" + meetings_page.set_title "Some title" + + meetings_page.set_start_date "2024-12-31" + meetings_page.set_start_time "13:30" + meetings_page.set_duration "1.5" + + meetings_page.set_end_date "2025-01-02" + + click_on "Create meeting" + + wait_for_network_idle + + meeting = RecurringMeeting.last + + Pages::RecurringMeeting::Show.new(meeting) + end + + it "can create a recurring meeting", with_flag: { recurring_meetings: true } do + expect_flash(type: :success, message: "Successful creation.") + + # Does not send invitation mails by default + perform_enqueued_jobs + expect(ActionMailer::Base.deliveries.size).to eq 0 + + expect(page).to have_css(".start_time", count: 3) + + show_page.expect_open_meeting date: "2024-12-31 01:30 PM" + show_page.expect_scheduled_meeting date: "2024-01-01 01:30 PM" + show_page.expect_scheduled_meeting date: "2024-01-02 01:30 PM" + end + + it "can delete a recurring meeting", with_flag: { recurring_meetings: true } do + click_on "action-menu" + + accept_confirm(I18n.t("text_are_you_sure")) do + click_on "Delete meeting series" + end + + expect(page).to have_current_path project_meetings_path(project) + end + + it "can use the 'Create from template' button", with_flag: { recurring_meetings: true } do + show_page.create_from_template date: "2024-01-01 01:30 PM" + + expect(page).to have_current_path meeting_path(Meeting.last) + + show_page.visit! + + show_page.expect_no_scheduled_meeting date: "2024-01-01 01:30 PM" + show_page.expect_open_meeting date: "2024-01-01 01:30 PM" + end + + it "can cancel an occurrence", with_flag: { recurring_meetings: true } do + accept_confirm(I18n.t("text_are_you_sure")) do + show_page.cancel_occurrence date: "2024-12-31 01:30 PM" + end + + expect_flash(type: :success, message: "Successful deletion.") + + expect(page).to have_current_path recurring_meeting_path(meeting) + + show_page.expect_no_open_meeting date: "2024-12-31 01:30 PM" + show_page.expect_cancelled_meeting date: "2024-12-31 01:30 PM" + end + + # it "can edit the details of a recurring meeting", with_flag: { recurring_meetings: true } do + # + # end + + # it "shows the correct actions based on status", with_flag: { recurring_meetings: true } do + # + # end +end diff --git a/modules/meeting/spec/support/pages/meetings/index.rb b/modules/meeting/spec/support/pages/meetings/index.rb index e8633c583891..0cf0026e3f62 100644 --- a/modules/meeting/spec/support/pages/meetings/index.rb +++ b/modules/meeting/spec/support/pages/meetings/index.rb @@ -60,6 +60,10 @@ def set_start_time(time) page.execute_script("arguments[0].value = arguments[1]", input.native, time) end + def set_end_date(date) + fill_in "End date", with: date, fill_options: { clear: :backspace } + end + def set_project(project) select_autocomplete find("[data-test-selector='project_id']"), query: project.name, diff --git a/modules/meeting/spec/support/pages/recurring_meeting/show.rb b/modules/meeting/spec/support/pages/recurring_meeting/show.rb new file mode 100644 index 000000000000..1af16e11a40e --- /dev/null +++ b/modules/meeting/spec/support/pages/recurring_meeting/show.rb @@ -0,0 +1,82 @@ +#-- 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_relative "../meetings/show" + +module Pages::RecurringMeeting + class Show < ::Pages::Meetings::Show + include ::Components::Autocompleter::NgSelectAutocompleteHelpers + + def expect_scheduled_meeting(date:) + within("li", text: date) do + expect(page).to have_css(".status", text: "Scheduled") + end + end + + def expect_no_scheduled_meeting(date:) + within("li", text: date) do + expect(page).to have_no_css(".status", text: "Scheduled") + end + end + + def expect_open_meeting(date:) + within("li", text: date) do + expect(page).to have_css(".status", text: "Open") + end + end + + def expect_no_open_meeting(date:) + within("li", text: date) do + expect(page).to have_no_css(".status", text: "Open") + end + end + + def expect_cancelled_meeting(date:) + within("li", text: date) do + expect(page).to have_css(".status", text: "Cancelled") + end + end + + def create_from_template(date:) + within("li", text: date) do + click_on "Create from template" + end + end + + def cancel_occurrence(date:) + within("li", text: date) do + click_on "more-button" + click_on "Cancel this occurrence" + end + end + + # def for_meeting(date:, &) + # within("li", text: date, &) + # end + end +end From e3f20c7cdafea262f3394715f575b1bb0a6e8399 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Thu, 28 Nov 2024 10:08:20 +0100 Subject: [PATCH 064/129] Fix date validation --- config/locales/en.yml | 1 + .../meeting/app/models/recurring_meeting.rb | 54 ++++++++++++------- 2 files changed, 35 insertions(+), 20 deletions(-) diff --git a/config/locales/en.yml b/config/locales/en.yml index c4e2d3c6de88..67edf3bc1268 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1017,6 +1017,7 @@ en: messages: accepted: "must be accepted." after: "must be after %{date}." + after_today: "must be in the future." after_or_equal_to: "must be after or equal to %{date}." before: "must be before %{date}." before_or_equal_to: "must be before or equal to %{date}." diff --git a/modules/meeting/app/models/recurring_meeting.rb b/modules/meeting/app/models/recurring_meeting.rb index cc313edecfde..d85b030f6efb 100644 --- a/modules/meeting/app/models/recurring_meeting.rb +++ b/modules/meeting/app/models/recurring_meeting.rb @@ -1,5 +1,7 @@ class RecurringMeeting < ApplicationRecord include ::Meeting::VirtualStartTime + include Redmine::I18n + belongs_to :project belongs_to :author, class_name: "User" @@ -8,20 +10,20 @@ class RecurringMeeting < ApplicationRecord validates_presence_of :start_time, :title, :frequency, :end_after validates_presence_of :end_date, if: -> { end_after_specific_date? } validates_numericality_of :iterations, if: -> { end_after_iterations? } - validates :end_date, - date: { after: :start_date }, - if: -> { end_after_specific_date? } + + validate :end_date_constraints, + if: -> { end_after_specific_date? } enum frequency: { daily: 0, working_days: 1, weekly: 2 - }.freeze, _prefix: true + }.freeze, _prefix: true, _default: "weekly" enum end_after: { specific_date: 0, iterations: 1 - }.freeze, _prefix: true + }.freeze, _prefix: true, _default: "specific_date" has_many :meetings, inverse_of: :recurring_meeting @@ -68,21 +70,21 @@ def schedule def schedule_in_words # rubocop:disable Metrics/AbcSize base = case frequency - when "daily" - interval == 1 ? human_frequency : I18n.t("recurring_meeting.in_words.daily_interval", interval: interval.ordinalize) - when "working_days" - if interval == 1 - I18n.t("recurring_meeting.in_words.working_days") - else - I18n.t("recurring_meeting.in_words.working_days_interval", interval: interval.ordinalize) - end - when "weekly" - if interval == 1 - I18n.t("recurring_meeting.in_words.weekly", weekday:) - else - I18n.t("recurring_meeting.in_words.weekly_interval", interval: interval.ordinalize, weekday:) - end - end + when "daily" + interval == 1 ? human_frequency : I18n.t("recurring_meeting.in_words.daily_interval", interval: interval.ordinalize) + when "working_days" + if interval == 1 + I18n.t("recurring_meeting.in_words.working_days") + else + I18n.t("recurring_meeting.in_words.working_days_interval", interval: interval.ordinalize) + end + when "weekly" + if interval == 1 + I18n.t("recurring_meeting.in_words.weekly", weekday:) + else + I18n.t("recurring_meeting.in_words.weekly_interval", interval: interval.ordinalize, weekday:) + end + end I18n.t("recurring_meeting.in_words.full", base:, time: format_time(start_time, include_date: false), end_date:) end @@ -110,6 +112,18 @@ def instances(upcoming: true) private + def end_date_constraints + return if end_date.nil? + + if end_date < Date.current + errors.add(:end_date, :after_today) + end + + if parsed_start_date.present? && end_date < parsed_start_date + errors.add(:end_date, :after, date: format_date(parsed_start_date)) + end + end + def exclude_non_working_days(schedule) NonWorkingDay .where(date: start_date...) From d5aa2b98888bcdecbe04deb0b50c04c34dff6cfc Mon Sep 17 00:00:00 2001 From: Mir Bhatia Date: Thu, 28 Nov 2024 11:31:47 +0100 Subject: [PATCH 065/129] Use the correct date formatter in the subtitle --- .../recurring_meetings/table_component.rb | 8 ----- .../meeting/app/models/recurring_meeting.rb | 35 ++++++++++--------- 2 files changed, 18 insertions(+), 25 deletions(-) diff --git a/modules/meeting/app/components/recurring_meetings/table_component.rb b/modules/meeting/app/components/recurring_meetings/table_component.rb index eb4ade4561ac..3d5aea0673c5 100644 --- a/modules/meeting/app/components/recurring_meetings/table_component.rb +++ b/modules/meeting/app/components/recurring_meetings/table_component.rb @@ -32,14 +32,6 @@ module RecurringMeetings class TableComponent < ::OpPrimer::BorderBoxTableComponent columns :start_time, :relative_time, :last_edited, :status, :create - # def sortable? - # true - # end - # - # def initial_sort - # %i[start_time asc] - # end - def has_actions? true end diff --git a/modules/meeting/app/models/recurring_meeting.rb b/modules/meeting/app/models/recurring_meeting.rb index d85b030f6efb..a73b589d6c42 100644 --- a/modules/meeting/app/models/recurring_meeting.rb +++ b/modules/meeting/app/models/recurring_meeting.rb @@ -70,23 +70,24 @@ def schedule def schedule_in_words # rubocop:disable Metrics/AbcSize base = case frequency - when "daily" - interval == 1 ? human_frequency : I18n.t("recurring_meeting.in_words.daily_interval", interval: interval.ordinalize) - when "working_days" - if interval == 1 - I18n.t("recurring_meeting.in_words.working_days") - else - I18n.t("recurring_meeting.in_words.working_days_interval", interval: interval.ordinalize) - end - when "weekly" - if interval == 1 - I18n.t("recurring_meeting.in_words.weekly", weekday:) - else - I18n.t("recurring_meeting.in_words.weekly_interval", interval: interval.ordinalize, weekday:) - end - end - - I18n.t("recurring_meeting.in_words.full", base:, time: format_time(start_time, include_date: false), end_date:) + when "daily" + interval == 1 ? human_frequency : I18n.t("recurring_meeting.in_words.daily_interval", interval: interval.ordinalize) + when "working_days" + if interval == 1 + I18n.t("recurring_meeting.in_words.working_days") + else + I18n.t("recurring_meeting.in_words.working_days_interval", interval: interval.ordinalize) + end + when "weekly" + if interval == 1 + I18n.t("recurring_meeting.in_words.weekly", weekday:) + else + I18n.t("recurring_meeting.in_words.weekly_interval", interval: interval.ordinalize, weekday:) + end + end + + I18n.t("recurring_meeting.in_words.full", base:, time: format_time(start_time, include_date: false), + end_date: format_date(end_date)) end def scheduled_occurrences(limit:) From 5a72451f0beeb327d51016d83c8001eb090a45d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Thu, 28 Nov 2024 14:53:58 +0100 Subject: [PATCH 066/129] Add spec for date validation --- .../factories/recurring_meeting_factory.rb | 54 +++++++++++++++++++ .../spec/models/recurring_meeting_spec.rb | 30 +++++++++-- 2 files changed, 81 insertions(+), 3 deletions(-) create mode 100644 modules/meeting/spec/factories/recurring_meeting_factory.rb diff --git a/modules/meeting/spec/factories/recurring_meeting_factory.rb b/modules/meeting/spec/factories/recurring_meeting_factory.rb new file mode 100644 index 000000000000..94ae8a616ec8 --- /dev/null +++ b/modules/meeting/spec/factories/recurring_meeting_factory.rb @@ -0,0 +1,54 @@ +#-- 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. +#++ + +FactoryBot.define do + factory :recurring_meeting, class: "RecurringMeeting" do |m| + author factory: :user + project + start_time { Date.tomorrow + 10.hours } + end_date { 1.year.from_now } + duration { 1.0 } + frequency { "weekly" } + interval { 1 } + iterations { 10 } + end_after { "specific_date" } + + location { "https://some-url.com" } + m.sequence(:title) { |n| "Meeting series #{n}" } + + after(:create) do |recurring_meeting, evaluator| + recurring_meeting.project = evaluator.project if evaluator.project + recurring_meeting.template = create(:meeting, recurring_meeting:) + end + + after(:stub) do |recurring_meeting, evaluator| + recurring_meeting.project = evaluator.project if evaluator.project + recurring_meeting.template = build_stubbed(:meeting, recurring_meeting:) + end + end +end diff --git a/modules/meeting/spec/models/recurring_meeting_spec.rb b/modules/meeting/spec/models/recurring_meeting_spec.rb index 51386b89bf4b..59fdd04eebed 100644 --- a/modules/meeting/spec/models/recurring_meeting_spec.rb +++ b/modules/meeting/spec/models/recurring_meeting_spec.rb @@ -1,5 +1,29 @@ -require 'rails_helper' +require "spec_helper" +require_module_spec_helper -RSpec.describe RecurringMeeting, type: :model do - pending "add some examples to (or delete) #{__FILE__}" +RSpec.describe RecurringMeeting, + with_settings: { + date_format: "%Y-%m-%d" + } do + describe "end_date" do + subject { build(:recurring_meeting, start_date: (Date.current + 2.days).iso8601, end_date:) } + + context "with end_date before start_date" do + let(:end_date) { Date.current + 1.day } + + it "is invalid" do + expect(subject).not_to be_valid + expect(subject.errors[:end_date]).to include("must be after #{subject.start_date}.") + end + end + + context "with end_date in the past" do + let(:end_date) { Date.yesterday } + + it "is invalid" do + expect(subject).not_to be_valid + expect(subject.errors[:end_date]).to include("must be in the future.") + end + end + end end From e3029d5448f0eabe8403a3006f5969a4fe2a4697 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Thu, 28 Nov 2024 16:01:45 +0100 Subject: [PATCH 067/129] Change label to View template --- .../show_page_header_component.html.erb | 9 +++------ modules/meeting/config/locales/en.yml | 1 + 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/modules/meeting/app/components/recurring_meetings/show_page_header_component.html.erb b/modules/meeting/app/components/recurring_meetings/show_page_header_component.html.erb index 2aa28d54b494..6aa94c30cf98 100644 --- a/modules/meeting/app/components/recurring_meetings/show_page_header_component.html.erb +++ b/modules/meeting/app/components/recurring_meetings/show_page_header_component.html.erb @@ -5,14 +5,11 @@ header.with_action_button( tag: :a, - mobile_label: I18n.t("recurring_meeting.template.label_edit_template"), - mobile_icon: :pencil, + mobile_label: I18n.t("recurring_meeting.template.label_view_template"), + mobile_icon: :eye, size: :medium, href: meeting_path(@meeting.template) - ) do |button| - button.with_leading_visual_icon(icon: :pencil) - I18n.t("recurring_meeting.template.label_edit_template") - end + ) { I18n.t("recurring_meeting.template.label_view_template") } if render_create_button? header.with_action_menu(menu_arguments: { anchor_align: :end }, diff --git a/modules/meeting/config/locales/en.yml b/modules/meeting/config/locales/en.yml index af6a10f4ab14..10c5886626df 100644 --- a/modules/meeting/config/locales/en.yml +++ b/modules/meeting/config/locales/en.yml @@ -213,6 +213,7 @@ en: occurrence: infoline: "This meeting is part of a recurring meeting series." template: + label_view_template: "View template" label_edit_template: "Edit template" banner_html: > You are currently editing a template of a meeting series: %{link}. From b659bc22a1367eb690eb1b0fdd16c79531d10768 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Thu, 28 Nov 2024 16:05:12 +0100 Subject: [PATCH 068/129] Add edit series button --- .../show_page_header_component.html.erb | 29 ++++++++++++++----- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/modules/meeting/app/components/recurring_meetings/show_page_header_component.html.erb b/modules/meeting/app/components/recurring_meetings/show_page_header_component.html.erb index 6aa94c30cf98..79f8c655610a 100644 --- a/modules/meeting/app/components/recurring_meetings/show_page_header_component.html.erb +++ b/modules/meeting/app/components/recurring_meetings/show_page_header_component.html.erb @@ -18,14 +18,27 @@ "aria-label": "Menu", data: { "test-selector": "action-menu" - }}) do |menu, _button| - menu.with_item(label: I18n.t(:label_recurring_meeting_series_edit), href: edit_recurring_meeting_path(@meeting)) - menu.with_item(label: I18n.t(:label_recurring_meeting_series_delete), - href: recurring_meeting_path(@meeting), - scheme: :danger, - form_arguments: { - method: :delete, data: { confirm: t("text_are_you_sure"), turbo: 'false' } - }) + } }) do |menu, _button| + + menu.with_item( + label: I18n.t(:label_recurring_meeting_series_edit), + icon: :gear, + href: details_dialog_recurring_meeting_path(@meeting), + tag: :a, + content_arguments: { + data: { controller: 'async-dialog' }, + }, + 'aria-label': t(:label_recurring_meeting_series_edit), + test_selector: "edit-meeting-details-button", + ) + menu.with_item( + label: I18n.t(:label_recurring_meeting_series_delete), + href: recurring_meeting_path(@meeting), + scheme: :danger, + form_arguments: { + method: :delete, data: { confirm: t("text_are_you_sure"), turbo: 'false' } + } + ) end end end %> From 12e098d4eca6e189d47c95ac394b1b3434407e85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Thu, 28 Nov 2024 16:23:58 +0100 Subject: [PATCH 069/129] Fix deletion of recurring meetings --- .../controllers/recurring_meetings_controller.rb | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/modules/meeting/app/controllers/recurring_meetings_controller.rb b/modules/meeting/app/controllers/recurring_meetings_controller.rb index 0111822fe040..d6ba65c015a0 100644 --- a/modules/meeting/app/controllers/recurring_meetings_controller.rb +++ b/modules/meeting/app/controllers/recurring_meetings_controller.rb @@ -129,10 +129,17 @@ def update end def destroy - @recurring_meeting.destroy - flash[:notice] = I18n.t(:notice_successful_delete) + if @recurring_meeting.destroy + flash[:notice] = I18n.t(:notice_successful_delete) + else + flash[:error] = I18n.t(:error_failed_to_delete_entry) + end - redirect_to action: "index", project_id: @project + respond_to do |format| + format.html do + redirect_to polymorphic_path([@project, :meetings]) + end + end end private From 4249e15fac38649846333101e3a3794b3b6ce78e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Thu, 28 Nov 2024 16:26:15 +0100 Subject: [PATCH 070/129] Correctly hide form for end_after options --- .../app/components/meetings/index/form_component.html.erb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/modules/meeting/app/components/meetings/index/form_component.html.erb b/modules/meeting/app/components/meetings/index/form_component.html.erb index e90dce40628e..bd3bb1d86437 100644 --- a/modules/meeting/app/components/meetings/index/form_component.html.erb +++ b/modules/meeting/app/components/meetings/index/form_component.html.erb @@ -67,6 +67,7 @@ end modal_body.with_row(mt: 3, + hidden: @meeting.end_after_iterations?, data: { value: "specific_date", target_name: "end_after", @@ -76,7 +77,7 @@ end modal_body.with_row(mt: 3, - hidden: true, + hidden: @meeting.end_after_specific_date?, data: { value: "iterations", target_name: "end_after", From 709e0242dc7a28fd0f91e3c562b83dec61e41bdf Mon Sep 17 00:00:00 2001 From: Mir Bhatia Date: Thu, 28 Nov 2024 17:55:41 +0100 Subject: [PATCH 071/129] Update copy behaviour to never allow recurring copies --- .../app/components/meetings/header_component.html.erb | 2 +- .../app/components/meetings/header_component.rb | 10 +++++++++- modules/meeting/app/controllers/meetings_controller.rb | 3 +++ modules/meeting/config/locales/en.yml | 1 + 4 files changed, 14 insertions(+), 2 deletions(-) diff --git a/modules/meeting/app/components/meetings/header_component.html.erb b/modules/meeting/app/components/meetings/header_component.html.erb index bed81ee17629..f434c2a4f07a 100644 --- a/modules/meeting/app/components/meetings/header_component.html.erb +++ b/modules/meeting/app/components/meetings/header_component.html.erb @@ -44,7 +44,7 @@ end if @meeting.editable? && !@series unless @meeting.template? - menu.with_item(label: t(:button_copy), + menu.with_item(label: copy_label, href: copy_meeting_path(@meeting), content_arguments: { data: { turbo_stream: true } diff --git a/modules/meeting/app/components/meetings/header_component.rb b/modules/meeting/app/components/meetings/header_component.rb index 8ed9ec71dbd5..751a02d4929f 100644 --- a/modules/meeting/app/components/meetings/header_component.rb +++ b/modules/meeting/app/components/meetings/header_component.rb @@ -93,11 +93,19 @@ def parent_element end def delete_label - if @meeting.recurring_meeting.present? + if @series.present? I18n.t("label_recurring_meeting_cancel") else I18n.t("label_meeting_delete") end end + + def copy_label + if @series.present? + I18n.t("label_recurring_meeting_copy") + else + I18n.t("button_copy") + end + end end end diff --git a/modules/meeting/app/controllers/meetings_controller.rb b/modules/meeting/app/controllers/meetings_controller.rb index 09619e86d2d2..4d6da1c7df27 100644 --- a/modules/meeting/app/controllers/meetings_controller.rb +++ b/modules/meeting/app/controllers/meetings_controller.rb @@ -413,6 +413,9 @@ def convert_params else force_defaults end + + # Recurring meeting occurrences can only be copied as one-off meetings + @converted_params[:recurring_meeting_id] = nil end def meeting_params diff --git a/modules/meeting/config/locales/en.yml b/modules/meeting/config/locales/en.yml index 10c5886626df..bf2cad1d5b9d 100644 --- a/modules/meeting/config/locales/en.yml +++ b/modules/meeting/config/locales/en.yml @@ -132,6 +132,7 @@ en: label_template: "Template" label_recurring_meeting_view: "View meeting series" label_recurring_meeting_create: "Create from template" + label_recurring_meeting_copy: "Copy as one-off" label_recurring_meeting_cancel: "Cancel this occurrence" label_recurring_meeting_delete: "Delete occurrence" label_recurring_meeting_delete_confirmation: > From b3de8d0a4da042ba016d442a07ffb5561011612c Mon Sep 17 00:00:00 2001 From: Mir Bhatia Date: Thu, 28 Nov 2024 18:02:48 +0100 Subject: [PATCH 072/129] Fix location value in form --- modules/meeting/app/forms/meeting/location.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/meeting/app/forms/meeting/location.rb b/modules/meeting/app/forms/meeting/location.rb index 6cad512c0a50..996b17e033b9 100644 --- a/modules/meeting/app/forms/meeting/location.rb +++ b/modules/meeting/app/forms/meeting/location.rb @@ -43,9 +43,9 @@ def initialize(meeting:) @value = if meeting.is_a?(RecurringMeeting) && meeting.template - meeting.template.duration + meeting.template.location else - meeting.duration + meeting.location end end end From 28b134a7a3115be32d72e3dfcd918e88577bc3e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Thu, 28 Nov 2024 19:58:10 +0100 Subject: [PATCH 073/129] Fix meeting details form --- .../side_panel/details_form_component.html.erb | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/modules/meeting/app/components/meetings/side_panel/details_form_component.html.erb b/modules/meeting/app/components/meetings/side_panel/details_form_component.html.erb index 0454078438f3..cdc2bbc4d52c 100644 --- a/modules/meeting/app/components/meetings/side_panel/details_form_component.html.erb +++ b/modules/meeting/app/components/meetings/side_panel/details_form_component.html.erb @@ -14,19 +14,15 @@ end modal_body.with_row(mt: 3) do - render(Meeting::StartDate.new(f, initial_value: start_date_initial_value)) + render(Meeting::TimeGroup.new(f, meeting: @meeting)) end modal_body.with_row(mt: 3) do - render(Meeting::StartTime.new(f, initial_value: start_time_initial_value)) + render(Meeting::Duration.new(f, meeting: @meeting)) end modal_body.with_row(mt: 3) do - render(Meeting::Duration.new(f)) - end - - modal_body.with_row(mt: 3) do - render(Meeting::Location.new(f)) + render(Meeting::Location.new(f, meeting: @meeting)) end modal_body.with_row do From a23b0347ff2ab7d39e4c8fefc53dfc478fc87531 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Thu, 28 Nov 2024 20:48:08 +0100 Subject: [PATCH 074/129] Add scheduled meetings table --- .../recurring_meetings/row_component.rb | 61 ++++++++------ .../recurring_meetings_controller.rb | 30 ++----- .../meeting/app/models/recurring_meeting.rb | 15 ++-- .../skeleton.rb => scheduled_meeting.rb} | 10 ++- .../init_occurrence_service.rb | 82 +++++++++++++++++++ ...0241128190428_create_scheduled_meetings.rb | 16 ++++ 6 files changed, 161 insertions(+), 53 deletions(-) rename modules/meeting/app/models/{recurring_meetings/skeleton.rb => scheduled_meeting.rb} (83%) create mode 100644 modules/meeting/app/services/recurring_meetings/init_occurrence_service.rb create mode 100644 modules/meeting/db/migrate/20241128190428_create_scheduled_meetings.rb diff --git a/modules/meeting/app/components/recurring_meetings/row_component.rb b/modules/meeting/app/components/recurring_meetings/row_component.rb index 00f75c2fcefe..e30d7d3996eb 100644 --- a/modules/meeting/app/components/recurring_meetings/row_component.rb +++ b/modules/meeting/app/components/recurring_meetings/row_component.rb @@ -30,16 +30,14 @@ module RecurringMeetings class RowComponent < ::OpPrimer::BorderBoxRowComponent - def meeting - model - end - - def schedule - meeting.schedule - end + delegate :meeting, to: :model + delegate :cancelled?, to: :model + delegate :recurring_meeting, to: :model + delegate :project, to: :recurring_meeting + delegate :schedule, to: :meeting def instantiated? - meeting.is_a?(Meeting) + meeting.present? end def column_args(column) @@ -59,11 +57,22 @@ def start_time end def start_time_title - helpers.format_time(meeting.start_time, include_date: true) + helpers.format_time(start_time_value, include_date: true) end def relative_time - render(OpPrimer::RelativeTimeComponent.new(datetime: meeting.start_time, prefix: I18n.t(:label_on))) + render(OpPrimer::RelativeTimeComponent.new(datetime: start_time_value, prefix: I18n.t(:label_on))) + end + + def start_time_value + if instantiated? + meeting.start_time + else + recurring_meeting + .template + .start_time + .change(year: model.date.year, month: model.date.month, day: model.date.day) + end end def last_edited @@ -72,11 +81,21 @@ def last_edited helpers.format_time(meeting.updated_at, include_date: true) end + def state + if model.cancelled? + :cancelled + elsif instantiated? + meeting.state + else + :scheduled + end + end + def status - scheme = status_scheme(model.state) + scheme = status_scheme(state) render(Primer::Beta::Label.new(title:, scheme:)) do - render(Primer::Beta::Text.new) { t("label_meeting_state_#{model.state}") } + render(Primer::Beta::Text.new) { t("label_meeting_state_#{state}") } end end @@ -86,8 +105,6 @@ def status_scheme(state) :success when "cancelled" :severe - when "skipped" - :attention else :secondary end @@ -102,7 +119,7 @@ def create size: :medium, tag: :a, data: { "turbo-method": "post"}, - href: init_recurring_meeting_path(model.recurring_meeting.id, start_time: model.start_time) + href: init_recurring_meeting_path(model.recurring_meeting.id, date: model.date) ) ) do |_c| I18n.t("label_recurring_meeting_create") @@ -140,7 +157,7 @@ def action_menu def ical_action(menu) menu.with_item(label: I18n.t(:label_icalendar_download), - href: download_ics_meeting_path(Meeting.find(model["id"])), + href: download_ics_meeting_path(meeting), content_arguments: { data: { turbo: false } }) do |item| @@ -151,7 +168,7 @@ def ical_action(menu) def delete_action(menu) menu.with_item(label: I18n.t(:label_recurring_meeting_cancel), scheme: :danger, - href: meeting_path(Meeting.find(model["id"])), + href: meeting_path(meeting), form_arguments: { method: :delete, data: { confirm: I18n.t("text_are_you_sure"), turbo: false } }) do |item| @@ -161,21 +178,17 @@ def delete_action(menu) def restore_action(menu) menu.with_item(label: I18n.t(:label_recurring_meeting_restore), - href: restore_meeting_path(Meeting.find(model["id"]))) do |item| + href: init_recurring_meeting_path(recurring_meeting, date: model.date)) do |item| item.with_leading_visual_icon(icon: :history) end end def delete_allowed? - User.current.allowed_in_project?(:delete_meetings, Project.find(model["project_id"])) + User.current.allowed_in_project?(:delete_meetings, project) end def copy_allowed? - User.current.allowed_in_project?(:create_meetings, Project.find(model["project_id"])) - end - - def cancelled? - instantiated? && model.cancelled? + User.current.allowed_in_project?(:create_meetings, project) end end end diff --git a/modules/meeting/app/controllers/recurring_meetings_controller.rb b/modules/meeting/app/controllers/recurring_meetings_controller.rb index d6ba65c015a0..b72c21ea3ae5 100644 --- a/modules/meeting/app/controllers/recurring_meetings_controller.rb +++ b/modules/meeting/app/controllers/recurring_meetings_controller.rb @@ -53,12 +53,10 @@ def show # rubocop:disable Metrics/AbcSize end def init - call = ::Meetings::CopyService - .new(user: current_user, model: @recurring_meeting.template) - .call(attributes: init_params, - copy_agenda: true, - copy_attachments: true, - send_notifications: false) + call = ::RecurringMeetings::InitOccurrenceService + .new(user: current_user, recurring_meeting: @recurring_meeting) + .call(date: params[:date]) + if call.success? redirect_to project_meeting_path(call.result.project, call.result), status: :see_other else @@ -144,34 +142,24 @@ def destroy private - def init_params - { - start_time: DateTime.parse(params[:start_time]), - recurring_meeting: @recurring_meeting - } - end - def upcoming_meetings meetings = @recurring_meeting - .instances(upcoming: true) - .index_by(&:start_date) + .scheduled_instances(upcoming: true) + .index_by(&:date) merged = @recurring_meeting .scheduled_occurrences(limit: 5) .map do |occurrence| date = occurrence.to_date - meetings.delete(date.to_s) || skeleton_meeting(date) + meetings.delete(date) || skeleton_meeting(date) end # Ensure we keep any remaining future meetings that exceed the limit - merged + meetings.values.sort_by(&:start_date) + merged + meetings.values.sort_by(&:date) end def skeleton_meeting(date) - start_time = @recurring_meeting.start_time.change(year: date.year, month: date.month, day: date.day) - RecurringMeetings::Skeleton.new(start_time:, - state: "scheduled", - recurring_meeting: @recurring_meeting) + ScheduledMeeting.new(date:, recurring_meeting: @recurring_meeting) end def find_optional_project diff --git a/modules/meeting/app/models/recurring_meeting.rb b/modules/meeting/app/models/recurring_meeting.rb index a73b589d6c42..e3600ce874f2 100644 --- a/modules/meeting/app/models/recurring_meeting.rb +++ b/modules/meeting/app/models/recurring_meeting.rb @@ -27,6 +27,8 @@ class RecurringMeeting < ApplicationRecord has_many :meetings, inverse_of: :recurring_meeting + has_many :scheduled_meetings, inverse_of: :recurring_meeting + has_one :template, -> { where(template: true) }, class_name: "Meeting" @@ -102,13 +104,14 @@ def remaining_occurrences end end - def instances(upcoming: true) - direction = upcoming ? :upcoming : :past + def scheduled_instances(upcoming: true) + filter_scope = upcoming ? :upcoming : :past + direction = upcoming ? :asc : :desc - meetings - .not_templated - .public_send(direction) - .order(start_time: :asc) + scheduled_meetings + .includes(:meeting) + .public_send(filter_scope) + .order(date: direction) end private diff --git a/modules/meeting/app/models/recurring_meetings/skeleton.rb b/modules/meeting/app/models/scheduled_meeting.rb similarity index 83% rename from modules/meeting/app/models/recurring_meetings/skeleton.rb rename to modules/meeting/app/models/scheduled_meeting.rb index 44598b25492f..d06b95a39360 100644 --- a/modules/meeting/app/models/recurring_meetings/skeleton.rb +++ b/modules/meeting/app/models/scheduled_meeting.rb @@ -26,6 +26,12 @@ # See COPYRIGHT and LICENSE files for more details. #++ -module RecurringMeetings - Skeleton = Data.define(:start_time, :recurring_meeting, :state) +class ScheduledMeeting < ApplicationRecord + belongs_to :meeting + belongs_to :recurring_meeting + + scope :upcoming, -> { where(date: Time.zone.today..) } + scope :past, -> { where(date: ...Time.zone.today) } + + validates_presence_of :date end diff --git a/modules/meeting/app/services/recurring_meetings/init_occurrence_service.rb b/modules/meeting/app/services/recurring_meetings/init_occurrence_service.rb new file mode 100644 index 000000000000..bdf7f6ab4b66 --- /dev/null +++ b/modules/meeting/app/services/recurring_meetings/init_occurrence_service.rb @@ -0,0 +1,82 @@ +#-- 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 RecurringMeetings + class InitOccurrenceService < ::BaseServices::BaseCallable + include ::Shared::ServiceContext + + attr_reader :user, :recurring_meeting + + def initialize(user:, recurring_meeting:) + super() + @user = user + @recurring_meeting = recurring_meeting + end + + protected + + def perform(date:) + in_context(recurring_meeting, send_notifications: false) do + call = instantiate(date) + create_schedule(call) if call.success? + + call + end + end + + def instantiate(date) + ::Meetings::CopyService + .new(user:, model: recurring_meeting.template) + .call(attributes: instantiate_params(date), + copy_agenda: true, + copy_attachments: true, + send_notifications: false) + end + + def instantiate_params(start_date) + { + start_date:, + recurring_meeting: + } + end + + def create_schedule(call) + meeting = call.result + + schedule = ScheduledMeeting.new( + meeting: meeting, + recurring_meeting: recurring_meeting, + date: meeting.parsed_start_date + ) + + unless schedule.save + call.merge!(ServiceResult.failure(errors: schedule.errors)) + end + end + end +end diff --git a/modules/meeting/db/migrate/20241128190428_create_scheduled_meetings.rb b/modules/meeting/db/migrate/20241128190428_create_scheduled_meetings.rb new file mode 100644 index 000000000000..239dae2854a5 --- /dev/null +++ b/modules/meeting/db/migrate/20241128190428_create_scheduled_meetings.rb @@ -0,0 +1,16 @@ +class CreateScheduledMeetings < ActiveRecord::Migration[7.1] + def change + create_table :scheduled_meetings do |t| + t.belongs_to :recurring_meeting, null: false, foreign_key: true, index: true + t.belongs_to :meeting, null: true, foreign_key: true, index: true + t.date :date, null: false + t.boolean :cancelled, default: false, null: false + + t.timestamps + end + + add_index :scheduled_meetings, + %i[recurring_meeting_id date], + unique: true + end +end From 61422d7eacd04203197b883157efc7cadc0a5afb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Fri, 29 Nov 2024 09:33:28 +0100 Subject: [PATCH 075/129] Replace destroy/restore with actual destroy --- .../meeting/app/controllers/meetings_controller.rb | 14 +------------- modules/meeting/config/routes.rb | 1 - modules/meeting/lib/open_project/meeting/engine.rb | 2 +- 3 files changed, 2 insertions(+), 15 deletions(-) diff --git a/modules/meeting/app/controllers/meetings_controller.rb b/modules/meeting/app/controllers/meetings_controller.rb index 4d6da1c7df27..73fa782d3f71 100644 --- a/modules/meeting/app/controllers/meetings_controller.rb +++ b/modules/meeting/app/controllers/meetings_controller.rb @@ -179,13 +179,7 @@ def copy end def destroy - type = @meeting.recurring_meeting_id - - if type.nil? - @meeting.destroy - else - @meeting.update_attribute :state, :cancelled - end + @meeting.destroy flash[:notice] = I18n.t(:notice_successful_delete) @@ -196,12 +190,6 @@ def destroy end end - def restore - @meeting.update_attribute :state, :open - - redirect_to controller: "recurring_meetings", action: "show", id: @meeting.recurring_meeting_id, status: :see_other - end - def edit respond_to do |format| format.turbo_stream do diff --git a/modules/meeting/config/routes.rb b/modules/meeting/config/routes.rb index 1bb29a50bff7..0b042d1f0c41 100644 --- a/modules/meeting/config/routes.rb +++ b/modules/meeting/config/routes.rb @@ -77,7 +77,6 @@ put :change_state post :notify get :history - get :restore end resources :agenda_items, controller: "meeting_agenda_items" do collection do diff --git a/modules/meeting/lib/open_project/meeting/engine.rb b/modules/meeting/lib/open_project/meeting/engine.rb index 5f5111d0650b..10ec308ba895 100644 --- a/modules/meeting/lib/open_project/meeting/engine.rb +++ b/modules/meeting/lib/open_project/meeting/engine.rb @@ -49,7 +49,7 @@ class Engine < ::Rails::Engine permissible_on: :project permission :create_meetings, { - meetings: %i[new create copy new_dialog restore], + meetings: %i[new create copy new_dialog], recurring_meetings: %i[new create copy init], "meetings/menus": %i[show] }, From 015c41355b9ce01e2b3952f7692a1b201dfde6bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Fri, 29 Nov 2024 09:44:29 +0100 Subject: [PATCH 076/129] Uniqueness --- modules/meeting/app/models/meeting.rb | 2 + .../meeting/app/models/recurring_meeting.rb | 40 ++++++++++-------- .../meeting/app/models/scheduled_meeting.rb | 1 + .../app/services/meetings/delete_service.rb | 41 +++++++++++++++++++ ...0241128190428_create_scheduled_meetings.rb | 10 ++++- 5 files changed, 74 insertions(+), 20 deletions(-) create mode 100644 modules/meeting/app/services/meetings/delete_service.rb diff --git a/modules/meeting/app/models/meeting.rb b/modules/meeting/app/models/meeting.rb index c8ab0250f578..8d3bf6814a05 100644 --- a/modules/meeting/app/models/meeting.rb +++ b/modules/meeting/app/models/meeting.rb @@ -34,7 +34,9 @@ class Meeting < ApplicationRecord belongs_to :project belongs_to :author, class_name: "User" + belongs_to :recurring_meeting, optional: true + has_one :scheduled_meeting, inverse_of: :meeting has_one :agenda, dependent: :destroy, class_name: "MeetingAgenda" has_one :minutes, dependent: :destroy, class_name: "MeetingMinutes" diff --git a/modules/meeting/app/models/recurring_meeting.rb b/modules/meeting/app/models/recurring_meeting.rb index e3600ce874f2..cadde50d381c 100644 --- a/modules/meeting/app/models/recurring_meeting.rb +++ b/modules/meeting/app/models/recurring_meeting.rb @@ -25,9 +25,13 @@ class RecurringMeeting < ApplicationRecord iterations: 1 }.freeze, _prefix: true, _default: "specific_date" - has_many :meetings, inverse_of: :recurring_meeting + has_many :meetings, + inverse_of: :recurring_meeting, + dependent: :destroy - has_many :scheduled_meetings, inverse_of: :recurring_meeting + has_many :scheduled_meetings, + inverse_of: :recurring_meeting, + dependent: :delete_all has_one :template, -> { where(template: true) }, class_name: "Meeting" @@ -72,24 +76,24 @@ def schedule def schedule_in_words # rubocop:disable Metrics/AbcSize base = case frequency - when "daily" - interval == 1 ? human_frequency : I18n.t("recurring_meeting.in_words.daily_interval", interval: interval.ordinalize) - when "working_days" - if interval == 1 - I18n.t("recurring_meeting.in_words.working_days") - else - I18n.t("recurring_meeting.in_words.working_days_interval", interval: interval.ordinalize) - end - when "weekly" - if interval == 1 - I18n.t("recurring_meeting.in_words.weekly", weekday:) - else - I18n.t("recurring_meeting.in_words.weekly_interval", interval: interval.ordinalize, weekday:) - end - end + when "daily" + interval == 1 ? human_frequency : I18n.t("recurring_meeting.in_words.daily_interval", interval: interval.ordinalize) + when "working_days" + if interval == 1 + I18n.t("recurring_meeting.in_words.working_days") + else + I18n.t("recurring_meeting.in_words.working_days_interval", interval: interval.ordinalize) + end + when "weekly" + if interval == 1 + I18n.t("recurring_meeting.in_words.weekly", weekday:) + else + I18n.t("recurring_meeting.in_words.weekly_interval", interval: interval.ordinalize, weekday:) + end + end I18n.t("recurring_meeting.in_words.full", base:, time: format_time(start_time, include_date: false), - end_date: format_date(end_date)) + end_date: format_date(end_date)) end def scheduled_occurrences(limit:) diff --git a/modules/meeting/app/models/scheduled_meeting.rb b/modules/meeting/app/models/scheduled_meeting.rb index d06b95a39360..60a404bef86a 100644 --- a/modules/meeting/app/models/scheduled_meeting.rb +++ b/modules/meeting/app/models/scheduled_meeting.rb @@ -33,5 +33,6 @@ class ScheduledMeeting < ApplicationRecord scope :upcoming, -> { where(date: Time.zone.today..) } scope :past, -> { where(date: ...Time.zone.today) } + validates_uniqueness_of :meeting validates_presence_of :date end diff --git a/modules/meeting/app/services/meetings/delete_service.rb b/modules/meeting/app/services/meetings/delete_service.rb new file mode 100644 index 000000000000..ea67bb1c2145 --- /dev/null +++ b/modules/meeting/app/services/meetings/delete_service.rb @@ -0,0 +1,41 @@ +#-- 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 MeetingSections + class DeleteService < ::BaseServices::Delete + delete_permission :delete_meetings + + protected + + def after_validate(_, call) + if model.scheduled_meeting + + end + end + end +end diff --git a/modules/meeting/db/migrate/20241128190428_create_scheduled_meetings.rb b/modules/meeting/db/migrate/20241128190428_create_scheduled_meetings.rb index 239dae2854a5..5cb3c35a8a0a 100644 --- a/modules/meeting/db/migrate/20241128190428_create_scheduled_meetings.rb +++ b/modules/meeting/db/migrate/20241128190428_create_scheduled_meetings.rb @@ -1,8 +1,14 @@ class CreateScheduledMeetings < ActiveRecord::Migration[7.1] def change create_table :scheduled_meetings do |t| - t.belongs_to :recurring_meeting, null: false, foreign_key: true, index: true - t.belongs_to :meeting, null: true, foreign_key: true, index: true + t.belongs_to :recurring_meeting, + null: false, + foreign_key: { index: true, on_delete: :cascade } + + t.belongs_to :meeting, + null: true, + foreign_key: { index: true, unique: true, on_delete: :nullify } + t.date :date, null: false t.boolean :cancelled, default: false, null: false From fd39c4d1e60283690db201e785d3a44a738cf74f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Fri, 29 Nov 2024 09:54:20 +0100 Subject: [PATCH 077/129] Delete with schedule --- .../recurring_meetings/row_component.rb | 8 ++--- .../app/contracts/meetings/delete_contract.rb | 33 +++++++++++++++++++ .../app/controllers/meetings_controller.rb | 14 +++++--- .../app/services/meetings/delete_service.rb | 9 +++-- .../init_occurrence_service.rb | 5 ++- 5 files changed, 52 insertions(+), 17 deletions(-) create mode 100644 modules/meeting/app/contracts/meetings/delete_contract.rb diff --git a/modules/meeting/app/components/recurring_meetings/row_component.rb b/modules/meeting/app/components/recurring_meetings/row_component.rb index e30d7d3996eb..4a00386b0974 100644 --- a/modules/meeting/app/components/recurring_meetings/row_component.rb +++ b/modules/meeting/app/components/recurring_meetings/row_component.rb @@ -83,11 +83,11 @@ def last_edited def state if model.cancelled? - :cancelled + "cancelled" elsif instantiated? meeting.state else - :scheduled + "scheduled" end end @@ -118,11 +118,11 @@ def create scheme: :default, size: :medium, tag: :a, - data: { "turbo-method": "post"}, + data: { "turbo-method": "post" }, href: init_recurring_meeting_path(model.recurring_meeting.id, date: model.date) ) ) do |_c| - I18n.t("label_recurring_meeting_create") + I18n.t(:label_recurring_meeting_create) end end diff --git a/modules/meeting/app/contracts/meetings/delete_contract.rb b/modules/meeting/app/contracts/meetings/delete_contract.rb new file mode 100644 index 000000000000..edf019c5e149 --- /dev/null +++ b/modules/meeting/app/contracts/meetings/delete_contract.rb @@ -0,0 +1,33 @@ +#-- 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 Meetings + class DeleteContract < ::DeleteContract + delete_permission :delete_meetings + end +end diff --git a/modules/meeting/app/controllers/meetings_controller.rb b/modules/meeting/app/controllers/meetings_controller.rb index 73fa782d3f71..ee41fc725e87 100644 --- a/modules/meeting/app/controllers/meetings_controller.rb +++ b/modules/meeting/app/controllers/meetings_controller.rb @@ -179,14 +179,18 @@ def copy end def destroy - @meeting.destroy + recurring = @meeting.recurring_meeting - flash[:notice] = I18n.t(:notice_successful_delete) + Meetings::DeleteService + .new(model: @meeting, user: User.current) + .call + .on_success { flash[:notice] = I18n.t(:notice_successful_delete) } + .on_failure { |call| flash[:error] = call.message } - if type.nil? - redirect_to action: "index", project_id: @project + if recurring + redirect_to polymorphic_path([@project, recurring], status: :see_other) else - redirect_to controller: "recurring_meetings", action: "show", id: type, status: :see_other + redirect_to polymorphic_path([@project, :meetings], status: :see_other) end end diff --git a/modules/meeting/app/services/meetings/delete_service.rb b/modules/meeting/app/services/meetings/delete_service.rb index ea67bb1c2145..55642242f49a 100644 --- a/modules/meeting/app/services/meetings/delete_service.rb +++ b/modules/meeting/app/services/meetings/delete_service.rb @@ -26,16 +26,15 @@ # See COPYRIGHT and LICENSE files for more details. #++ -module MeetingSections +module Meetings class DeleteService < ::BaseServices::Delete - delete_permission :delete_meetings - protected def after_validate(_, call) - if model.scheduled_meeting + schedule = model.scheduled_meeting + schedule.update_column(:cancelled, true) if schedule.present? - end + call end end end diff --git a/modules/meeting/app/services/recurring_meetings/init_occurrence_service.rb b/modules/meeting/app/services/recurring_meetings/init_occurrence_service.rb index bdf7f6ab4b66..aeb7de13c67b 100644 --- a/modules/meeting/app/services/recurring_meetings/init_occurrence_service.rb +++ b/modules/meeting/app/services/recurring_meetings/init_occurrence_service.rb @@ -68,13 +68,12 @@ def instantiate_params(start_date) def create_schedule(call) meeting = call.result - schedule = ScheduledMeeting.new( - meeting: meeting, + schedule = ScheduledMeeting.find_or_initialize_by( recurring_meeting: recurring_meeting, date: meeting.parsed_start_date ) - unless schedule.save + unless schedule.update(meeting:, cancelled: false) call.merge!(ServiceResult.failure(errors: schedule.errors)) end end From 5b174173bbcd428e11437f80a86bb553632e5097 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Fri, 29 Nov 2024 10:14:11 +0100 Subject: [PATCH 078/129] Switch to start_time --- .../recurring_meetings/row_component.rb | 19 ++++--------------- .../recurring_meetings_controller.rb | 15 +++++++-------- .../meeting/app/models/recurring_meeting.rb | 2 +- .../meeting/app/models/scheduled_meeting.rb | 6 +++--- .../init_occurrence_service.rb | 14 +++++++------- ...0241128190428_create_scheduled_meetings.rb | 4 ++-- 6 files changed, 24 insertions(+), 36 deletions(-) diff --git a/modules/meeting/app/components/recurring_meetings/row_component.rb b/modules/meeting/app/components/recurring_meetings/row_component.rb index 4a00386b0974..ff0d4b11f6ac 100644 --- a/modules/meeting/app/components/recurring_meetings/row_component.rb +++ b/modules/meeting/app/components/recurring_meetings/row_component.rb @@ -57,22 +57,11 @@ def start_time end def start_time_title - helpers.format_time(start_time_value, include_date: true) + helpers.format_time(model.start_time, include_date: true) end def relative_time - render(OpPrimer::RelativeTimeComponent.new(datetime: start_time_value, prefix: I18n.t(:label_on))) - end - - def start_time_value - if instantiated? - meeting.start_time - else - recurring_meeting - .template - .start_time - .change(year: model.date.year, month: model.date.month, day: model.date.day) - end + render(OpPrimer::RelativeTimeComponent.new(datetime: model.start_time, prefix: I18n.t(:label_on))) end def last_edited @@ -119,7 +108,7 @@ def create size: :medium, tag: :a, data: { "turbo-method": "post" }, - href: init_recurring_meeting_path(model.recurring_meeting.id, date: model.date) + href: init_recurring_meeting_path(model.recurring_meeting.id, start_time: model.start_time.iso8601) ) ) do |_c| I18n.t(:label_recurring_meeting_create) @@ -178,7 +167,7 @@ def delete_action(menu) def restore_action(menu) menu.with_item(label: I18n.t(:label_recurring_meeting_restore), - href: init_recurring_meeting_path(recurring_meeting, date: model.date)) do |item| + href: init_recurring_meeting_path(recurring_meeting, start_time: model.start_time.iso8601)) do |item| item.with_leading_visual_icon(icon: :history) end end diff --git a/modules/meeting/app/controllers/recurring_meetings_controller.rb b/modules/meeting/app/controllers/recurring_meetings_controller.rb index b72c21ea3ae5..9390b444c2b1 100644 --- a/modules/meeting/app/controllers/recurring_meetings_controller.rb +++ b/modules/meeting/app/controllers/recurring_meetings_controller.rb @@ -55,7 +55,7 @@ def show # rubocop:disable Metrics/AbcSize def init call = ::RecurringMeetings::InitOccurrenceService .new(user: current_user, recurring_meeting: @recurring_meeting) - .call(date: params[:date]) + .call(start_time: DateTime.iso8601(params[:start_time])) if call.success? redirect_to project_meeting_path(call.result.project, call.result), status: :see_other @@ -145,21 +145,20 @@ def destroy def upcoming_meetings meetings = @recurring_meeting .scheduled_instances(upcoming: true) - .index_by(&:date) + .index_by(&:start_time) merged = @recurring_meeting .scheduled_occurrences(limit: 5) - .map do |occurrence| - date = occurrence.to_date - meetings.delete(date) || skeleton_meeting(date) + .map do |start_time| + meetings.delete(start_time) || skeleton_meeting(start_time) end # Ensure we keep any remaining future meetings that exceed the limit - merged + meetings.values.sort_by(&:date) + merged + meetings.values.sort_by(&:start_time) end - def skeleton_meeting(date) - ScheduledMeeting.new(date:, recurring_meeting: @recurring_meeting) + def skeleton_meeting(start_time) + ScheduledMeeting.new(start_time:, recurring_meeting: @recurring_meeting) end def find_optional_project diff --git a/modules/meeting/app/models/recurring_meeting.rb b/modules/meeting/app/models/recurring_meeting.rb index cadde50d381c..f030a545682c 100644 --- a/modules/meeting/app/models/recurring_meeting.rb +++ b/modules/meeting/app/models/recurring_meeting.rb @@ -115,7 +115,7 @@ def scheduled_instances(upcoming: true) scheduled_meetings .includes(:meeting) .public_send(filter_scope) - .order(date: direction) + .order(start_time: direction) end private diff --git a/modules/meeting/app/models/scheduled_meeting.rb b/modules/meeting/app/models/scheduled_meeting.rb index 60a404bef86a..32d4d1ab6a65 100644 --- a/modules/meeting/app/models/scheduled_meeting.rb +++ b/modules/meeting/app/models/scheduled_meeting.rb @@ -30,9 +30,9 @@ class ScheduledMeeting < ApplicationRecord belongs_to :meeting belongs_to :recurring_meeting - scope :upcoming, -> { where(date: Time.zone.today..) } - scope :past, -> { where(date: ...Time.zone.today) } + scope :upcoming, -> { where(start_time: Time.current..) } + scope :past, -> { where(start_time: ...Time.current) } validates_uniqueness_of :meeting - validates_presence_of :date + validates_presence_of :start_time end diff --git a/modules/meeting/app/services/recurring_meetings/init_occurrence_service.rb b/modules/meeting/app/services/recurring_meetings/init_occurrence_service.rb index aeb7de13c67b..098533a52948 100644 --- a/modules/meeting/app/services/recurring_meetings/init_occurrence_service.rb +++ b/modules/meeting/app/services/recurring_meetings/init_occurrence_service.rb @@ -40,27 +40,27 @@ def initialize(user:, recurring_meeting:) protected - def perform(date:) + def perform(start_time:) in_context(recurring_meeting, send_notifications: false) do - call = instantiate(date) + call = instantiate(start_time) create_schedule(call) if call.success? call end end - def instantiate(date) + def instantiate(start_time) ::Meetings::CopyService .new(user:, model: recurring_meeting.template) - .call(attributes: instantiate_params(date), + .call(attributes: instantiate_params(start_time), copy_agenda: true, copy_attachments: true, send_notifications: false) end - def instantiate_params(start_date) + def instantiate_params(start_time) { - start_date:, + start_time:, recurring_meeting: } end @@ -70,7 +70,7 @@ def create_schedule(call) schedule = ScheduledMeeting.find_or_initialize_by( recurring_meeting: recurring_meeting, - date: meeting.parsed_start_date + start_time: meeting.start_time ) unless schedule.update(meeting:, cancelled: false) diff --git a/modules/meeting/db/migrate/20241128190428_create_scheduled_meetings.rb b/modules/meeting/db/migrate/20241128190428_create_scheduled_meetings.rb index 5cb3c35a8a0a..b9e9368521ca 100644 --- a/modules/meeting/db/migrate/20241128190428_create_scheduled_meetings.rb +++ b/modules/meeting/db/migrate/20241128190428_create_scheduled_meetings.rb @@ -9,14 +9,14 @@ def change null: true, foreign_key: { index: true, unique: true, on_delete: :nullify } - t.date :date, null: false + t.datetime :start_time, null: false t.boolean :cancelled, default: false, null: false t.timestamps end add_index :scheduled_meetings, - %i[recurring_meeting_id date], + %i[recurring_meeting_id start_time], unique: true end end From 17f8a8b3232e0d288c7daac8948c83d59e3f2318 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Fri, 29 Nov 2024 10:36:53 +0100 Subject: [PATCH 079/129] Use start_time in schedule Otherwise, we get time mismatches --- .../recurring_meetings/row_component.rb | 23 ++++++++++++------- .../meeting/app/models/recurring_meeting.rb | 2 +- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/modules/meeting/app/components/recurring_meetings/row_component.rb b/modules/meeting/app/components/recurring_meetings/row_component.rb index ff0d4b11f6ac..4849b34dd879 100644 --- a/modules/meeting/app/components/recurring_meetings/row_component.rb +++ b/modules/meeting/app/components/recurring_meetings/row_component.rb @@ -155,19 +155,26 @@ def ical_action(menu) end def delete_action(menu) - menu.with_item(label: I18n.t(:label_recurring_meeting_cancel), - scheme: :danger, - href: meeting_path(meeting), - form_arguments: { - method: :delete, data: { confirm: I18n.t("text_are_you_sure"), turbo: false } - }) do |item| + menu.with_item( + label: I18n.t(:label_recurring_meeting_cancel), + scheme: :danger, + href: meeting_path(meeting), + form_arguments: { + method: :delete, data: { confirm: I18n.t("text_are_you_sure"), turbo: false } + } + ) do |item| item.with_leading_visual_icon(icon: :trash) end end def restore_action(menu) - menu.with_item(label: I18n.t(:label_recurring_meeting_restore), - href: init_recurring_meeting_path(recurring_meeting, start_time: model.start_time.iso8601)) do |item| + menu.with_item( + label: I18n.t(:label_recurring_meeting_restore), + href: init_recurring_meeting_path(recurring_meeting, start_time: model.start_time.iso8601), + form_arguments: { + method: :post + } + ) do |item| item.with_leading_visual_icon(icon: :history) end end diff --git a/modules/meeting/app/models/recurring_meeting.rb b/modules/meeting/app/models/recurring_meeting.rb index f030a545682c..dad1c47d3b9c 100644 --- a/modules/meeting/app/models/recurring_meeting.rb +++ b/modules/meeting/app/models/recurring_meeting.rb @@ -68,7 +68,7 @@ def date end def schedule - @schedule ||= IceCube::Schedule.new(start_date.to_time(:utc), end_time: end_date).tap do |s| + @schedule ||= IceCube::Schedule.new(start_time, end_time: end_date).tap do |s| s.add_recurrence_rule count_rule(frequency_rule) exclude_non_working_days(s) if frequency_working_days? end From 91a86726cf974ed9fa76be485e7e519d98cc3232 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Fri, 29 Nov 2024 10:47:28 +0100 Subject: [PATCH 080/129] Add helper for time formatting to the current user --- lib_static/redmine/i18n.rb | 23 +++++++++++++++---- .../recurring_meetings/row_component.rb | 8 +++++-- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/lib_static/redmine/i18n.rb b/lib_static/redmine/i18n.rb index 243027c58fbd..3c7ec567f7fd 100644 --- a/lib_static/redmine/i18n.rb +++ b/lib_static/redmine/i18n.rb @@ -125,8 +125,7 @@ def link_regex def format_time_as_date(time, format: nil) return nil unless time - zone = User.current.time_zone - local_date = time.in_time_zone(zone).to_date + local_date = in_current_user_zone(time).to_date if format local_date.strftime(format) @@ -148,13 +147,27 @@ def format_time_as_date(time, format: nil) def format_time(time, include_date: true, format: Setting.time_format) return nil unless time - zone = User.current.time_zone - local = time.in_time_zone(zone) + local = in_current_user_zone(time) (include_date ? "#{format_date(local)} " : "") + (format.blank? ? ::I18n.l(local, format: :time) : local.strftime(format)) end + ## + # Formats the given time as a time string according to the +User.current+ time zone + # @param time [Time] The time to format. + # @return [Time] The time with the user's time zone applied. + def in_current_user_zone(time) + time.in_time_zone(current_user_time_zone) + end + + ## + # Returns the time zone of +User.current+, cached for the duration of the request + # @return [ActiveSupport::TimeZone] The time zone of the current user + def current_user_time_zone + RequestStore.fetch("current_user_time_zone") { User.current.time_zone } + end + # Returns the offset to UTC (with utc prepended) currently active # in the current users time zone. DST is factored in so the offset can # shift over the course of the year @@ -162,7 +175,7 @@ def formatted_time_zone_offset # Doing User.current.time_zone and format that will not take heed of DST as it has no notion # of a current time. # https://github.com/rails/rails/issues/7297 - "UTC#{User.current.time_zone.now.formatted_offset}" + "UTC#{current_user_time_zone.now.formatted_offset}" end def day_name(day) diff --git a/modules/meeting/app/components/recurring_meetings/row_component.rb b/modules/meeting/app/components/recurring_meetings/row_component.rb index 4849b34dd879..778db4091220 100644 --- a/modules/meeting/app/components/recurring_meetings/row_component.rb +++ b/modules/meeting/app/components/recurring_meetings/row_component.rb @@ -56,12 +56,16 @@ def start_time end end + def user_start_time_value + @user_start_time_value ||= helpers.in_current_user_zone(model.start_time) + end + def start_time_title - helpers.format_time(model.start_time, include_date: true) + helpers.format_time(user_start_time_value, include_date: true) end def relative_time - render(OpPrimer::RelativeTimeComponent.new(datetime: model.start_time, prefix: I18n.t(:label_on))) + render(OpPrimer::RelativeTimeComponent.new(datetime: user_start_time_value, prefix: I18n.t(:label_on))) end def last_edited From 8ff6d78053d5801bbd7c02c6a036f16a0d382625 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Fri, 29 Nov 2024 11:44:14 +0100 Subject: [PATCH 081/129] fixup! Delete with schedule --- .../meeting/app/controllers/recurring_meetings_controller.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/meeting/app/controllers/recurring_meetings_controller.rb b/modules/meeting/app/controllers/recurring_meetings_controller.rb index 9390b444c2b1..cc1e315298d8 100644 --- a/modules/meeting/app/controllers/recurring_meetings_controller.rb +++ b/modules/meeting/app/controllers/recurring_meetings_controller.rb @@ -150,14 +150,14 @@ def upcoming_meetings merged = @recurring_meeting .scheduled_occurrences(limit: 5) .map do |start_time| - meetings.delete(start_time) || skeleton_meeting(start_time) + meetings.delete(start_time) || scheduled_meeting(start_time) end # Ensure we keep any remaining future meetings that exceed the limit merged + meetings.values.sort_by(&:start_time) end - def skeleton_meeting(start_time) + def scheduled_meeting(start_time) ScheduledMeeting.new(start_time:, recurring_meeting: @recurring_meeting) end From 31e0e46417ce84876239c928e8058673e878429b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Fri, 29 Nov 2024 14:44:16 +0100 Subject: [PATCH 082/129] Move update_start_time to concern --- modules/meeting/app/models/meeting/virtual_start_time.rb | 1 + modules/meeting/app/models/recurring_meeting.rb | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/modules/meeting/app/models/meeting/virtual_start_time.rb b/modules/meeting/app/models/meeting/virtual_start_time.rb index cebfab7fde4a..1dbb0ca1efe5 100644 --- a/modules/meeting/app/models/meeting/virtual_start_time.rb +++ b/modules/meeting/app/models/meeting/virtual_start_time.rb @@ -43,6 +43,7 @@ module Meeting::VirtualStartTime validate :validate_date_and_time after_initialize :set_initial_values + before_save :update_start_time! end ## diff --git a/modules/meeting/app/models/recurring_meeting.rb b/modules/meeting/app/models/recurring_meeting.rb index dad1c47d3b9c..9f0732124547 100644 --- a/modules/meeting/app/models/recurring_meeting.rb +++ b/modules/meeting/app/models/recurring_meeting.rb @@ -5,7 +5,7 @@ class RecurringMeeting < ApplicationRecord belongs_to :project belongs_to :author, class_name: "User" - before_save :update_start_time! + validates_presence_of :start_time, :title, :frequency, :end_after validates_presence_of :end_date, if: -> { end_after_specific_date? } From 41f82e295d2de2a5e23439b537700fa07888b7d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Fri, 29 Nov 2024 14:58:02 +0100 Subject: [PATCH 083/129] Delete cancelled meetings when changing schedule --- modules/meeting/app/models/recurring_meeting.rb | 8 ++++++-- modules/meeting/app/models/scheduled_meeting.rb | 2 ++ .../app/services/recurring_meetings/update_service.rb | 11 +++++++++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/modules/meeting/app/models/recurring_meeting.rb b/modules/meeting/app/models/recurring_meeting.rb index 9f0732124547..5b7ecad3978d 100644 --- a/modules/meeting/app/models/recurring_meeting.rb +++ b/modules/meeting/app/models/recurring_meeting.rb @@ -5,8 +5,6 @@ class RecurringMeeting < ApplicationRecord belongs_to :project belongs_to :author, class_name: "User" - - validates_presence_of :start_time, :title, :frequency, :end_after validates_presence_of :end_date, if: -> { end_after_specific_date? } validates_numericality_of :iterations, if: -> { end_after_iterations? } @@ -14,6 +12,8 @@ class RecurringMeeting < ApplicationRecord validate :end_date_constraints, if: -> { end_after_specific_date? } + after_save :unset_schedule + enum frequency: { daily: 0, working_days: 1, @@ -120,6 +120,10 @@ def scheduled_instances(upcoming: true) private + def unset_schedule + @schedule = nil + end + def end_date_constraints return if end_date.nil? diff --git a/modules/meeting/app/models/scheduled_meeting.rb b/modules/meeting/app/models/scheduled_meeting.rb index 32d4d1ab6a65..7e83b5b27ab4 100644 --- a/modules/meeting/app/models/scheduled_meeting.rb +++ b/modules/meeting/app/models/scheduled_meeting.rb @@ -33,6 +33,8 @@ class ScheduledMeeting < ApplicationRecord scope :upcoming, -> { where(start_time: Time.current..) } scope :past, -> { where(start_time: ...Time.current) } + scope :cancelled, -> { where(cancelled: true) } + validates_uniqueness_of :meeting validates_presence_of :start_time end diff --git a/modules/meeting/app/services/recurring_meetings/update_service.rb b/modules/meeting/app/services/recurring_meetings/update_service.rb index aa746dc67217..b7ae23888e73 100644 --- a/modules/meeting/app/services/recurring_meetings/update_service.rb +++ b/modules/meeting/app/services/recurring_meetings/update_service.rb @@ -35,6 +35,7 @@ class UpdateService < ::BaseServices::Update def after_perform(call) return call unless call.success? + cleanup_cancelled_schedules(call.result) update_template(call) end @@ -48,5 +49,15 @@ def update_template(call) call end + + def cleanup_cancelled_schedules(recurring_meeting) + ScheduledMeeting + .where(recurring_meeting:) + .cancelled + .find_each do |scheduled| + occurring = recurring_meeting.schedule.occurs_at?(scheduled.start_time) + scheduled.delete unless occurring + end + end end end From 01e062fd728a491a276172baae346f6f2a8ca238 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Fri, 29 Nov 2024 15:14:25 +0100 Subject: [PATCH 084/129] Allow passing dates directly --- .../app/models/meeting/virtual_start_time.rb | 2 + .../factories/scheduled_meeting_factory.rb | 54 ++++++ .../update_service_integration_spec.rb | 173 ++++++++++++++++++ 3 files changed, 229 insertions(+) create mode 100644 modules/meeting/spec/factories/scheduled_meeting_factory.rb create mode 100644 modules/meeting/spec/services/recurring_meetings/update_service_integration_spec.rb diff --git a/modules/meeting/app/models/meeting/virtual_start_time.rb b/modules/meeting/app/models/meeting/virtual_start_time.rb index 1dbb0ca1efe5..68bbdf198e8b 100644 --- a/modules/meeting/app/models/meeting/virtual_start_time.rb +++ b/modules/meeting/app/models/meeting/virtual_start_time.rb @@ -120,6 +120,8 @@ def update_derived_fields # Enforce ISO 8601 date parsing for the given input string # This avoids weird parsing of dates due to malformed input. def parsed_start_date + return @start_date if @start_date.is_a?(Date) + Date.iso8601(@start_date) rescue ArgumentError nil diff --git a/modules/meeting/spec/factories/scheduled_meeting_factory.rb b/modules/meeting/spec/factories/scheduled_meeting_factory.rb new file mode 100644 index 000000000000..94ae8a616ec8 --- /dev/null +++ b/modules/meeting/spec/factories/scheduled_meeting_factory.rb @@ -0,0 +1,54 @@ +#-- 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. +#++ + +FactoryBot.define do + factory :recurring_meeting, class: "RecurringMeeting" do |m| + author factory: :user + project + start_time { Date.tomorrow + 10.hours } + end_date { 1.year.from_now } + duration { 1.0 } + frequency { "weekly" } + interval { 1 } + iterations { 10 } + end_after { "specific_date" } + + location { "https://some-url.com" } + m.sequence(:title) { |n| "Meeting series #{n}" } + + after(:create) do |recurring_meeting, evaluator| + recurring_meeting.project = evaluator.project if evaluator.project + recurring_meeting.template = create(:meeting, recurring_meeting:) + end + + after(:stub) do |recurring_meeting, evaluator| + recurring_meeting.project = evaluator.project if evaluator.project + recurring_meeting.template = build_stubbed(:meeting, recurring_meeting:) + end + end +end diff --git a/modules/meeting/spec/services/recurring_meetings/update_service_integration_spec.rb b/modules/meeting/spec/services/recurring_meetings/update_service_integration_spec.rb new file mode 100644 index 000000000000..b2a6affd53a6 --- /dev/null +++ b/modules/meeting/spec/services/recurring_meetings/update_service_integration_spec.rb @@ -0,0 +1,173 @@ +#-- 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 RecurringMeetings::UpdateService, "integration", type: :model do + shared_let(:project) { create(:project, enabled_module_names: %i[meetings]) } + shared_let(:user) do + create(:user, member_with_permissions: { project => %i(view_meetings create_meetings) }) + end + shared_let(:meeting) do + create(:recurring_meeting, + project:, + start_time: Time.zone.today + 10.hours, + interval: "daily", + end_after: "specific_date", + end_date: 1.month.from_now, + ) + end + + let(:instance) { described_class.new(model: meeting, user:) } + let(:attributes) { {} } + let(:params) { {} } + + let(:service_result) { instance.call(attributes:, **params) } + let(:copy) { service_result.result } + + it "copies the meeting as is" do + expect(service_result).to be_success + expect(copy.author).to eq(user) + expect(copy.start_time).to eq(meeting.start_time + 1.day) + end + + context "when the meeting is closed" do + it "reopens the meeting" do + meeting.update! state: "closed" + expect(service_result).to be_success + expect(copy.state).to eq("open") + end + end + + describe "with participants" do + let(:invited_user) { create(:user, member_with_permissions: { project => %i(view_meetings) }) } + let(:attending_user) { create(:user, member_with_permissions: { project => %i(view_meetings) }) } + let(:invalid_user) { create(:user) } + + it "copies applicable participants, resetting attended status" do + meeting.participants.create!(user: invited_user, invited: true, attended: false) + meeting.participants.create!(user: attending_user, invited: true, attended: true) + meeting.participants.create!(user: invalid_user, invited: true, attended: true) + + expect(service_result).to be_success + expect(copy.participants.count).to eq(2) + invited = copy.participants.find_by(user: invited_user) + attending = copy.participants.find_by(user: attending_user) + expect(invited).to be_invited + expect(invited).not_to be_attended + + expect(attending).to be_invited + expect(attending).not_to be_attended + + invalid = copy.participants.find_by(user: invalid_user) + expect(invalid).to be_nil + end + end + + describe "without participants" do + it "sets the author as invited" do + meeting.participants.destroy_all + + expect(service_result).to be_success + expect(copy.participants.count).to eq(1) + invited = copy.participants.find_by(user:) + expect(invited).to be_invited + end + end + + describe "when not saving" do + let(:params) { { save: false } } + + it "builds the meeting" do + expect(service_result).to be_success + expect(copy.author).to eq(user) + expect(copy.start_time).to eq(meeting.start_time + 1.day) + expect(copy).to be_new_record + end + end + + context "with agenda items" do + shared_let(:agenda_item) do + create(:meeting_agenda_item, + meeting:, + notes: "hello there") + end + + it "copies the agenda item" do + expect(copy.reload.agenda_items.length) + .to eq 1 + + expect(copy.agenda_items.first.notes) + .to eq agenda_item.notes + end + + context "when asking not to copy agenda" do + let(:params) { { copy_agenda: false } } + + it "does not copy agenda items" do + expect(copy.agenda_items).to be_empty + end + end + end + + context "with attachments" do + shared_let(:attachment) do + create(:attachment, + container: meeting) + end + shared_let(:agenda_item) do + create(:meeting_agenda_item, + meeting:, + notes: "![](/api/v3/attachments/#{attachment.id}/content") + end + + context "when asking to copy attachments" do + let(:params) { { copy_attachments: true } } + + it "copies the attachment" do + expect(copy.attachments.length) + .to eq 1 + + expect(copy.attachments.first.id) + .not_to eq attachment.id + + expect(copy.agenda_items.count).to eq(1) + expect(copy.agenda_items.first.notes).to include "attachments/#{copy.attachments.first.id}/content" + end + end + + context "when asking not to copy attachments" do + let(:params) { { copy_attachments: false } } + + it "does not copy attachments" do + expect(copy.attachments).to be_empty + expect(copy.agenda_items.first.notes).to include "attachments/#{attachment.id}/content" + end + end + end +end From baf964d8ec6b6b752b6955bc0184539751fe498a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Fri, 29 Nov 2024 15:21:39 +0100 Subject: [PATCH 085/129] Remove cancelled occurrences when changing schedules --- .../meeting/spec/factories/meeting_factory.rb | 11 ++ .../factories/recurring_meeting_factory.rb | 4 +- .../factories/scheduled_meeting_factory.rb | 26 ++-- .../update_service_integration_spec.rb | 145 ++++-------------- 4 files changed, 51 insertions(+), 135 deletions(-) diff --git a/modules/meeting/spec/factories/meeting_factory.rb b/modules/meeting/spec/factories/meeting_factory.rb index 415af6bccb2d..a12b89654137 100644 --- a/modules/meeting/spec/factories/meeting_factory.rb +++ b/modules/meeting/spec/factories/meeting_factory.rb @@ -42,5 +42,16 @@ factory :structured_meeting, class: "StructuredMeeting" do |m| m.sequence(:title) { |n| "Structured meeting #{n}" } end + + factory :structured_meeting_template, class: "StructuredMeeting" do |m| + m.sequence(:title) { |n| "Structured meeting template #{n}" } + template { true } + recurring_meeting + + after(:build) do |template, evaluator| + template.author = evaluator.recurring_meeting.author + template.project = evaluator.recurring_meeting.project + end + end end end diff --git a/modules/meeting/spec/factories/recurring_meeting_factory.rb b/modules/meeting/spec/factories/recurring_meeting_factory.rb index 94ae8a616ec8..8703d7173c2a 100644 --- a/modules/meeting/spec/factories/recurring_meeting_factory.rb +++ b/modules/meeting/spec/factories/recurring_meeting_factory.rb @@ -43,12 +43,12 @@ after(:create) do |recurring_meeting, evaluator| recurring_meeting.project = evaluator.project if evaluator.project - recurring_meeting.template = create(:meeting, recurring_meeting:) + recurring_meeting.template = create(:structured_meeting_template, recurring_meeting:) end after(:stub) do |recurring_meeting, evaluator| recurring_meeting.project = evaluator.project if evaluator.project - recurring_meeting.template = build_stubbed(:meeting, recurring_meeting:) + recurring_meeting.template = build_stubbed(:structured_meeting, recurring_meeting:) end end end diff --git a/modules/meeting/spec/factories/scheduled_meeting_factory.rb b/modules/meeting/spec/factories/scheduled_meeting_factory.rb index 94ae8a616ec8..acf021f015db 100644 --- a/modules/meeting/spec/factories/scheduled_meeting_factory.rb +++ b/modules/meeting/spec/factories/scheduled_meeting_factory.rb @@ -27,28 +27,20 @@ #++ FactoryBot.define do - factory :recurring_meeting, class: "RecurringMeeting" do |m| - author factory: :user - project + factory :scheduled_meeting, class: "ScheduledMeeting" do |m| + recurring_meeting + cancelled { false } + meeting { nil } start_time { Date.tomorrow + 10.hours } - end_date { 1.year.from_now } - duration { 1.0 } - frequency { "weekly" } - interval { 1 } - iterations { 10 } - end_after { "specific_date" } - location { "https://some-url.com" } - m.sequence(:title) { |n| "Meeting series #{n}" } + trait :scheduled - after(:create) do |recurring_meeting, evaluator| - recurring_meeting.project = evaluator.project if evaluator.project - recurring_meeting.template = create(:meeting, recurring_meeting:) + trait :cancelled do + cancelled { true } end - after(:stub) do |recurring_meeting, evaluator| - recurring_meeting.project = evaluator.project if evaluator.project - recurring_meeting.template = build_stubbed(:meeting, recurring_meeting:) + trait :persisted do + meeting factory: :structured_meeting end end end diff --git a/modules/meeting/spec/services/recurring_meetings/update_service_integration_spec.rb b/modules/meeting/spec/services/recurring_meetings/update_service_integration_spec.rb index b2a6affd53a6..c238d097a0e7 100644 --- a/modules/meeting/spec/services/recurring_meetings/update_service_integration_spec.rb +++ b/modules/meeting/spec/services/recurring_meetings/update_service_integration_spec.rb @@ -31,142 +31,55 @@ RSpec.describe RecurringMeetings::UpdateService, "integration", type: :model do shared_let(:project) { create(:project, enabled_module_names: %i[meetings]) } shared_let(:user) do - create(:user, member_with_permissions: { project => %i(view_meetings create_meetings) }) + create(:user, member_with_permissions: { project => %i(view_meetings edit_meetings) }) end - shared_let(:meeting) do + shared_let(:series) do create(:recurring_meeting, project:, start_time: Time.zone.today + 10.hours, - interval: "daily", + frequency: "daily", + interval: 1, end_after: "specific_date", - end_date: 1.month.from_now, - ) + end_date: 1.month.from_now) end - let(:instance) { described_class.new(model: meeting, user:) } - let(:attributes) { {} } + let(:instance) { described_class.new(model: series, user:) } let(:params) { {} } - let(:service_result) { instance.call(attributes:, **params) } - let(:copy) { service_result.result } + let(:service_result) { instance.call(**params) } + let(:updated_meeting) { service_result.result } - it "copies the meeting as is" do - expect(service_result).to be_success - expect(copy.author).to eq(user) - expect(copy.start_time).to eq(meeting.start_time + 1.day) - end - - context "when the meeting is closed" do - it "reopens the meeting" do - meeting.update! state: "closed" - expect(service_result).to be_success - expect(copy.state).to eq("open") - end - end - - describe "with participants" do - let(:invited_user) { create(:user, member_with_permissions: { project => %i(view_meetings) }) } - let(:attending_user) { create(:user, member_with_permissions: { project => %i(view_meetings) }) } - let(:invalid_user) { create(:user) } - - it "copies applicable participants, resetting attended status" do - meeting.participants.create!(user: invited_user, invited: true, attended: false) - meeting.participants.create!(user: attending_user, invited: true, attended: true) - meeting.participants.create!(user: invalid_user, invited: true, attended: true) - - expect(service_result).to be_success - expect(copy.participants.count).to eq(2) - invited = copy.participants.find_by(user: invited_user) - attending = copy.participants.find_by(user: attending_user) - expect(invited).to be_invited - expect(invited).not_to be_attended - - expect(attending).to be_invited - expect(attending).not_to be_attended - - invalid = copy.participants.find_by(user: invalid_user) - expect(invalid).to be_nil - end - end - - describe "without participants" do - it "sets the author as invited" do - meeting.participants.destroy_all - - expect(service_result).to be_success - expect(copy.participants.count).to eq(1) - invited = copy.participants.find_by(user:) - expect(invited).to be_invited + context "with a cancelled meeting for tomorrow" do + let!(:scheduled_meeting) do + create(:scheduled_meeting, + :cancelled, + recurring_meeting: series, + start_time: Time.zone.today + 1.day + 10.hours) end - end - describe "when not saving" do - let(:params) { { save: false } } - - it "builds the meeting" do - expect(service_result).to be_success - expect(copy.author).to eq(user) - expect(copy.start_time).to eq(meeting.start_time + 1.day) - expect(copy).to be_new_record - end - end - - context "with agenda items" do - shared_let(:agenda_item) do - create(:meeting_agenda_item, - meeting:, - notes: "hello there") - end - - it "copies the agenda item" do - expect(copy.reload.agenda_items.length) - .to eq 1 - - expect(copy.agenda_items.first.notes) - .to eq agenda_item.notes - end + context "when updating the start_date to the same time" do + let(:params) do + { start_date: Time.zone.today + 1.days } + end - context "when asking not to copy agenda" do - let(:params) { { copy_agenda: false } } + it "keeps that cancelled occurrence" do + expect(service_result).to be_success + expect(updated_meeting.start_time).to eq(Time.zone.today + 1.days + 10.hours) - it "does not copy agenda items" do - expect(copy.agenda_items).to be_empty + expect { scheduled_meeting.reload }.not_to raise_error end end - end - context "with attachments" do - shared_let(:attachment) do - create(:attachment, - container: meeting) - end - shared_let(:agenda_item) do - create(:meeting_agenda_item, - meeting:, - notes: "![](/api/v3/attachments/#{attachment.id}/content") - end - - context "when asking to copy attachments" do - let(:params) { { copy_attachments: true } } - - it "copies the attachment" do - expect(copy.attachments.length) - .to eq 1 - - expect(copy.attachments.first.id) - .not_to eq attachment.id - - expect(copy.agenda_items.count).to eq(1) - expect(copy.agenda_items.first.notes).to include "attachments/#{copy.attachments.first.id}/content" + context "when updating the start_date to further in the future" do + let(:params) do + { start_date: Time.zone.today + 2.days } end - end - context "when asking not to copy attachments" do - let(:params) { { copy_attachments: false } } + it "deletes that cancelled occurrence" do + expect(service_result).to be_success + expect(updated_meeting.start_time).to eq(Time.zone.today + 2.days + 10.hours) - it "does not copy attachments" do - expect(copy.attachments).to be_empty - expect(copy.agenda_items.first.notes).to include "attachments/#{attachment.id}/content" + expect { scheduled_meeting.reload }.to raise_error(ActiveRecord::RecordNotFound) end end end From c62cf70d43274e9da8620196929b761caa5c75ca Mon Sep 17 00:00:00 2001 From: Mir Bhatia Date: Fri, 29 Nov 2024 12:08:19 +0100 Subject: [PATCH 086/129] Limit count_rule until end_date --- modules/meeting/app/models/recurring_meeting.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/meeting/app/models/recurring_meeting.rb b/modules/meeting/app/models/recurring_meeting.rb index 5b7ecad3978d..9f0f6f621d0e 100644 --- a/modules/meeting/app/models/recurring_meeting.rb +++ b/modules/meeting/app/models/recurring_meeting.rb @@ -164,7 +164,7 @@ def count_rule(rule) if end_after_iterations? rule.count(iterations) else - rule + rule.until(end_date) end end end From a657f794857466268aa3bab6e7c28e3f9fa37ae6 Mon Sep 17 00:00:00 2001 From: Mir Bhatia Date: Fri, 29 Nov 2024 18:27:39 +0100 Subject: [PATCH 087/129] Show changed start times for occurrences --- .../recurring_meetings/row_component.rb | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/modules/meeting/app/components/recurring_meetings/row_component.rb b/modules/meeting/app/components/recurring_meetings/row_component.rb index 778db4091220..82f569ecffc7 100644 --- a/modules/meeting/app/components/recurring_meetings/row_component.rb +++ b/modules/meeting/app/components/recurring_meetings/row_component.rb @@ -56,16 +56,30 @@ def start_time end end - def user_start_time_value - @user_start_time_value ||= helpers.in_current_user_zone(model.start_time) + def user_time_zone(time) + helpers.in_current_user_zone(time) + end + + def formatted_time(time) + helpers.format_time(user_time_zone(time), include_date: true) + end + + def old_time + render(Primer::Beta::Text.new(tag: :s)) { formatted_time(model.start_time) } end def start_time_title - helpers.format_time(user_start_time_value, include_date: true) + if start_time_changed? + "#{old_time}\n#{formatted_time(meeting.start_time)}".html_safe # rubocop:disable Rails/OutputSafety + else + formatted_time(model.start_time) + end end def relative_time - render(OpPrimer::RelativeTimeComponent.new(datetime: user_start_time_value, prefix: I18n.t(:label_on))) + time = start_time_changed? ? meeting.start_time : model.start_time + + render(OpPrimer::RelativeTimeComponent.new(datetime: user_time_zone(time), prefix: I18n.t(:label_on))) end def last_edited @@ -190,5 +204,9 @@ def delete_allowed? def copy_allowed? User.current.allowed_in_project?(:create_meetings, project) end + + def start_time_changed? + meeting && meeting.start_time != model.start_time + end end end From 40741d100383b8168a85c432cacae2f7b3dc791b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Mon, 2 Dec 2024 08:38:30 +0100 Subject: [PATCH 088/129] Indent case --- .../meeting/app/models/recurring_meeting.rb | 31 ++++++++++--------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/modules/meeting/app/models/recurring_meeting.rb b/modules/meeting/app/models/recurring_meeting.rb index 9f0f6f621d0e..378c5e874bf3 100644 --- a/modules/meeting/app/models/recurring_meeting.rb +++ b/modules/meeting/app/models/recurring_meeting.rb @@ -75,22 +75,23 @@ def schedule end def schedule_in_words # rubocop:disable Metrics/AbcSize - base = case frequency - when "daily" - interval == 1 ? human_frequency : I18n.t("recurring_meeting.in_words.daily_interval", interval: interval.ordinalize) - when "working_days" - if interval == 1 - I18n.t("recurring_meeting.in_words.working_days") - else - I18n.t("recurring_meeting.in_words.working_days_interval", interval: interval.ordinalize) + base = + case frequency + when "daily" + interval == 1 ? human_frequency : I18n.t("recurring_meeting.in_words.daily_interval", interval: interval.ordinalize) + when "working_days" + if interval == 1 + I18n.t("recurring_meeting.in_words.working_days") + else + I18n.t("recurring_meeting.in_words.working_days_interval", interval: interval.ordinalize) + end + when "weekly" + if interval == 1 + I18n.t("recurring_meeting.in_words.weekly", weekday:) + else + I18n.t("recurring_meeting.in_words.weekly_interval", interval: interval.ordinalize, weekday:) + end end - when "weekly" - if interval == 1 - I18n.t("recurring_meeting.in_words.weekly", weekday:) - else - I18n.t("recurring_meeting.in_words.weekly_interval", interval: interval.ordinalize, weekday:) - end - end I18n.t("recurring_meeting.in_words.full", base:, time: format_time(start_time, include_date: false), end_date: format_date(end_date)) From fbd5ff7b0d6c2b04c71e2d44d799497d63e3890c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Mon, 2 Dec 2024 09:11:21 +0100 Subject: [PATCH 089/129] Fix destroy path to include project --- .../recurring_meetings/show_page_header_component.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/meeting/app/components/recurring_meetings/show_page_header_component.html.erb b/modules/meeting/app/components/recurring_meetings/show_page_header_component.html.erb index 79f8c655610a..d4b156c36d0d 100644 --- a/modules/meeting/app/components/recurring_meetings/show_page_header_component.html.erb +++ b/modules/meeting/app/components/recurring_meetings/show_page_header_component.html.erb @@ -33,7 +33,7 @@ ) menu.with_item( label: I18n.t(:label_recurring_meeting_series_delete), - href: recurring_meeting_path(@meeting), + href: polymorphic_path([@project, @meeting]), scheme: :danger, form_arguments: { method: :delete, data: { confirm: t("text_are_you_sure"), turbo: 'false' } From 2dc1c14807c044007fcc48f606ab9e5e477aec70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Mon, 2 Dec 2024 09:11:25 +0100 Subject: [PATCH 090/129] Fix past meetings --- .../meeting/app/controllers/recurring_meetings_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/meeting/app/controllers/recurring_meetings_controller.rb b/modules/meeting/app/controllers/recurring_meetings_controller.rb index cc1e315298d8..42cde4121b72 100644 --- a/modules/meeting/app/controllers/recurring_meetings_controller.rb +++ b/modules/meeting/app/controllers/recurring_meetings_controller.rb @@ -37,7 +37,7 @@ def show # rubocop:disable Metrics/AbcSize @direction = params[:direction] if params[:direction] == "past" @meetings = @recurring_meeting - .instances(upcoming: false) + .scheduled_instances(upcoming: false) .page(page_param) .per_page(per_page_param) else From b1a8bed7bc0786ae807a83d9381a3aedfef311e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Mon, 2 Dec 2024 09:11:33 +0100 Subject: [PATCH 091/129] Schedule first occurrence on creation --- .../meeting/app/models/recurring_meeting.rb | 4 + .../recurring_meetings/create_service.rb | 28 +++--- .../create_service_integration_spec.rb | 89 +++++++++++++++++++ 3 files changed, 111 insertions(+), 10 deletions(-) create mode 100644 modules/meeting/spec/services/recurring_meetings/create_service_integration_spec.rb diff --git a/modules/meeting/app/models/recurring_meeting.rb b/modules/meeting/app/models/recurring_meeting.rb index 378c5e874bf3..894266e1278d 100644 --- a/modules/meeting/app/models/recurring_meeting.rb +++ b/modules/meeting/app/models/recurring_meeting.rb @@ -101,6 +101,10 @@ def scheduled_occurrences(limit:) schedule.next_occurrences(limit, Time.current) end + def first_occurrence + schedule.first + end + def remaining_occurrences if end_date.present? schedule.occurrences_between(Time.current, end_date) diff --git a/modules/meeting/app/services/recurring_meetings/create_service.rb b/modules/meeting/app/services/recurring_meetings/create_service.rb index 4304c891f676..7155a6c41179 100644 --- a/modules/meeting/app/services/recurring_meetings/create_service.rb +++ b/modules/meeting/app/services/recurring_meetings/create_service.rb @@ -35,21 +35,29 @@ class CreateService < ::BaseServices::Create def after_perform(call) return call unless call.success? - template = create_meeting_template(call.result) - unless template.save - call.merge! ServiceResult.failure(result: template, errors: template.errors) - end + recurring_meeting = call.result + call.merge! create_meeting_template(recurring_meeting) if call.success? + call.merge! init_first_occurrence(recurring_meeting) if call.success? call end + def init_first_occurrence(recurring_meeting) + first_time = recurring_meeting.first_occurrence + + ::RecurringMeetings::InitOccurrenceService + .new(user:, recurring_meeting:) + .call(start_time: first_time) + end + def create_meeting_template(recurring_meeting) - StructuredMeeting.new(@template_params).tap do |template| - template.project = recurring_meeting.project - template.template = true - template.recurring_meeting = recurring_meeting - template.author = user - end + template = StructuredMeeting.new(@template_params) + template.project = recurring_meeting.project + template.template = true + template.recurring_meeting = recurring_meeting + template.author = user + + ServiceResult.new(success: template.save, errors: template.errors) end end end diff --git a/modules/meeting/spec/services/recurring_meetings/create_service_integration_spec.rb b/modules/meeting/spec/services/recurring_meetings/create_service_integration_spec.rb new file mode 100644 index 000000000000..6158436b986f --- /dev/null +++ b/modules/meeting/spec/services/recurring_meetings/create_service_integration_spec.rb @@ -0,0 +1,89 @@ +#-- 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 RecurringMeetings::CreateService, "integration", type: :model do + shared_let(:project) { create(:project, enabled_module_names: %i[meetings]) } + shared_let(:user) do + create(:user, member_with_permissions: { project => %i(view_meetings create_meetings) }) + end + let(:instance) { described_class.new(user:) } + let(:params) { {} } + + let(:service_result) { instance.call(**params) } + let(:series) { service_result.result } + + context "with a daily schedule" do + let(:first_start) { Time.zone.tomorrow + 10.hours } + let(:params) do + { + start_time: first_start, + frequency: "daily", + interval: 1, + end_after: "specific_date", + end_date: 1.month.from_now, + project:, + title: "My daily" + } + end + + it "creates the series, template, and first occurrence" do + expect(service_result).to be_success + expect(series).to be_persisted + + expect(series.template).to be_a(StructuredMeeting) + expect(series.template).to be_template + + expect(series.meetings.count).to eq(2) # template and occurrence + expect(series.first_occurrence).to eq(first_start) + + first_instance = series.meetings.not_templated.first + first_scheduled = series.scheduled_meetings.first + + expect(first_instance.start_time).to eq(first_start) + expect(first_scheduled.start_time).to eq(first_start) + expect(first_scheduled.recurring_meeting).to eq(series) + expect(first_scheduled.meeting).to eq(first_instance) + end + + context "when the template cannot be saved" do + let(:template) { StructuredMeeting.new } + + before do + allow(StructuredMeeting).to receive(:new).and_return(template) + allow(template).to receive(:save).and_return(false) + end + + it "does not create the series" do + expect(service_result).not_to be_success + expect(series).to be_new_record + end + end + end +end From 47e1093db28f43f012a69f368ce6fb278f82b3bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Mon, 2 Dec 2024 09:46:12 +0100 Subject: [PATCH 092/129] Linting --- .../app/components/meetings/combined_filter_component.rb | 2 +- .../app/components/meetings/index_sub_header_component.rb | 2 +- .../recurring_meetings/show_page_header_component.rb | 2 +- .../recurring_meetings/show_page_sub_header_component.rb | 2 +- modules/meeting/app/controllers/meetings_controller.rb | 6 ++++-- modules/meeting/app/forms/recurring_meeting/frequency.rb | 2 +- modules/meeting/app/forms/recurring_meeting/interval.rb | 2 +- .../meeting/app/forms/recurring_meeting/specific_date.rb | 1 + modules/meeting/app/models/recurring_meeting.rb | 4 +++- modules/meeting/spec/factories/meeting_factory.rb | 8 ++++---- modules/meeting/spec/requests/recurring_meetings_spec.rb | 7 ------- 11 files changed, 18 insertions(+), 20 deletions(-) delete mode 100644 modules/meeting/spec/requests/recurring_meetings_spec.rb diff --git a/modules/meeting/app/components/meetings/combined_filter_component.rb b/modules/meeting/app/components/meetings/combined_filter_component.rb index 6997f57e3e64..786ef0632e1e 100644 --- a/modules/meeting/app/components/meetings/combined_filter_component.rb +++ b/modules/meeting/app/components/meetings/combined_filter_component.rb @@ -33,7 +33,7 @@ class CombinedFilterComponent < ApplicationComponent include OpPrimer::ComponentHelpers include Redmine::I18n - def initialize(query:, project: nil, params:) + def initialize(query:, params:, project: nil) super() @query = query diff --git a/modules/meeting/app/components/meetings/index_sub_header_component.rb b/modules/meeting/app/components/meetings/index_sub_header_component.rb index 59ff8672bef0..1be28c8987c8 100644 --- a/modules/meeting/app/components/meetings/index_sub_header_component.rb +++ b/modules/meeting/app/components/meetings/index_sub_header_component.rb @@ -34,7 +34,7 @@ class IndexSubHeaderComponent < ApplicationComponent # rubocop:enable OpenProject/AddPreviewForViewComponent include ApplicationHelper - def initialize(query:, project: nil, params:) + def initialize(query:, params:, project: nil) super @query = query @project = project diff --git a/modules/meeting/app/components/recurring_meetings/show_page_header_component.rb b/modules/meeting/app/components/recurring_meetings/show_page_header_component.rb index a0e641a09665..20cc8aa68e4a 100644 --- a/modules/meeting/app/components/recurring_meetings/show_page_header_component.rb +++ b/modules/meeting/app/components/recurring_meetings/show_page_header_component.rb @@ -65,7 +65,7 @@ def label_text end def page_title - @meeting.present? ? @meeting.title + " (Meeting series)" : I18n.t(:label_recurring_meeting_plural) + @meeting.present? ? "#{@meeting.title} (Meeting series)" : I18n.t(:label_recurring_meeting_plural) end def page_description diff --git a/modules/meeting/app/components/recurring_meetings/show_page_sub_header_component.rb b/modules/meeting/app/components/recurring_meetings/show_page_sub_header_component.rb index 60c17bf60ac1..8d7f8c856985 100644 --- a/modules/meeting/app/components/recurring_meetings/show_page_sub_header_component.rb +++ b/modules/meeting/app/components/recurring_meetings/show_page_sub_header_component.rb @@ -34,7 +34,7 @@ class ShowPageSubHeaderComponent < ApplicationComponent # rubocop:enable OpenProject/AddPreviewForViewComponent include ApplicationHelper - def initialize(meeting:, project: nil, params:) + def initialize(meeting:, params:, project: nil) super @meeting = meeting diff --git a/modules/meeting/app/controllers/meetings_controller.rb b/modules/meeting/app/controllers/meetings_controller.rb index ee41fc725e87..ef6aee2a8665 100644 --- a/modules/meeting/app/controllers/meetings_controller.rb +++ b/modules/meeting/app/controllers/meetings_controller.rb @@ -69,7 +69,7 @@ def index :meetings end - def show + def show # rubocop:disable Metrics/AbcSize respond_to do |format| format.html do html_title "#{t(:label_meeting)}: #{@meeting.title}" @@ -181,11 +181,13 @@ def copy def destroy recurring = @meeting.recurring_meeting + # rubocop:disable Rails/ActionControllerFlashBeforeRender Meetings::DeleteService .new(model: @meeting, user: User.current) .call .on_success { flash[:notice] = I18n.t(:notice_successful_delete) } .on_failure { |call| flash[:error] = call.message } + # rubocop:enable Rails/ActionControllerFlashBeforeRender if recurring redirect_to polymorphic_path([@project, recurring], status: :see_other) @@ -389,7 +391,7 @@ def find_meeting render_404 end - def convert_params + def convert_params # rubocop:disable Metrics/AbcSize # We do some preprocessing of `meeting_params` that we will store in this # instance variable. @converted_params = meeting_params.to_h diff --git a/modules/meeting/app/forms/recurring_meeting/frequency.rb b/modules/meeting/app/forms/recurring_meeting/frequency.rb index c8282039f0e4..06992662d220 100644 --- a/modules/meeting/app/forms/recurring_meeting/frequency.rb +++ b/modules/meeting/app/forms/recurring_meeting/frequency.rb @@ -36,7 +36,7 @@ class RecurringMeeting::Frequency < ApplicationForm "show-when-value-selected-target": "cause" } ) do |list| - RecurringMeeting.frequencies.keys.each do |value| + RecurringMeeting.frequencies.each_key do |value| label = I18n.t(:"recurring_meeting.frequency.#{value}") list.option(label:, value:) end diff --git a/modules/meeting/app/forms/recurring_meeting/interval.rb b/modules/meeting/app/forms/recurring_meeting/interval.rb index d660a509e69f..9d598246dd6b 100644 --- a/modules/meeting/app/forms/recurring_meeting/interval.rb +++ b/modules/meeting/app/forms/recurring_meeting/interval.rb @@ -32,7 +32,7 @@ class RecurringMeeting::Interval < ApplicationForm name: :interval, type: :number, label: I18n.t("activerecord.attributes.recurring_meeting.interval"), - caption: I18n.t("recurring_meeting.interval.instructions"), + caption: I18n.t("recurring_meeting.interval.instructions") ) end end diff --git a/modules/meeting/app/forms/recurring_meeting/specific_date.rb b/modules/meeting/app/forms/recurring_meeting/specific_date.rb index d905d22fdef0..d4ab18a15af8 100644 --- a/modules/meeting/app/forms/recurring_meeting/specific_date.rb +++ b/modules/meeting/app/forms/recurring_meeting/specific_date.rb @@ -41,6 +41,7 @@ class RecurringMeeting::SpecificDate < ApplicationForm end def initialize(initial_value: 1.year.from_now.strftime("%Y-%m-%d")) + super() @initial_value = initial_value end end diff --git a/modules/meeting/app/models/recurring_meeting.rb b/modules/meeting/app/models/recurring_meeting.rb index 894266e1278d..5e591055f0ab 100644 --- a/modules/meeting/app/models/recurring_meeting.rb +++ b/modules/meeting/app/models/recurring_meeting.rb @@ -93,7 +93,9 @@ def schedule_in_words # rubocop:disable Metrics/AbcSize end end - I18n.t("recurring_meeting.in_words.full", base:, time: format_time(start_time, include_date: false), + I18n.t("recurring_meeting.in_words.full", + base:, + time: format_time(start_time, include_date: false), end_date: format_date(end_date)) end diff --git a/modules/meeting/spec/factories/meeting_factory.rb b/modules/meeting/spec/factories/meeting_factory.rb index a12b89654137..68dd2c14897b 100644 --- a/modules/meeting/spec/factories/meeting_factory.rb +++ b/modules/meeting/spec/factories/meeting_factory.rb @@ -39,12 +39,12 @@ meeting.project = evaluator.project if evaluator.project end - factory :structured_meeting, class: "StructuredMeeting" do |m| - m.sequence(:title) { |n| "Structured meeting #{n}" } + factory :structured_meeting, class: "StructuredMeeting" do |structured_meeting| + structured_meeting.sequence(:title) { |n| "Structured meeting #{n}" } end - factory :structured_meeting_template, class: "StructuredMeeting" do |m| - m.sequence(:title) { |n| "Structured meeting template #{n}" } + factory :structured_meeting_template, class: "StructuredMeeting" do |structured_meeting| + structured_meeting.sequence(:title) { |n| "Structured meeting template #{n}" } template { true } recurring_meeting diff --git a/modules/meeting/spec/requests/recurring_meetings_spec.rb b/modules/meeting/spec/requests/recurring_meetings_spec.rb deleted file mode 100644 index 5f22e0ff9eb3..000000000000 --- a/modules/meeting/spec/requests/recurring_meetings_spec.rb +++ /dev/null @@ -1,7 +0,0 @@ -require 'rails_helper' - -RSpec.describe "RecurringMeetings", type: :request do - describe "GET /index" do - pending "add some examples (or delete) #{__FILE__}" - end -end From 101c42e6beb855b878e7e6d5041e78342ae4b893 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Mon, 2 Dec 2024 09:48:00 +0100 Subject: [PATCH 093/129] fixup! Linting --- modules/meeting/spec/factories/scheduled_meeting_factory.rb | 2 +- .../recurring_meetings/update_service_integration_spec.rb | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/meeting/spec/factories/scheduled_meeting_factory.rb b/modules/meeting/spec/factories/scheduled_meeting_factory.rb index acf021f015db..b963ce8333cd 100644 --- a/modules/meeting/spec/factories/scheduled_meeting_factory.rb +++ b/modules/meeting/spec/factories/scheduled_meeting_factory.rb @@ -27,7 +27,7 @@ #++ FactoryBot.define do - factory :scheduled_meeting, class: "ScheduledMeeting" do |m| + factory :scheduled_meeting, class: "ScheduledMeeting" do recurring_meeting cancelled { false } meeting { nil } diff --git a/modules/meeting/spec/services/recurring_meetings/update_service_integration_spec.rb b/modules/meeting/spec/services/recurring_meetings/update_service_integration_spec.rb index c238d097a0e7..ff3a69ae6160 100644 --- a/modules/meeting/spec/services/recurring_meetings/update_service_integration_spec.rb +++ b/modules/meeting/spec/services/recurring_meetings/update_service_integration_spec.rb @@ -59,12 +59,12 @@ context "when updating the start_date to the same time" do let(:params) do - { start_date: Time.zone.today + 1.days } + { start_date: Time.zone.today + 1.day } end it "keeps that cancelled occurrence" do expect(service_result).to be_success - expect(updated_meeting.start_time).to eq(Time.zone.today + 1.days + 10.hours) + expect(updated_meeting.start_time).to eq(Time.zone.today + 1.day + 10.hours) expect { scheduled_meeting.reload }.not_to raise_error end From 83ff834a4efbbd7271c39c6bae7ef78c12098661 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Mon, 2 Dec 2024 13:54:59 +0100 Subject: [PATCH 094/129] Autoschedule job --- .../meeting/app/models/recurring_meeting.rb | 8 + .../recurring_meetings/create_service.rb | 17 +- .../init_next_occurrence_job.rb | 121 +++++++++++++++ .../create_service_integration_spec.rb | 24 ++- .../init_next_occurrence_job_spec.rb | 146 ++++++++++++++++++ 5 files changed, 297 insertions(+), 19 deletions(-) create mode 100644 modules/meeting/app/workers/recurring_meetings/init_next_occurrence_job.rb create mode 100644 modules/meeting/spec/workers/recurring_meetings/init_next_occurrence_job_spec.rb diff --git a/modules/meeting/app/models/recurring_meeting.rb b/modules/meeting/app/models/recurring_meeting.rb index 5e591055f0ab..42e3dbab629e 100644 --- a/modules/meeting/app/models/recurring_meeting.rb +++ b/modules/meeting/app/models/recurring_meeting.rb @@ -107,6 +107,14 @@ def first_occurrence schedule.first end + def last_occurrence + schedule.last + end + + def next_occurrence(from_time: Time.current) + schedule.next_occurrence(from_time) + end + def remaining_occurrences if end_date.present? schedule.occurrences_between(Time.current, end_date) diff --git a/modules/meeting/app/services/recurring_meetings/create_service.rb b/modules/meeting/app/services/recurring_meetings/create_service.rb index 7155a6c41179..a29d6af935a5 100644 --- a/modules/meeting/app/services/recurring_meetings/create_service.rb +++ b/modules/meeting/app/services/recurring_meetings/create_service.rb @@ -37,17 +37,22 @@ def after_perform(call) recurring_meeting = call.result call.merge! create_meeting_template(recurring_meeting) if call.success? - call.merge! init_first_occurrence(recurring_meeting) if call.success? + schedule_init_job(recurring_meeting) if call.success? call end - def init_first_occurrence(recurring_meeting) - first_time = recurring_meeting.first_occurrence + ## + # We want to automatically schedule the next occurrence + # AFTER the first occurrence has passed. + # We do not create initially as you still need to update the template. + def schedule_init_job(recurring_meeting) + first_occurrence = recurring_meeting.first_occurrence + return if first_occurrence.nil? - ::RecurringMeetings::InitOccurrenceService - .new(user:, recurring_meeting:) - .call(start_time: first_time) + ::RecurringMeetings::InitNextOccurrenceJob + .set(wait_until: first_occurrence.to_time) + .perform_later(recurring_meeting) end def create_meeting_template(recurring_meeting) diff --git a/modules/meeting/app/workers/recurring_meetings/init_next_occurrence_job.rb b/modules/meeting/app/workers/recurring_meetings/init_next_occurrence_job.rb new file mode 100644 index 000000000000..612adc36410b --- /dev/null +++ b/modules/meeting/app/workers/recurring_meetings/init_next_occurrence_job.rb @@ -0,0 +1,121 @@ +#-- 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 RecurringMeetings + class InitNextOccurrenceJob < ApplicationJob + include GoodJob::ActiveJobExtensions::Concurrency + + good_job_control_concurrency_with( + perform_limit: 1, + key: -> { "#{self.class.name}-#{queue_name}-#{arguments.first.id}" } + ) + + attr_accessor :recurring_meeting + + def perform(recurring_meeting) + self.recurring_meeting = recurring_meeting + + if next_scheduled_time.nil? + Rails.logger.debug { "Meeting series #{recurring_meeting} is ending." } + return + end + + # Schedule the next occurrence, if not instantiated + check_next_occurrence + rescue StandardError => e + Rails.logger.error { "Error while initializing next occurrence for series ##{recurring_meeting}: #{e.message}" } + ensure + schedule_next_job + end + + private + + def check_next_occurrence + if next_occurrence_instantiated? + Rails.logger.debug { "Will not create next occurrence for series #{recurring_meeting} as already instantiated" } + return + end + + if next_occurrence_cancelled? + Rails.logger.debug { "Will not create next occurrence for series #{recurring_meeting} is already cancelled" } + return + end + + init_meeting + end + + def init_meeting + call = ::RecurringMeetings::InitOccurrenceService + .new(user: User.system, recurring_meeting:) + .call(start_time: next_scheduled_time) + + call.on_success do + Rails.logger.debug { "Initialized occurrence for series ##{recurring_meeting} at #{next_scheduled_time}" } + end + + call.on_failure do |call| + Rails.logger.error do + "Could not create next occurrence for series ##{recurring_meeting}: #{call.message}" + end + end + end + + ## + # Schedule when this job should be run the next time + def schedule_next_job + self + .class + .set(wait_until: next_scheduled_time) + .perform_later(recurring_meeting) + end + + ## + # Return if there is already an instantiated upcoming meeting + def next_occurrence_instantiated? + recurring_meeting + .scheduled_instances + .where.not(meeting_id: nil) + .exists?(start_time: next_scheduled_time) + end + + ## + # Return if the next occurrence is cancelled + def next_occurrence_cancelled? + recurring_meeting + .scheduled_instances + .where(cancelled: true) + .exists?(start_time: next_scheduled_time) + end + + def next_scheduled_time + return @next_scheduled_time if defined?(@next_scheduled_time) + + @next_scheduled_time = recurring_meeting.next_occurrence&.to_time + end + end +end diff --git a/modules/meeting/spec/services/recurring_meetings/create_service_integration_spec.rb b/modules/meeting/spec/services/recurring_meetings/create_service_integration_spec.rb index 6158436b986f..deb3eba6caf8 100644 --- a/modules/meeting/spec/services/recurring_meetings/create_service_integration_spec.rb +++ b/modules/meeting/spec/services/recurring_meetings/create_service_integration_spec.rb @@ -34,10 +34,11 @@ create(:user, member_with_permissions: { project => %i(view_meetings create_meetings) }) end let(:instance) { described_class.new(user:) } + let(:service_result) { subject } + let(:series) { service_result.result } let(:params) { {} } - let(:service_result) { instance.call(**params) } - let(:series) { service_result.result } + subject { instance.call(**params) } context "with a daily schedule" do let(:first_start) { Time.zone.tomorrow + 10.hours } @@ -53,23 +54,19 @@ } end - it "creates the series, template, and first occurrence" do + it "creates the series, template, and schedule the init job" do + expect { subject }.to have_enqueued_job(RecurringMeetings::InitNextOccurrenceJob) + job = enqueued_jobs.detect { |h| h["job_class"] == "RecurringMeetings::InitNextOccurrenceJob" } + expect(DateTime.parse(job["scheduled_at"])).to eq(Time.zone.tomorrow + 10.hours) + expect(service_result).to be_success expect(series).to be_persisted expect(series.template).to be_a(StructuredMeeting) expect(series.template).to be_template - expect(series.meetings.count).to eq(2) # template and occurrence - expect(series.first_occurrence).to eq(first_start) - - first_instance = series.meetings.not_templated.first - first_scheduled = series.scheduled_meetings.first - - expect(first_instance.start_time).to eq(first_start) - expect(first_scheduled.start_time).to eq(first_start) - expect(first_scheduled.recurring_meeting).to eq(series) - expect(first_scheduled.meeting).to eq(first_instance) + expect(series.meetings.count).to eq(1) + expect(series.meetings.first).to be_template end context "when the template cannot be saved" do @@ -81,6 +78,7 @@ end it "does not create the series" do + expect { subject }.not_to have_enqueued_job(RecurringMeetings::InitNextOccurrenceJob) expect(service_result).not_to be_success expect(series).to be_new_record end diff --git a/modules/meeting/spec/workers/recurring_meetings/init_next_occurrence_job_spec.rb b/modules/meeting/spec/workers/recurring_meetings/init_next_occurrence_job_spec.rb new file mode 100644 index 000000000000..2bc41fce308e --- /dev/null +++ b/modules/meeting/spec/workers/recurring_meetings/init_next_occurrence_job_spec.rb @@ -0,0 +1,146 @@ +#-- 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" +require_module_spec_helper + +RSpec.describe RecurringMeetings::InitNextOccurrenceJob, type: :model do + shared_let(:series) do + create(:recurring_meeting, + start_time: Time.zone.tomorrow + 10.hours, + frequency: "daily", + interval: 1, + end_after: "specific_date", + end_date: 1.month.from_now) + end + + subject { described_class.perform_now(series) } + + it "schedules the next occurrence" do + expect { subject }.to change(StructuredMeeting, :count).by(1) + expect(subject).to be_success + + created_meeting = subject.result + expect(created_meeting.start_time).to eq(Time.zone.tomorrow + 10.hours) + end + + context "when next occurrence is cancelled" do + let!(:schedule) do + create(:scheduled_meeting, + :cancelled, + recurring_meeting: series, + start_time: Time.zone.tomorrow + 10.hours) + end + + it "does not schedule anything" do + expect { subject }.not_to change(StructuredMeeting, :count) + expect(subject).to be_nil + end + end + + context "when next occurrence is already instantiated" do + let!(:instance) do + create(:structured_meeting, + recurring_meeting: series, + start_time: Time.zone.tomorrow + 10.hours) + end + + let!(:schedule) do + create(:scheduled_meeting, + meeting: instance, + recurring_meeting: series, + start_time: Time.zone.tomorrow + 10.hours) + end + + it "does not schedule anything" do + expect { subject }.not_to change(StructuredMeeting, :count) + expect(subject).to be_nil + end + end + + context "when next occurrence is already instantiated, and moved" do + let!(:instance) do + create(:structured_meeting, + recurring_meeting: series, + start_time: Time.zone.tomorrow + 1.day + 10.hours) + end + + let!(:schedule) do + create(:scheduled_meeting, + meeting: instance, + recurring_meeting: series, + start_time: Time.zone.tomorrow + 10.hours) + end + + it "does not schedule anything" do + expect { subject }.not_to change(StructuredMeeting, :count) + expect(subject).to be_nil + end + end + + context "when later occurrence is already instantiated" do + let!(:instance) do + create(:structured_meeting, + recurring_meeting: series, + start_time: Time.zone.tomorrow + 1.day + 10.hours) + end + + let!(:schedule) do + create(:scheduled_meeting, + meeting: instance, + recurring_meeting: series, + start_time: Time.zone.tomorrow + 1.day + 10.hours) + end + + it "schedules the one for tomorrow" do + expect { subject }.to change(StructuredMeeting, :count).by(1) + expect(subject).to be_success + + created_meeting = subject.result + expect(created_meeting.start_time).to eq(Time.zone.tomorrow + 10.hours) + end + end + + context "when called after end_date" do + it "does not schedule the next occurrence" do + Timecop.freeze(series.end_date + 1.day) do + expect { subject }.not_to change(StructuredMeeting, :count) + expect(subject).to be_nil + end + end + end + + context "when called on last occurrence" do + it "does not schedule the next occurrence" do + Timecop.freeze(series.last_occurrence) do + expect { subject }.not_to change(StructuredMeeting, :count) + expect(subject).to be_nil + end + end + end +end From 20680872962c38afce14b14061aa6bb9d5194ca6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Mon, 2 Dec 2024 14:50:33 +0100 Subject: [PATCH 095/129] Add more schedule tests --- .../spec/models/recurring_meeting_spec.rb | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/modules/meeting/spec/models/recurring_meeting_spec.rb b/modules/meeting/spec/models/recurring_meeting_spec.rb index 59fdd04eebed..415b9c9c2472 100644 --- a/modules/meeting/spec/models/recurring_meeting_spec.rb +++ b/modules/meeting/spec/models/recurring_meeting_spec.rb @@ -26,4 +26,105 @@ end end end + + describe "daily schedule" do + subject do + build(:recurring_meeting, + start_time: Time.zone.tomorrow + 10.hours, + frequency: "daily", + end_after: "specific_date", + end_date: Time.zone.tomorrow + 1.week) + end + + it "schedules daily", :aggregate_failures do + expect(subject.first_occurrence).to eq Time.zone.tomorrow + 10.hours + expect(subject.last_occurrence).to eq Time.zone.tomorrow + 1.week + 10.hours + + occurrence_in_two_days = Time.zone.today + 2.days + 10.hours + Timecop.freeze(Time.zone.tomorrow + 11.hours) do + expect(subject.next_occurrence).to eq occurrence_in_two_days + end + + next_occurrences = subject.scheduled_occurrences(limit: 5).map(&:to_time) + expect(next_occurrences).to eq [ + Time.zone.tomorrow + 10.hours, + Time.zone.today + 2.days + 10.hours, + Time.zone.today + 3.days + 10.hours, + Time.zone.today + 4.days + 10.hours, + Time.zone.today + 5.days + 10.hours + ] + + Timecop.freeze(Time.zone.tomorrow + 2.weeks) do + expect(subject.next_occurrence).to be_nil + end + end + end + + describe "working_days schedule" do + subject do + build(:recurring_meeting, + start_time: DateTime.parse("2024-12-02T10:00Z"), + frequency: "working_days", + end_after: "specific_date", + end_date: DateTime.parse("2024-12-29T10:00Z")) + end + + context "with working days set to four-week", with_settings: { working_days: [1, 2, 3, 4] } do + it "schedules working days", :aggregate_failures do + # Monday, 9AM + Timecop.freeze(DateTime.parse("2024-12-02T09:00Z")) do + expect(subject.first_occurrence).to eq Time.zone.today + 10.hours + # Last thursday of the year + expect(subject.last_occurrence).to eq DateTime.parse("2024-12-26T10:00Z") + + next_occurrences = subject.scheduled_occurrences(limit: 5).map(&:to_time) + expect(next_occurrences).to eq [ + DateTime.parse("2024-12-02T10:00Z"), + DateTime.parse("2024-12-03T10:00Z"), + DateTime.parse("2024-12-04T10:00Z"), + DateTime.parse("2024-12-05T10:00Z"), + DateTime.parse("2024-12-09T10:00Z") + ] + end + + # Go to Saturday, expect next on Monday + Timecop.freeze(DateTime.parse("2024-12-07T09:00Z")) do + expect(subject.next_occurrence).to eq DateTime.parse("2024-12-09T10:00Z") + end + end + end + end + + describe "weekly schedule" do + subject do + build(:recurring_meeting, + start_time: Time.zone.tomorrow + 10.hours, + frequency: "weekly", + end_after: "specific_date", + end_date: Time.zone.tomorrow + 4.weeks) + end + + it "schedules weekly", :aggregate_failures do + expect(subject.first_occurrence).to eq Time.zone.tomorrow + 10.hours + expect(subject.last_occurrence).to eq Time.zone.tomorrow + 4.weeks + 10.hours + + following_occurrence = Time.zone.tomorrow + 7.days + 10.hours + Timecop.freeze(Time.zone.tomorrow + 11.hours) do + expect(subject.next_occurrence).to eq following_occurrence + end + + next_occurrences = subject.scheduled_occurrences(limit: 5).map(&:to_time) + expect(next_occurrences).to eq [ + Time.zone.tomorrow + 10.hours, + Time.zone.tomorrow + 7.days + 10.hours, + Time.zone.tomorrow + 14.days + 10.hours, + Time.zone.tomorrow + 21.days + 10.hours, + Time.zone.tomorrow + 28.days + 10.hours + ] + + Timecop.freeze(Time.zone.tomorrow + 5.weeks) do + expect(subject.next_occurrence).to be_nil + end + end + end end From c9275c70ac70a0444836917fbe867d705f8fd039 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Mon, 2 Dec 2024 15:54:22 +0100 Subject: [PATCH 096/129] Fix project-based destroy --- modules/meeting/config/routes.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/meeting/config/routes.rb b/modules/meeting/config/routes.rb index 0b042d1f0c41..80a659f77d90 100644 --- a/modules/meeting/config/routes.rb +++ b/modules/meeting/config/routes.rb @@ -33,7 +33,7 @@ get "menu" => "meetings/menus#show" end end - resources :recurring_meetings, only: %i[index new create show] + resources :recurring_meetings, only: %i[index new create show destroy] end resources :work_packages, only: %i[] do From a8f2a69bc39c60ff856498623777fd414ed054e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Mon, 2 Dec 2024 15:58:10 +0100 Subject: [PATCH 097/129] Redirect to template, not show --- .../meeting/app/controllers/recurring_meetings_controller.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/modules/meeting/app/controllers/recurring_meetings_controller.rb b/modules/meeting/app/controllers/recurring_meetings_controller.rb index 42cde4121b72..92c759d326f8 100644 --- a/modules/meeting/app/controllers/recurring_meetings_controller.rb +++ b/modules/meeting/app/controllers/recurring_meetings_controller.rb @@ -79,7 +79,8 @@ def create if call.success? flash[:notice] = I18n.t(:notice_successful_create).html_safe - redirect_to status: :see_other, action: :show, id: call.result + redirect_to polymorphic_path([@project, :meeting], { id: call.result.template.id }), + status: :see_other else respond_to do |format| format.turbo_stream do From b26d144af89535b10a0501d6a8d4eb5e680a58d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Mon, 2 Dec 2024 19:11:03 +0100 Subject: [PATCH 098/129] Don't cache user time zone in request store --- lib_static/redmine/i18n.rb | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/lib_static/redmine/i18n.rb b/lib_static/redmine/i18n.rb index 3c7ec567f7fd..52dfcf29864d 100644 --- a/lib_static/redmine/i18n.rb +++ b/lib_static/redmine/i18n.rb @@ -125,7 +125,7 @@ def link_regex def format_time_as_date(time, format: nil) return nil unless time - local_date = in_current_user_zone(time).to_date + local_date = in_user_zone(time).to_date if format local_date.strftime(format) @@ -147,35 +147,29 @@ def format_time_as_date(time, format: nil) def format_time(time, include_date: true, format: Setting.time_format) return nil unless time - local = in_current_user_zone(time) + local = in_user_zone(time) (include_date ? "#{format_date(local)} " : "") + (format.blank? ? ::I18n.l(local, format: :time) : local.strftime(format)) end ## - # Formats the given time as a time string according to the +User.current+ time zone + # Formats the given time as a time string according to the +user+'s time zone # @param time [Time] The time to format. + # @param user [User] The user to use for the time zone. Defaults to +User.current+. # @return [Time] The time with the user's time zone applied. - def in_current_user_zone(time) - time.in_time_zone(current_user_time_zone) - end - - ## - # Returns the time zone of +User.current+, cached for the duration of the request - # @return [ActiveSupport::TimeZone] The time zone of the current user - def current_user_time_zone - RequestStore.fetch("current_user_time_zone") { User.current.time_zone } + def in_user_zone(time, user: User.current) + time.in_time_zone(user.time_zone) end # Returns the offset to UTC (with utc prepended) currently active # in the current users time zone. DST is factored in so the offset can # shift over the course of the year - def formatted_time_zone_offset + def formatted_time_zone_offset(user: User.current) # Doing User.current.time_zone and format that will not take heed of DST as it has no notion # of a current time. # https://github.com/rails/rails/issues/7297 - "UTC#{current_user_time_zone.now.formatted_offset}" + "UTC#{user.time_zone.now.formatted_offset}" end def day_name(day) From 9067c5e45aa51f6720ece86adb389fbf6f971726 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Mon, 2 Dec 2024 19:17:03 +0100 Subject: [PATCH 099/129] Fix forgotten invited where --- .../queries/meetings/filters/invited_user_filter.rb | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/modules/meeting/app/models/queries/meetings/filters/invited_user_filter.rb b/modules/meeting/app/models/queries/meetings/filters/invited_user_filter.rb index 906406a4fbd4..db80fb27f544 100644 --- a/modules/meeting/app/models/queries/meetings/filters/invited_user_filter.rb +++ b/modules/meeting/app/models/queries/meetings/filters/invited_user_filter.rb @@ -40,11 +40,10 @@ def type_strategy end def where - operator_strategy.sql_for_field( - values, - MeetingParticipant.table_name, - "user_id" - ) + [ + operator_strategy.sql_for_field(values, MeetingParticipant.table_name, "user_id"), + "#{MeetingParticipant.table_name}.invited" + ].join(" AND ") end def joins From af113dd0749d9ee332f0da82a5d27860d4f4e68a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Mon, 2 Dec 2024 19:25:03 +0100 Subject: [PATCH 100/129] fixup! Linting --- .../meeting/app/controllers/recurring_meetings_controller.rb | 2 +- .../app/workers/recurring_meetings/init_next_occurrence_job.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/meeting/app/controllers/recurring_meetings_controller.rb b/modules/meeting/app/controllers/recurring_meetings_controller.rb index 92c759d326f8..4465ceeb262b 100644 --- a/modules/meeting/app/controllers/recurring_meetings_controller.rb +++ b/modules/meeting/app/controllers/recurring_meetings_controller.rb @@ -72,7 +72,7 @@ def details_dialog ) end - def create + def create # rubocop:disable Metrics/AbcSize call = ::RecurringMeetings::CreateService .new(user: current_user) .call(@converted_params) diff --git a/modules/meeting/app/workers/recurring_meetings/init_next_occurrence_job.rb b/modules/meeting/app/workers/recurring_meetings/init_next_occurrence_job.rb index 612adc36410b..dd6be44930d6 100644 --- a/modules/meeting/app/workers/recurring_meetings/init_next_occurrence_job.rb +++ b/modules/meeting/app/workers/recurring_meetings/init_next_occurrence_job.rb @@ -78,7 +78,7 @@ def init_meeting Rails.logger.debug { "Initialized occurrence for series ##{recurring_meeting} at #{next_scheduled_time}" } end - call.on_failure do |call| + call.on_failure do Rails.logger.error do "Could not create next occurrence for series ##{recurring_meeting}: #{call.message}" end From 252566b7c98b32e1790652e7e3304927e7a4d105 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Mon, 2 Dec 2024 19:53:31 +0100 Subject: [PATCH 101/129] Remove default meeting order --- modules/meeting/app/models/queries/meetings/meeting_query.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/meeting/app/models/queries/meetings/meeting_query.rb b/modules/meeting/app/models/queries/meetings/meeting_query.rb index 4d01cc87ac29..55b39517f656 100644 --- a/modules/meeting/app/models/queries/meetings/meeting_query.rb +++ b/modules/meeting/app/models/queries/meetings/meeting_query.rb @@ -45,6 +45,7 @@ def default_scope .not_templated .not_cancelled .visible(user) + .unscope(:order) # remove default scope order end end end From 6815382c9b98ae3d23af71724f41c96acf0da644 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Mon, 2 Dec 2024 19:53:40 +0100 Subject: [PATCH 102/129] Fix meetings index spec with new ordering --- .../spec/features/meetings_index_spec.rb | 42 ++++++++----------- .../spec/support/pages/meetings/index.rb | 12 ++++++ 2 files changed, 29 insertions(+), 25 deletions(-) diff --git a/modules/meeting/spec/features/meetings_index_spec.rb b/modules/meeting/spec/features/meetings_index_spec.rb index 8b41961492d5..d85160fe7d6c 100644 --- a/modules/meeting/spec/features/meetings_index_spec.rb +++ b/modules/meeting/spec/features/meetings_index_spec.rb @@ -109,17 +109,18 @@ def invite_to_meeting(meeting) end shared_examples "sidebar filtering" do |context:| - context "when filtering with the sidebar" do + context "when showing all meetings with the sidebar" do before do ongoing_meeting other_project_meeting setup_meeting_involvement meetings_page.visit! + meetings_page.set_sidebar_filter "All meetings" end - context 'with the "Upcoming meetings" filter' do + context 'with the "Upcoming meetings" quick filter' do before do - meetings_page.set_sidebar_filter "Upcoming meetings" + meetings_page.set_quick_filter upcoming: true end it "shows all upcoming and ongoing meetings", :aggregate_failures do @@ -134,48 +135,39 @@ def invite_to_meeting(meeting) end end - context 'with the "Past meetings" filter' do + context 'with the "Past meetings" quick filter' do before do - meetings_page.set_sidebar_filter "Past meetings" + meetings_page.set_quick_filter upcoming: false end - it "show all past and ongoing meetings" do - meetings_page.expect_meetings_listed_in_order(ongoing_meeting, - yesterdays_meeting) - meetings_page.expect_meetings_not_listed(meeting, - tomorrows_meeting) + it "show all past meetings" do + meetings_page.expect_meetings_listed(yesterdays_meeting) + meetings_page.expect_meetings_not_listed(meeting, tomorrows_meeting) end end - context 'with the "Upcoming invitations" filter' do + context 'with the "Invitations" filter' do before do - meetings_page.set_sidebar_filter "Upcoming invitations" + meetings_page.set_sidebar_filter "Invitations" end - it "shows all upcoming meetings I've been marked as invited to" do + it "shows all meetings I've been marked as invited to with a quick filter" do meetings_page.expect_meetings_listed(tomorrows_meeting) meetings_page.expect_meetings_not_listed(yesterdays_meeting, meeting, ongoing_meeting) - end - end - context 'with the "Past invitations" filter' do - before do - meetings_page.set_sidebar_filter "Past invitations" - end + meetings_page.set_quick_filter upcoming: false - it "shows all past meetings I've been marked as invited to" do meetings_page.expect_meetings_listed(yesterdays_meeting) - meetings_page.expect_meetings_not_listed(ongoing_meeting, - meeting, - tomorrows_meeting) + + meetings_page.expect_meetings_not_listed(meeting, tomorrows_meeting) end end context 'with the "Attendee" filter' do before do - meetings_page.set_sidebar_filter "Attendee" + meetings_page.set_sidebar_filter "Attended" end it "shows all meetings I've been marked as attending to" do @@ -188,7 +180,7 @@ def invite_to_meeting(meeting) context 'with the "Creator" filter' do before do - meetings_page.set_sidebar_filter "Creator" + meetings_page.set_sidebar_filter "Created by me" end it "shows all meetings I'm the author of" do diff --git a/modules/meeting/spec/support/pages/meetings/index.rb b/modules/meeting/spec/support/pages/meetings/index.rb index 0cf0026e3f62..40fcfdf69d41 100644 --- a/modules/meeting/spec/support/pages/meetings/index.rb +++ b/modules/meeting/spec/support/pages/meetings/index.rb @@ -141,6 +141,18 @@ def set_sidebar_filter(filter_name) submenu.click_item(filter_name) end + def set_quick_filter(upcoming: true) + page.within("#content-body") do + if upcoming + click_link_or_button "Upcoming" + else + click_link_or_button "Past" + end + end + + wait_for_network_idle + end + def expect_no_meetings_listed within "#content-wrapper" do expect(page) From 89feef5e5200dce7c235ea5a98cde7becf1b1731 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Mon, 2 Dec 2024 19:59:16 +0100 Subject: [PATCH 103/129] Fix path --- modules/meeting/app/controllers/meetings_controller.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/meeting/app/controllers/meetings_controller.rb b/modules/meeting/app/controllers/meetings_controller.rb index ef6aee2a8665..a3adbf421b67 100644 --- a/modules/meeting/app/controllers/meetings_controller.rb +++ b/modules/meeting/app/controllers/meetings_controller.rb @@ -190,9 +190,9 @@ def destroy # rubocop:enable Rails/ActionControllerFlashBeforeRender if recurring - redirect_to polymorphic_path([@project, recurring], status: :see_other) + redirect_to polymorphic_path([@project, recurring]), status: :see_other else - redirect_to polymorphic_path([@project, :meetings], status: :see_other) + redirect_to polymorphic_path([@project, :meetings]), status: :see_other end end From 00cf518a926edbb08918accbbdd25bbf3c9fd863 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Mon, 2 Dec 2024 20:05:23 +0100 Subject: [PATCH 104/129] Convert meeting tab to cuprite --- .../work_package_meetings_tab_spec.rb | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/modules/meeting/spec/features/structured_meetings/work_package_meetings_tab_spec.rb b/modules/meeting/spec/features/structured_meetings/work_package_meetings_tab_spec.rb index a853bd2e4e0e..a6f54cfae1d3 100644 --- a/modules/meeting/spec/features/structured_meetings/work_package_meetings_tab_spec.rb +++ b/modules/meeting/spec/features/structured_meetings/work_package_meetings_tab_spec.rb @@ -30,7 +30,9 @@ require_relative "../../support/pages/work_package_meetings_tab" require_relative "../../support/pages/structured_meeting/show" -RSpec.describe "Open the Meetings tab", :js do +RSpec.describe "Open the Meetings tab", + :js, + :with_cuprite do shared_let(:project) { create(:project) } shared_let(:work_package) { create(:work_package, project:, subject: "A test work_package") } @@ -406,11 +408,11 @@ meetings_tab.open_add_to_meeting_dialog - retry_block do - click_on("Save") + click_on("Save") - expect(page).to have_content("Meeting can't be blank") - end + wait_for_network_idle + + expect(page).to have_content("Meeting can't be blank") end it "adds presenter when the work package is added to a meeting" do From 928a7abc6c3df14bd7dc27470b49e0a4a1bada2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Mon, 2 Dec 2024 20:09:29 +0100 Subject: [PATCH 105/129] Fix expectation for meeting tab --- .../work_package_meetings_tab_controller.rb | 2 +- .../work_package_meetings_tab_spec.rb | 24 ++++++++++--------- .../pages/work_package_meetings_tab.rb | 10 ++++++-- 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/modules/meeting/app/controllers/work_package_meetings_tab_controller.rb b/modules/meeting/app/controllers/work_package_meetings_tab_controller.rb index 7a8e8825928a..5dfe89179aac 100644 --- a/modules/meeting/app/controllers/work_package_meetings_tab_controller.rb +++ b/modules/meeting/app/controllers/work_package_meetings_tab_controller.rb @@ -130,7 +130,7 @@ def get_grouped_agenda_items(direction) def get_agenda_items_of_work_package(direction) agenda_items = MeetingAgendaItem .includes(:meeting) - .where(meeting_id: Meeting.visible(current_user)) + .where(meeting_id: Meeting.not_templated.visible(current_user)) .where(work_package_id: @work_package.id) .reorder(sort_clause(direction)) diff --git a/modules/meeting/spec/features/structured_meetings/work_package_meetings_tab_spec.rb b/modules/meeting/spec/features/structured_meetings/work_package_meetings_tab_spec.rb index a6f54cfae1d3..64dd35199996 100644 --- a/modules/meeting/spec/features/structured_meetings/work_package_meetings_tab_spec.rb +++ b/modules/meeting/spec/features/structured_meetings/work_package_meetings_tab_spec.rb @@ -341,11 +341,10 @@ meetings_tab.fill_and_submit_meeting_dialog( first_upcoming_meeting, - "A very important note added from the meetings tab to the first meeting!" + "A very important note added from the meetings tab to the first meeting!", + 1 ) - meetings_tab.expect_upcoming_counter_to_be(1) - page.within_test_selector("op-meeting-container-#{first_upcoming_meeting.id}") do expect(page).to have_content("A very important note added from the meetings tab to the first meeting!") end @@ -354,11 +353,10 @@ meetings_tab.fill_and_submit_meeting_dialog( second_upcoming_meeting, - "A very important note added from the meetings tab to the second meeting!" + "A very important note added from the meetings tab to the second meeting!", + 2 ) - meetings_tab.expect_upcoming_counter_to_be(2) - page.within_test_selector("op-meeting-container-#{second_upcoming_meeting.id}") do expect(page).to have_content("A very important note added from the meetings tab to the second meeting!") end @@ -372,7 +370,8 @@ meetings_tab.fill_and_submit_meeting_dialog( ongoing_meeting, - "Some notes to be added" + "Some notes to be added", + 1 ) meetings_tab.expect_upcoming_counter_to_be(1) @@ -408,11 +407,13 @@ meetings_tab.open_add_to_meeting_dialog - click_on("Save") + retry_block do + click_on("Save") - wait_for_network_idle + wait_for_network_idle - expect(page).to have_content("Meeting can't be blank") + raise "Expected error message to be shown" unless page.has_content?("Meeting can't be blank") + end end it "adds presenter when the work package is added to a meeting" do @@ -423,7 +424,8 @@ meetings_tab.fill_and_submit_meeting_dialog( first_upcoming_meeting, - "A very important note added from the meetings tab to the first meeting!" + "A very important note added from the meetings tab to the first meeting!", + 1 ) meeting_page.visit! diff --git a/modules/meeting/spec/support/pages/work_package_meetings_tab.rb b/modules/meeting/spec/support/pages/work_package_meetings_tab.rb index 118b7b9a53ee..8b2c49ae2396 100644 --- a/modules/meeting/spec/support/pages/work_package_meetings_tab.rb +++ b/modules/meeting/spec/support/pages/work_package_meetings_tab.rb @@ -98,13 +98,19 @@ def open_add_to_meeting_dialog page.find_test_selector("op-add-work-package-to-meeting-dialog-trigger").click end - def fill_and_submit_meeting_dialog(meeting, notes) + def fill_and_submit_meeting_dialog(meeting, notes, counter) fill_in("meeting_agenda_item_meeting_id", with: meeting.title) expect(page).to have_css(".ng-option-marked", text: meeting.title) # wait for selection page.find(".ng-option-marked").click page.find(".ck-editor__editable").set(notes) - click_on("Save") + retry_block do + click_on("Save") + + page.within_test_selector("op-upcoming-meetings-counter") do + raise "Expected counter to eq #{counter}" unless page.has_content?(counter) + end + end end private From 39a46f077746c716df5a59d2416b6bb1d8a62cff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Mon, 2 Dec 2024 21:36:50 +0100 Subject: [PATCH 106/129] More robust selector on meeting tab --- .../work_package_meetings_tab_spec.rb | 8 +++++--- .../spec/support/pages/work_package_meetings_tab.rb | 10 +++++----- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/modules/meeting/spec/features/structured_meetings/work_package_meetings_tab_spec.rb b/modules/meeting/spec/features/structured_meetings/work_package_meetings_tab_spec.rb index 64dd35199996..9588d4ba53d2 100644 --- a/modules/meeting/spec/features/structured_meetings/work_package_meetings_tab_spec.rb +++ b/modules/meeting/spec/features/structured_meetings/work_package_meetings_tab_spec.rb @@ -258,9 +258,11 @@ expect(page).to have_content(meeting_agenda_item_of_second_meeting.notes) end - meeting_containers = page.all("[data-test-selector^='op-meeting-container-']") - expect(meeting_containers[0]["data-test-selector"]).to eq("op-meeting-container-#{first_meeting.id}") - expect(meeting_containers[1]["data-test-selector"]).to eq("op-meeting-container-#{second_meeting.id}") + meeting_containers = page + .all("[data-test-selector^='op-meeting-container-']") + .map { |container| container["data-test-selector"] } + expect(meeting_containers).to contain_exactly("op-meeting-container-#{first_meeting.id}", + "op-meeting-container-#{second_meeting.id}") end end diff --git a/modules/meeting/spec/support/pages/work_package_meetings_tab.rb b/modules/meeting/spec/support/pages/work_package_meetings_tab.rb index 8b2c49ae2396..bf081bafa8cb 100644 --- a/modules/meeting/spec/support/pages/work_package_meetings_tab.rb +++ b/modules/meeting/spec/support/pages/work_package_meetings_tab.rb @@ -99,12 +99,12 @@ def open_add_to_meeting_dialog end def fill_and_submit_meeting_dialog(meeting, notes, counter) - fill_in("meeting_agenda_item_meeting_id", with: meeting.title) - expect(page).to have_css(".ng-option-marked", text: meeting.title) # wait for selection - page.find(".ng-option-marked").click - page.find(".ck-editor__editable").set(notes) - retry_block do + fill_in("meeting_agenda_item_meeting_id", with: meeting.title) + page.find(".ng-option-marked", text: meeting.title) # wait for selection + page.find(".ng-option-marked").click + page.find(".ck-editor__editable").set(notes) + click_on("Save") page.within_test_selector("op-upcoming-meetings-counter") do From 4ea6130a6ebcb477b3d5f48c5609bb34634ab53f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Mon, 2 Dec 2024 21:38:18 +0100 Subject: [PATCH 107/129] Change global menu spec --- .../meeting/spec/features/meetings_global_menu_item_spec.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/meeting/spec/features/meetings_global_menu_item_spec.rb b/modules/meeting/spec/features/meetings_global_menu_item_spec.rb index f77ab30f3cbd..85af3945cc1e 100644 --- a/modules/meeting/spec/features/meetings_global_menu_item_spec.rb +++ b/modules/meeting/spec/features/meetings_global_menu_item_spec.rb @@ -57,9 +57,9 @@ expect(page).to have_current_path("/meetings") end - specify '"Upcoming invitations" is the default filter set' do + specify '"My meetings" is the default filter set' do within "#main-menu" do - expect(page).to have_css(".selected", text: I18n.t(:label_upcoming_invitations)) + expect(page).to have_css(".selected", text: "My meetings") end end end From e18b67507acdad1d3c44c9f702dd96e1ff785d62 Mon Sep 17 00:00:00 2001 From: Mir Bhatia Date: Tue, 3 Dec 2024 16:19:37 +0100 Subject: [PATCH 108/129] Add more CRUD specs --- .../recurring_meeting_crud_spec.rb | 101 +++++++++++++----- .../support/pages/recurring_meeting/show.rb | 66 +++++++++++- 2 files changed, 137 insertions(+), 30 deletions(-) diff --git a/modules/meeting/spec/features/recurring_meetings/recurring_meeting_crud_spec.rb b/modules/meeting/spec/features/recurring_meetings/recurring_meeting_crud_spec.rb index d5c74e11a65a..fc18b2c3e471 100644 --- a/modules/meeting/spec/features/recurring_meetings/recurring_meeting_crud_spec.rb +++ b/modules/meeting/spec/features/recurring_meetings/recurring_meeting_crud_spec.rb @@ -28,6 +28,7 @@ require "spec_helper" +require_relative "../../support/pages/meetings/new" require_relative "../../support/pages/structured_meeting/show" require_relative "../../support/pages/recurring_meeting/show" require_relative "../../support/pages/meetings/index" @@ -68,76 +69,122 @@ expect(page).to have_current_path(meetings_page.path) # rubocop:disable RSpec/ExpectInHook meetings_page.click_on "add-meeting-button" meetings_page.click_on "Recurring" + + sleep 1 # flaky without + meetings_page.set_title "Some title" meetings_page.set_start_date "2024-12-31" meetings_page.set_start_time "13:30" meetings_page.set_duration "1.5" - meetings_page.set_end_date "2025-01-02" + meetings_page.set_end_date "2025-01-15" click_on "Create meeting" - wait_for_network_idle - - meeting = RecurringMeeting.last - - Pages::RecurringMeeting::Show.new(meeting) + wait_for_reload + perform_enqueued_jobs end it "can create a recurring meeting", with_flag: { recurring_meetings: true } do expect_flash(type: :success, message: "Successful creation.") # Does not send invitation mails by default - perform_enqueued_jobs expect(ActionMailer::Base.deliveries.size).to eq 0 + show_page.visit! + expect(page).to have_css(".start_time", count: 3) - show_page.expect_open_meeting date: "2024-12-31 01:30 PM" - show_page.expect_scheduled_meeting date: "2024-01-01 01:30 PM" - show_page.expect_scheduled_meeting date: "2024-01-02 01:30 PM" + show_page.expect_open_meeting date: "12/31/2024 01:30 PM" + show_page.expect_scheduled_meeting date: "01/07/2025 01:30 PM" + show_page.expect_scheduled_meeting date: "01/14/2025 01:30 PM" end - it "can delete a recurring meeting", with_flag: { recurring_meetings: true } do + it "can delete a recurring meeting from the show page and return to the index page", with_flag: { recurring_meetings: true } do + show_page.visit! + click_on "action-menu" accept_confirm(I18n.t("text_are_you_sure")) do click_on "Delete meeting series" end - expect(page).to have_current_path project_meetings_path(project) + expect(page).to have_current_path meetings_path # check path end it "can use the 'Create from template' button", with_flag: { recurring_meetings: true } do - show_page.create_from_template date: "2024-01-01 01:30 PM" + show_page.visit! - expect(page).to have_current_path meeting_path(Meeting.last) + show_page.create_from_template date: "01/07/2025 01:30 PM" + wait_for_reload + + expect(page).to have_current_path project_meeting_path(project, Meeting.reorder(id: :asc).last) show_page.visit! - show_page.expect_no_scheduled_meeting date: "2024-01-01 01:30 PM" - show_page.expect_open_meeting date: "2024-01-01 01:30 PM" + show_page.expect_no_scheduled_meeting date: "01/07/2025 01:30 PM" + show_page.expect_open_meeting date: "01/07/2025 01:30 PM" end - it "can cancel an occurrence", with_flag: { recurring_meetings: true } do + xit "can cancel an occurrence", with_flag: { recurring_meetings: true } do # rubocop:disable RSpec/PendingWithoutReason + show_page.visit! + accept_confirm(I18n.t("text_are_you_sure")) do - show_page.cancel_occurrence date: "2024-12-31 01:30 PM" + show_page.cancel_occurrence date: "12/31/2024 01:30 PM" end expect_flash(type: :success, message: "Successful deletion.") - expect(page).to have_current_path recurring_meeting_path(meeting) + expect(page).to have_current_path(show_page.path) # check path + + show_page.expect_no_open_meeting date: "12/31/2024 01:30 PM" + show_page.expect_cancelled_meeting date: "12/31/2024 01:30 PM" + end + + xit "can edit the details of a recurring meeting", with_flag: { recurring_meetings: true } do # rubocop:disable RSpec/PendingWithoutReason + show_page.visit! + + show_page.expect_subtitle text: "Weekly on Tuesday at 01:30 PM, ends on 01/15/2025" + + show_page.edit_meeting_series + show_page.in_edit_dialog do + page.select("Daily", from: "Frequency") + meetings_page.set_start_time "11:00" + page.select("A number of occurrences", from: "End after") + page.fill_in("Occurrences", with: "8") + + click_on("Save") + end - show_page.expect_no_open_meeting date: "2024-12-31 01:30 PM" - show_page.expect_cancelled_meeting date: "2024-12-31 01:30 PM" + show_page.refresh # check refresh + show_page.expect_subtitle text: "Daily at 11:00 AM, ends on 01/07/2025" end - # it "can edit the details of a recurring meeting", with_flag: { recurring_meetings: true } do - # - # end + it "shows the correct actions based on status", with_flag: { recurring_meetings: true } do + show_page.visit! + + show_page.expect_open_meeting date: "12/31/2024 01:30 PM" + show_page.expect_open_actions date: "12/31/2024 01:30 PM" - # it "shows the correct actions based on status", with_flag: { recurring_meetings: true } do - # - # end + show_page.expect_scheduled_meeting date: "01/07/2025 01:30 PM" + show_page.expect_scheduled_actions date: "01/07/2025 01:30 PM" + + accept_confirm(I18n.t("text_are_you_sure")) do + show_page.cancel_occurrence date: "12/31/2024 01:30 PM" + end + + show_page.expect_cancelled_meeting date: "12/31/2024 01:30 PM" + show_page.expect_cancelled_actions date: "12/31/2024 01:30 PM" + end + + it "shows rescheduled occurrences", with_flag: { recurring_meetings: true } do + last = Meeting.reorder(id: :asc).last + last.start_time += 1.day + last.save! + + show_page.visit! + + show_page.expect_rescheduled_meeting old_date: "12/31/2024 01:30 PM", new_date: "01/01/2025 01:30 PM" + end end diff --git a/modules/meeting/spec/support/pages/recurring_meeting/show.rb b/modules/meeting/spec/support/pages/recurring_meeting/show.rb index 1af16e11a40e..1e02ec016771 100644 --- a/modules/meeting/spec/support/pages/recurring_meeting/show.rb +++ b/modules/meeting/spec/support/pages/recurring_meeting/show.rb @@ -26,11 +26,21 @@ # See COPYRIGHT and LICENSE files for more details. #++ -# require_relative "../meetings/show" +require_relative "../meetings/base" module Pages::RecurringMeeting - class Show < ::Pages::Meetings::Show - include ::Components::Autocompleter::NgSelectAutocompleteHelpers + class Show < ::Pages::Meetings::Base + attr_accessor :meeting + + def initialize(meeting) + super + + self.meeting = meeting + end + + def path + recurring_meeting_path(meeting) + end def expect_scheduled_meeting(date:) within("li", text: date) do @@ -62,6 +72,13 @@ def expect_cancelled_meeting(date:) end end + def expect_rescheduled_meeting(old_date:, new_date:) + within("li", text: old_date) do + expect(page).to have_css("s", text: old_date) + expect(page).to have_text("#{old_date}\n#{new_date}") + end + end + def create_from_template(date:) within("li", text: date) do click_on "Create from template" @@ -75,6 +92,49 @@ def cancel_occurrence(date:) end end + def expect_subtitle(text:) + expect(page).to have_css(".PageHeader-description", text: text) + end + + def edit_meeting_series + page.find_test_selector("action-menu").click + click_on "Edit meeting series" + + expect(page).to have_css("#new-meeting-dialog") + end + + def in_edit_dialog(&) + page.within("#new-meeting-dialog", &) + end + + def expect_open_actions(date:) + within("li", text: date) do + click_on "more-button" + + expect(page).to have_css(".ActionListItem-label", count: 2) + expect(page).to have_css(".ActionListItem-label", text: "Download iCalendar event") + expect(page).to have_css(".ActionListItem-label", text: "Cancel this occurrence") + end + end + + def expect_scheduled_actions(date:) + within("li", text: date) do + click_on "more-button" + + expect(page).to have_css(".ActionListItem-label", count: 1) + expect(page).to have_css(".ActionListItem-label", text: "Download iCalendar event") + end + end + + def expect_cancelled_actions(date:) + within("li", text: date) do + click_on "more-button" + + expect(page).to have_css(".ActionListItem-label", count: 1) + expect(page).to have_css(".ActionListItem-label", text: "Restore this occurrence") + end + end + # def for_meeting(date:, &) # within("li", text: date, &) # end From 41e89174b554ac5219e8127ab6bc796614c124ba Mon Sep 17 00:00:00 2001 From: Mir Bhatia Date: Tue, 3 Dec 2024 16:20:29 +0100 Subject: [PATCH 109/129] Fix dates and times --- .../app/components/recurring_meetings/row_component.rb | 4 ++-- modules/meeting/app/models/recurring_meeting.rb | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/meeting/app/components/recurring_meetings/row_component.rb b/modules/meeting/app/components/recurring_meetings/row_component.rb index 82f569ecffc7..eaf90ce52d01 100644 --- a/modules/meeting/app/components/recurring_meetings/row_component.rb +++ b/modules/meeting/app/components/recurring_meetings/row_component.rb @@ -57,7 +57,7 @@ def start_time end def user_time_zone(time) - helpers.in_current_user_zone(time) + helpers.in_user_zone(time) end def formatted_time(time) @@ -70,7 +70,7 @@ def old_time def start_time_title if start_time_changed? - "#{old_time}\n#{formatted_time(meeting.start_time)}".html_safe # rubocop:disable Rails/OutputSafety + old_time + simple_format("\n#{formatted_time(meeting.start_time)}") else formatted_time(model.start_time) end diff --git a/modules/meeting/app/models/recurring_meeting.rb b/modules/meeting/app/models/recurring_meeting.rb index 42e3dbab629e..ad71950ff323 100644 --- a/modules/meeting/app/models/recurring_meeting.rb +++ b/modules/meeting/app/models/recurring_meeting.rb @@ -96,7 +96,7 @@ def schedule_in_words # rubocop:disable Metrics/AbcSize I18n.t("recurring_meeting.in_words.full", base:, time: format_time(start_time, include_date: false), - end_date: format_date(end_date)) + end_date: format_date(last_occurrence)) end def scheduled_occurrences(limit:) From 515f923e10d87d938e6aa72de12d2ea20f64e716 Mon Sep 17 00:00:00 2001 From: Mir Bhatia Date: Tue, 3 Dec 2024 20:02:24 +0100 Subject: [PATCH 110/129] Allow cancellation of scheduled meetings --- config/locales/en.yml | 1 + .../recurring_meetings/row_component.rb | 19 ++++++++++++++- .../app/controllers/meetings_controller.rb | 2 +- .../recurring_meetings_controller.rb | 23 +++++++++++++++++-- modules/meeting/config/routes.rb | 1 + .../lib/open_project/meeting/engine.rb | 2 +- 6 files changed, 43 insertions(+), 5 deletions(-) diff --git a/config/locales/en.yml b/config/locales/en.yml index 8a1f7549b356..37447e7021fb 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -3094,6 +3094,7 @@ en: notice_successful_connection: "Successful connection." notice_successful_create: "Successful creation." notice_successful_delete: "Successful deletion." + notice_successful_cancel: "Successful cancellation." notice_successful_update: "Successful update." notice_successful_update_custom_fields_added_to_project: | Successful update. The custom fields of the activated types are automatically activated diff --git a/modules/meeting/app/components/recurring_meetings/row_component.rb b/modules/meeting/app/components/recurring_meetings/row_component.rb index eaf90ce52d01..e377d3328d50 100644 --- a/modules/meeting/app/components/recurring_meetings/row_component.rb +++ b/modules/meeting/app/components/recurring_meetings/row_component.rb @@ -118,7 +118,7 @@ def status_scheme(state) end def create - return if instantiated? + return if instantiated? || cancelled? render( Primer::Beta::Button.new( @@ -148,6 +148,10 @@ def action_menu "test-selector": "more-button" }) + if delete_allowed? && !cancelled? && !instantiated? + delete_scheduled_action(menu) + end + if instantiated? && !cancelled? ical_action(menu) @@ -185,6 +189,19 @@ def delete_action(menu) end end + def delete_scheduled_action(menu) + menu.with_item( + label: I18n.t(:label_recurring_meeting_cancel), + scheme: :danger, + href: delete_scheduled_recurring_meeting_path(model.recurring_meeting.id, start_time: model.start_time.iso8601), + form_arguments: { + method: :post, data: { confirm: I18n.t("text_are_you_sure"), turbo: false } + } + ) do |item| + item.with_leading_visual_icon(icon: :trash) + end + end + def restore_action(menu) menu.with_item( label: I18n.t(:label_recurring_meeting_restore), diff --git a/modules/meeting/app/controllers/meetings_controller.rb b/modules/meeting/app/controllers/meetings_controller.rb index a3adbf421b67..e54828ab1276 100644 --- a/modules/meeting/app/controllers/meetings_controller.rb +++ b/modules/meeting/app/controllers/meetings_controller.rb @@ -185,7 +185,7 @@ def destroy Meetings::DeleteService .new(model: @meeting, user: User.current) .call - .on_success { flash[:notice] = I18n.t(:notice_successful_delete) } + .on_success { flash[:notice] = recurring ? I18n.t(:notice_successful_cancel) : I18n.t(:notice_successful_delete) } .on_failure { |call| flash[:error] = call.message } # rubocop:enable Rails/ActionControllerFlashBeforeRender diff --git a/modules/meeting/app/controllers/recurring_meetings_controller.rb b/modules/meeting/app/controllers/recurring_meetings_controller.rb index 4465ceeb262b..eeb149147653 100644 --- a/modules/meeting/app/controllers/recurring_meetings_controller.rb +++ b/modules/meeting/app/controllers/recurring_meetings_controller.rb @@ -5,8 +5,8 @@ class RecurringMeetingsController < ApplicationController include OpTurbo::FlashStreamHelper include OpTurbo::DialogStreamHelper - before_action :find_meeting, only: %i[show update details_dialog destroy edit init] - before_action :find_optional_project, only: %i[index show new create update details_dialog destroy edit] + before_action :find_meeting, only: %i[show update details_dialog destroy edit init delete_scheduled] + before_action :find_optional_project, only: %i[index show new create update details_dialog destroy edit delete_scheduled] before_action :authorize_global, only: %i[index new create] before_action :authorize, except: %i[index new create] @@ -141,6 +141,25 @@ def destroy end end + def delete_scheduled # rubocop:disable Metrics/AbcSize + call = ::RecurringMeetings::InitOccurrenceService + .new(user: current_user, recurring_meeting: @recurring_meeting) + .call(start_time: DateTime.iso8601(params[:start_time])) + + if call.success? + ::Meetings::DeleteService + .new(model: call.result, user: current_user) + .call + .on_success { flash[:notice] = I18n.t(:notice_successful_cancel) } + .on_failure { |delete_call| flash[:error] = delete_call.message } + + redirect_to polymorphic_path([@project, @recurring_meeting]), status: :see_other + else + flash[:error] = call.message + redirect_to action: :show, id: @recurring_meeting + end + end + private def upcoming_meetings diff --git a/modules/meeting/config/routes.rb b/modules/meeting/config/routes.rb index 80a659f77d90..3be786f35dcc 100644 --- a/modules/meeting/config/routes.rb +++ b/modules/meeting/config/routes.rb @@ -60,6 +60,7 @@ member do get :details_dialog post :init + post :delete_scheduled end end diff --git a/modules/meeting/lib/open_project/meeting/engine.rb b/modules/meeting/lib/open_project/meeting/engine.rb index 10ec308ba895..a4fb9f2bf15b 100644 --- a/modules/meeting/lib/open_project/meeting/engine.rb +++ b/modules/meeting/lib/open_project/meeting/engine.rb @@ -67,7 +67,7 @@ class Engine < ::Rails::Engine permission :delete_meetings, { meetings: [:destroy], - recurring_meetings: [:destroy] + recurring_meetings: %i[destroy delete_scheduled] }, permissible_on: :project, require: :member From 8f698c09463e5ed1ee81d3e5147bc10fbd434a8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 4 Dec 2024 07:58:19 +0100 Subject: [PATCH 111/129] Lint --- .../meeting/app/components/recurring_meetings/row_component.rb | 2 +- modules/meeting/app/controllers/meetings_controller.rb | 2 +- .../structured_meetings/work_package_meetings_tab_spec.rb | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/meeting/app/components/recurring_meetings/row_component.rb b/modules/meeting/app/components/recurring_meetings/row_component.rb index e377d3328d50..44121384e465 100644 --- a/modules/meeting/app/components/recurring_meetings/row_component.rb +++ b/modules/meeting/app/components/recurring_meetings/row_component.rb @@ -139,7 +139,7 @@ def button_links ] end - def action_menu + def action_menu # rubocop:disable Metrics/AbcSize render(Primer::Alpha::ActionMenu.new) do |menu| menu.with_show_button(icon: "kebab-horizontal", "aria-label": "More", diff --git a/modules/meeting/app/controllers/meetings_controller.rb b/modules/meeting/app/controllers/meetings_controller.rb index e54828ab1276..b708fac84c00 100644 --- a/modules/meeting/app/controllers/meetings_controller.rb +++ b/modules/meeting/app/controllers/meetings_controller.rb @@ -178,7 +178,7 @@ def copy end end - def destroy + def destroy # rubocop:disable Metrics/AbcSize recurring = @meeting.recurring_meeting # rubocop:disable Rails/ActionControllerFlashBeforeRender diff --git a/modules/meeting/spec/features/structured_meetings/work_package_meetings_tab_spec.rb b/modules/meeting/spec/features/structured_meetings/work_package_meetings_tab_spec.rb index 9588d4ba53d2..cef6c8721316 100644 --- a/modules/meeting/spec/features/structured_meetings/work_package_meetings_tab_spec.rb +++ b/modules/meeting/spec/features/structured_meetings/work_package_meetings_tab_spec.rb @@ -260,7 +260,7 @@ meeting_containers = page .all("[data-test-selector^='op-meeting-container-']") - .map { |container| container["data-test-selector"] } + .map { |container| container["data-test-selector"] } # rubocop:disable Rails/Pluck expect(meeting_containers).to contain_exactly("op-meeting-container-#{first_meeting.id}", "op-meeting-container-#{second_meeting.id}") end From a0f9d2e4967f6f0c68c036bc7ccba503085e4e57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 4 Dec 2024 09:15:41 +0100 Subject: [PATCH 112/129] Reschedule init job when updating schedule --- .../recurring_meetings/update_service.rb | 23 +++++++++++ .../init_next_occurrence_job.rb | 6 ++- .../update_service_integration_spec.rb | 38 ++++++++++++++++++- 3 files changed, 65 insertions(+), 2 deletions(-) diff --git a/modules/meeting/app/services/recurring_meetings/update_service.rb b/modules/meeting/app/services/recurring_meetings/update_service.rb index b7ae23888e73..80fcf24e97de 100644 --- a/modules/meeting/app/services/recurring_meetings/update_service.rb +++ b/modules/meeting/app/services/recurring_meetings/update_service.rb @@ -36,6 +36,7 @@ def after_perform(call) return call unless call.success? cleanup_cancelled_schedules(call.result) + reschedule_init_job(call.result) update_template(call) end @@ -59,5 +60,27 @@ def cleanup_cancelled_schedules(recurring_meeting) scheduled.delete unless occurring end end + + def reschedule_init_job(recurring_meeting) + return unless should_reschedule?(recurring_meeting) + + concurrency_key = InitNextOccurrenceJob.unique_key(recurring_meeting) + + # Delete all scheduled jobs for this meeting + GoodJob::Job.where(finished_at: nil, concurrency_key:).delete_all + + InitNextOccurrenceJob + .set(wait_until: recurring_meeting.next_occurrence.to_time) + .perform_later(recurring_meeting) + end + + def should_reschedule?(recurring_meeting) + return false if recurring_meeting.next_occurrence.nil? + + recurring_meeting + .previous_changes + .keys + .intersect?(%w[frequency start_date start_time start_time_hour iterations interval end_after end_date]) + end end end diff --git a/modules/meeting/app/workers/recurring_meetings/init_next_occurrence_job.rb b/modules/meeting/app/workers/recurring_meetings/init_next_occurrence_job.rb index dd6be44930d6..8d818cad011e 100644 --- a/modules/meeting/app/workers/recurring_meetings/init_next_occurrence_job.rb +++ b/modules/meeting/app/workers/recurring_meetings/init_next_occurrence_job.rb @@ -32,9 +32,13 @@ class InitNextOccurrenceJob < ApplicationJob good_job_control_concurrency_with( perform_limit: 1, - key: -> { "#{self.class.name}-#{queue_name}-#{arguments.first.id}" } + key: -> { self.class.unique_key(arguments.first) } ) + def self.unique_key(recurring_meeting) + "RecurringMeetings::InitNextOccurrenceJob-#{recurring_meeting.id}" + end + attr_accessor :recurring_meeting def perform(recurring_meeting) diff --git a/modules/meeting/spec/services/recurring_meetings/update_service_integration_spec.rb b/modules/meeting/spec/services/recurring_meetings/update_service_integration_spec.rb index ff3a69ae6160..11aab02ae46e 100644 --- a/modules/meeting/spec/services/recurring_meetings/update_service_integration_spec.rb +++ b/modules/meeting/spec/services/recurring_meetings/update_service_integration_spec.rb @@ -33,7 +33,7 @@ shared_let(:user) do create(:user, member_with_permissions: { project => %i(view_meetings edit_meetings) }) end - shared_let(:series) do + shared_let(:series, refind: true) do create(:recurring_meeting, project:, start_time: Time.zone.today + 10.hours, @@ -83,4 +83,40 @@ end end end + + describe "rescheduling job" do + context "when updating the title" do + let(:params) do + { title: "New title" } + end + + it "does not reschedule" do + expect { service_result }.not_to have_enqueued_job(RecurringMeetings::InitNextOccurrenceJob) + expect(service_result).to be_success + end + end + + context "when updating the frequency and start_time" do + let(:params) do + { start_time: Time.zone.today + 2.days + 11.hours } + end + + before do + ActiveJob::Base.disable_test_adapter + RecurringMeetings::InitNextOccurrenceJob + .set(wait_until: Time.zone.today + 1.day + 10.hours) + .perform_later(series) + end + + it "reschedules" do + job = GoodJob::Job.find_by(job_class: "RecurringMeetings::InitNextOccurrenceJob") + expect(job.scheduled_at).to eq Time.zone.today + 1.day + 10.hours + expect(service_result).to be_success + expect { job.reload }.to raise_error(ActiveRecord::RecordNotFound) + + new_job = GoodJob::Job.find_by(job_class: "RecurringMeetings::InitNextOccurrenceJob") + expect(new_job.scheduled_at).to eq Time.zone.today + 2.days + 11.hours + end + end + end end From 7a6eba0b86a1541d345a30b4988599f0e1d40805 Mon Sep 17 00:00:00 2001 From: Mir Bhatia Date: Wed, 4 Dec 2024 09:26:18 +0100 Subject: [PATCH 113/129] Fix end date in the form --- .../app/components/meetings/index/form_component.html.erb | 2 +- .../meeting/app/forms/recurring_meeting/specific_date.rb | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/modules/meeting/app/components/meetings/index/form_component.html.erb b/modules/meeting/app/components/meetings/index/form_component.html.erb index bd3bb1d86437..ee26235bf1e4 100644 --- a/modules/meeting/app/components/meetings/index/form_component.html.erb +++ b/modules/meeting/app/components/meetings/index/form_component.html.erb @@ -73,7 +73,7 @@ target_name: "end_after", "show-when-value-selected-target": "effect" } ) do - render(RecurringMeeting::SpecificDate.new(f)) + render(RecurringMeeting::SpecificDate.new(f, meeting: @meeting)) end modal_body.with_row(mt: 3, diff --git a/modules/meeting/app/forms/recurring_meeting/specific_date.rb b/modules/meeting/app/forms/recurring_meeting/specific_date.rb index d4ab18a15af8..8860012a3f74 100644 --- a/modules/meeting/app/forms/recurring_meeting/specific_date.rb +++ b/modules/meeting/app/forms/recurring_meeting/specific_date.rb @@ -31,7 +31,7 @@ class RecurringMeeting::SpecificDate < ApplicationForm meeting_form.text_field( name: :end_date, type: "date", - value: @initial_value, + value: @value, placeholder: Meeting.human_attribute_name(:end_date), label: Meeting.human_attribute_name(:end_date), leading_visual: { icon: :calendar }, @@ -40,8 +40,10 @@ class RecurringMeeting::SpecificDate < ApplicationForm ) end - def initialize(initial_value: 1.year.from_now.strftime("%Y-%m-%d")) + def initialize(meeting:) super() - @initial_value = initial_value + + end_time = meeting.end_date || 1.year.from_now + @value = end_time.strftime("%Y-%m-%d") end end From dc59d3159e50635d8be74f09544a1e7be1589e2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 4 Dec 2024 10:01:31 +0100 Subject: [PATCH 114/129] Test cancellation --- .../recurring_meeting_crud_spec.rb | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/modules/meeting/spec/features/recurring_meetings/recurring_meeting_crud_spec.rb b/modules/meeting/spec/features/recurring_meetings/recurring_meeting_crud_spec.rb index fc18b2c3e471..4e968869e664 100644 --- a/modules/meeting/spec/features/recurring_meetings/recurring_meeting_crud_spec.rb +++ b/modules/meeting/spec/features/recurring_meetings/recurring_meeting_crud_spec.rb @@ -35,7 +35,8 @@ RSpec.describe "Recurring meetings CRUD", :js, - :with_cuprite do + :with_cuprite, + with_flag: { recurring_meetings: true } do include Components::Autocompleter::NgSelectAutocompleteHelpers shared_let(:project) { create(:project, enabled_module_names: %w[meetings]) } @@ -86,7 +87,7 @@ perform_enqueued_jobs end - it "can create a recurring meeting", with_flag: { recurring_meetings: true } do + it "can create a recurring meeting" do expect_flash(type: :success, message: "Successful creation.") # Does not send invitation mails by default @@ -101,7 +102,7 @@ show_page.expect_scheduled_meeting date: "01/14/2025 01:30 PM" end - it "can delete a recurring meeting from the show page and return to the index page", with_flag: { recurring_meetings: true } do + it "can delete a recurring meeting from the show page and return to the index page" do show_page.visit! click_on "action-menu" @@ -113,7 +114,7 @@ expect(page).to have_current_path meetings_path # check path end - it "can use the 'Create from template' button", with_flag: { recurring_meetings: true } do + it "can use the 'Create from template' button" do show_page.visit! show_page.create_from_template date: "01/07/2025 01:30 PM" @@ -127,14 +128,14 @@ show_page.expect_open_meeting date: "01/07/2025 01:30 PM" end - xit "can cancel an occurrence", with_flag: { recurring_meetings: true } do # rubocop:disable RSpec/PendingWithoutReason + it "can cancel an occurrence" do show_page.visit! accept_confirm(I18n.t("text_are_you_sure")) do show_page.cancel_occurrence date: "12/31/2024 01:30 PM" end - expect_flash(type: :success, message: "Successful deletion.") + expect_flash(type: :success, message: "Successful cancellation.") expect(page).to have_current_path(show_page.path) # check path @@ -142,7 +143,7 @@ show_page.expect_cancelled_meeting date: "12/31/2024 01:30 PM" end - xit "can edit the details of a recurring meeting", with_flag: { recurring_meetings: true } do # rubocop:disable RSpec/PendingWithoutReason + xit "can edit the details of a recurring meeting" do # rubocop:disable RSpec/PendingWithoutReason show_page.visit! show_page.expect_subtitle text: "Weekly on Tuesday at 01:30 PM, ends on 01/15/2025" @@ -161,7 +162,7 @@ show_page.expect_subtitle text: "Daily at 11:00 AM, ends on 01/07/2025" end - it "shows the correct actions based on status", with_flag: { recurring_meetings: true } do + it "shows the correct actions based on status" do show_page.visit! show_page.expect_open_meeting date: "12/31/2024 01:30 PM" @@ -178,7 +179,7 @@ show_page.expect_cancelled_actions date: "12/31/2024 01:30 PM" end - it "shows rescheduled occurrences", with_flag: { recurring_meetings: true } do + it "shows rescheduled occurrences" do last = Meeting.reorder(id: :asc).last last.start_time += 1.day last.save! From 52470d89a13f92a8163de53b01052c45e202e5cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 4 Dec 2024 10:10:50 +0100 Subject: [PATCH 115/129] Extract create spec --- .../factories/recurring_meeting_factory.rb | 10 +- .../recurring_meeting_create_spec.rb | 111 ++++++++++++++++++ .../recurring_meeting_crud_spec.rb | 41 +++---- 3 files changed, 131 insertions(+), 31 deletions(-) create mode 100644 modules/meeting/spec/features/recurring_meetings/recurring_meeting_create_spec.rb diff --git a/modules/meeting/spec/factories/recurring_meeting_factory.rb b/modules/meeting/spec/factories/recurring_meeting_factory.rb index 8703d7173c2a..fc1188dd8430 100644 --- a/modules/meeting/spec/factories/recurring_meeting_factory.rb +++ b/modules/meeting/spec/factories/recurring_meeting_factory.rb @@ -42,13 +42,15 @@ m.sequence(:title) { |n| "Meeting series #{n}" } after(:create) do |recurring_meeting, evaluator| - recurring_meeting.project = evaluator.project if evaluator.project - recurring_meeting.template = create(:structured_meeting_template, recurring_meeting:) + project = evaluator.project + recurring_meeting.project = project + recurring_meeting.template = create(:structured_meeting_template, recurring_meeting:, project:) end after(:stub) do |recurring_meeting, evaluator| - recurring_meeting.project = evaluator.project if evaluator.project - recurring_meeting.template = build_stubbed(:structured_meeting, recurring_meeting:) + project = evaluator.project + recurring_meeting.project = project + recurring_meeting.template = build_stubbed(:structured_meeting_template, recurring_meeting:, project:) end end end diff --git a/modules/meeting/spec/features/recurring_meetings/recurring_meeting_create_spec.rb b/modules/meeting/spec/features/recurring_meetings/recurring_meeting_create_spec.rb new file mode 100644 index 000000000000..e79194f05337 --- /dev/null +++ b/modules/meeting/spec/features/recurring_meetings/recurring_meeting_create_spec.rb @@ -0,0 +1,111 @@ +#-- 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" + +require_relative "../../support/pages/meetings/new" +require_relative "../../support/pages/structured_meeting/show" +require_relative "../../support/pages/recurring_meeting/show" +require_relative "../../support/pages/meetings/index" + +RSpec.describe "Recurring meetings creation", + :js, + :with_cuprite, + with_flag: { recurring_meetings: true } do + include Components::Autocompleter::NgSelectAutocompleteHelpers + + shared_let(:project) { create(:project, enabled_module_names: %w[meetings]) } + shared_let(:user) do + create(:user, + lastname: "First", + member_with_permissions: { project => %i[view_meetings create_meetings edit_meetings delete_meetings] }).tap do |u| + u.pref[:time_zone] = "Etc/UTC" + + u.save! + end + end + shared_let(:other_user) do + create(:user, + lastname: "Second", + member_with_permissions: { project => %i[view_meetings] }) + end + shared_let(:no_member_user) do + create(:user, + lastname: "Third") + end + + let(:current_user) { user } + let(:meeting) { RecurringMeeting.order(id: :asc).last } + let(:show_page) { Pages::RecurringMeeting::Show.new(meeting) } + let(:meetings_page) { Pages::Meetings::Index.new(project:) } + + context "with a user with permissions" do + it "can create a recurring meeting" do + login_as current_user + meetings_page.visit! + expect(page).to have_current_path(meetings_page.path) + meetings_page.click_on "add-meeting-button" + meetings_page.click_on "Recurring" + + wait_for_network_idle + + meetings_page.set_title "Some title" + + meetings_page.set_start_date "2024-12-31" + meetings_page.set_start_time "13:30" + meetings_page.set_duration "1.5" + meetings_page.set_end_date "2025-01-15" + + click_on "Create meeting" + expect_and_dismiss_flash(type: :success, message: "Successful creation.") + + # Does not send invitation mails by default + perform_enqueued_jobs + expect(ActionMailer::Base.deliveries.size).to eq 0 + + show_page.visit! + + expect(page).to have_css(".start_time", count: 3) + + show_page.expect_open_meeting date: "12/31/2024 01:30 PM" + show_page.expect_scheduled_meeting date: "01/07/2025 01:30 PM" + show_page.expect_scheduled_meeting date: "01/14/2025 01:30 PM" + end + end + + context "as a user with viewing permissions only" do + let(:current_user) { other_user } + + it "does not offer that option" do + login_as current_user + meetings_page.visit! + expect(page).to have_current_path(meetings_page.path) + expect(page).not_to have_test_selctor("add-meeting-button") + end + end +end diff --git a/modules/meeting/spec/features/recurring_meetings/recurring_meeting_crud_spec.rb b/modules/meeting/spec/features/recurring_meetings/recurring_meeting_crud_spec.rb index 4e968869e664..fe7b94d500ac 100644 --- a/modules/meeting/spec/features/recurring_meetings/recurring_meeting_crud_spec.rb +++ b/modules/meeting/spec/features/recurring_meetings/recurring_meeting_crud_spec.rb @@ -41,13 +41,10 @@ shared_let(:project) { create(:project, enabled_module_names: %w[meetings]) } shared_let(:user) do - create(:user, + create :user, lastname: "First", - member_with_permissions: { project => %i[view_meetings create_meetings edit_meetings delete_meetings] }).tap do |u| - u.pref[:time_zone] = "Etc/UTC" - - u.save! - end + preferences: { time_zone: "Etc/UTC" }, + member_with_permissions: { project => %i[view_meetings create_meetings edit_meetings delete_meetings] } end shared_let(:other_user) do create(:user, @@ -58,33 +55,23 @@ create(:user, lastname: "Third") end + shared_let(:meeting) do + create :recurring_meeting, + project:, + start_time: "2024-12-31T13:30:00Z", + duration: 1.5, + frequency: "weekly", + end_after: "specific_date", + end_date: "2025-01-15", + author: user + end let(:current_user) { user } - let(:meeting) { RecurringMeeting.order(id: :asc).last } let(:show_page) { Pages::RecurringMeeting::Show.new(meeting) } let(:meetings_page) { Pages::Meetings::Index.new(project:) } before do login_as current_user - meetings_page.visit! - expect(page).to have_current_path(meetings_page.path) # rubocop:disable RSpec/ExpectInHook - meetings_page.click_on "add-meeting-button" - meetings_page.click_on "Recurring" - - sleep 1 # flaky without - - meetings_page.set_title "Some title" - - meetings_page.set_start_date "2024-12-31" - meetings_page.set_start_time "13:30" - meetings_page.set_duration "1.5" - - meetings_page.set_end_date "2025-01-15" - - click_on "Create meeting" - - wait_for_reload - perform_enqueued_jobs end it "can create a recurring meeting" do @@ -143,7 +130,7 @@ show_page.expect_cancelled_meeting date: "12/31/2024 01:30 PM" end - xit "can edit the details of a recurring meeting" do # rubocop:disable RSpec/PendingWithoutReason + it "can edit the details of a recurring meeting" do show_page.visit! show_page.expect_subtitle text: "Weekly on Tuesday at 01:30 PM, ends on 01/15/2025" From 6d200d4c29ea3322ac37c1b52dc27ad77b25dba9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 4 Dec 2024 10:46:03 +0100 Subject: [PATCH 116/129] Fix specs --- .../recurring_meeting_crud_spec.rb | 23 ++++--------------- .../support/pages/recurring_meeting/show.rb | 15 +++++++++++- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/modules/meeting/spec/features/recurring_meetings/recurring_meeting_crud_spec.rb b/modules/meeting/spec/features/recurring_meetings/recurring_meeting_crud_spec.rb index fe7b94d500ac..8b5c4e8bcdb3 100644 --- a/modules/meeting/spec/features/recurring_meetings/recurring_meeting_crud_spec.rb +++ b/modules/meeting/spec/features/recurring_meetings/recurring_meeting_crud_spec.rb @@ -72,21 +72,9 @@ before do login_as current_user - end - - it "can create a recurring meeting" do - expect_flash(type: :success, message: "Successful creation.") - - # Does not send invitation mails by default - expect(ActionMailer::Base.deliveries.size).to eq 0 - - show_page.visit! - expect(page).to have_css(".start_time", count: 3) - - show_page.expect_open_meeting date: "12/31/2024 01:30 PM" - show_page.expect_scheduled_meeting date: "01/07/2025 01:30 PM" - show_page.expect_scheduled_meeting date: "01/14/2025 01:30 PM" + # Assuming the first init job has run + RecurringMeetings::InitNextOccurrenceJob.perform_now(meeting) end it "can delete a recurring meeting from the show page and return to the index page" do @@ -124,7 +112,7 @@ expect_flash(type: :success, message: "Successful cancellation.") - expect(page).to have_current_path(show_page.path) # check path + expect(page).to have_current_path(show_page.project_path) show_page.expect_no_open_meeting date: "12/31/2024 01:30 PM" show_page.expect_cancelled_meeting date: "12/31/2024 01:30 PM" @@ -133,7 +121,7 @@ it "can edit the details of a recurring meeting" do show_page.visit! - show_page.expect_subtitle text: "Weekly on Tuesday at 01:30 PM, ends on 01/15/2025" + show_page.expect_subtitle text: "Weekly on Tuesday at 01:30 PM, ends on 01/14/2025" show_page.edit_meeting_series show_page.in_edit_dialog do @@ -144,8 +132,7 @@ click_on("Save") end - - show_page.refresh # check refresh + wait_for_network_idle show_page.expect_subtitle text: "Daily at 11:00 AM, ends on 01/07/2025" end diff --git a/modules/meeting/spec/support/pages/recurring_meeting/show.rb b/modules/meeting/spec/support/pages/recurring_meeting/show.rb index 1e02ec016771..90611881d1a5 100644 --- a/modules/meeting/spec/support/pages/recurring_meeting/show.rb +++ b/modules/meeting/spec/support/pages/recurring_meeting/show.rb @@ -42,6 +42,10 @@ def path recurring_meeting_path(meeting) end + def project_path + project_recurring_meeting_path(meeting.project, meeting) + end + def expect_scheduled_meeting(date:) within("li", text: date) do expect(page).to have_css(".status", text: "Scheduled") @@ -114,6 +118,9 @@ def expect_open_actions(date:) expect(page).to have_css(".ActionListItem-label", count: 2) expect(page).to have_css(".ActionListItem-label", text: "Download iCalendar event") expect(page).to have_css(".ActionListItem-label", text: "Cancel this occurrence") + + # Close it again + click_on "more-button" end end @@ -122,7 +129,10 @@ def expect_scheduled_actions(date:) click_on "more-button" expect(page).to have_css(".ActionListItem-label", count: 1) - expect(page).to have_css(".ActionListItem-label", text: "Download iCalendar event") + expect(page).to have_css(".ActionListItem-label", text: "Cancel this occurrence") + + # Close it again + click_on "more-button" end end @@ -132,6 +142,9 @@ def expect_cancelled_actions(date:) expect(page).to have_css(".ActionListItem-label", count: 1) expect(page).to have_css(".ActionListItem-label", text: "Restore this occurrence") + + # Close it again + click_on "more-button" end end From 9140018f26738618fb9c4bc87d2673f8f02ca7ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 4 Dec 2024 11:06:51 +0100 Subject: [PATCH 117/129] Add spec when lacking permissions --- .../recurring_meetings/row_component.rb | 1 + .../show_page_header_component.html.erb | 2 +- .../recurring_meeting_crud_spec.rb | 28 ++++++++++++++++++- .../support/pages/recurring_meeting/show.rb | 8 +++++- 4 files changed, 36 insertions(+), 3 deletions(-) diff --git a/modules/meeting/app/components/recurring_meetings/row_component.rb b/modules/meeting/app/components/recurring_meetings/row_component.rb index 44121384e465..16c7dc0104b5 100644 --- a/modules/meeting/app/components/recurring_meetings/row_component.rb +++ b/modules/meeting/app/components/recurring_meetings/row_component.rb @@ -118,6 +118,7 @@ def status_scheme(state) end def create + return unless copy_allowed? return if instantiated? || cancelled? render( diff --git a/modules/meeting/app/components/recurring_meetings/show_page_header_component.html.erb b/modules/meeting/app/components/recurring_meetings/show_page_header_component.html.erb index d4b156c36d0d..93121f0e7d07 100644 --- a/modules/meeting/app/components/recurring_meetings/show_page_header_component.html.erb +++ b/modules/meeting/app/components/recurring_meetings/show_page_header_component.html.erb @@ -17,7 +17,7 @@ classes: "hide-when-print", "aria-label": "Menu", data: { - "test-selector": "action-menu" + "test-selector": "recurring-meeting-action-menu" } }) do |menu, _button| menu.with_item( diff --git a/modules/meeting/spec/features/recurring_meetings/recurring_meeting_crud_spec.rb b/modules/meeting/spec/features/recurring_meetings/recurring_meeting_crud_spec.rb index 8b5c4e8bcdb3..a74b32d54bca 100644 --- a/modules/meeting/spec/features/recurring_meetings/recurring_meeting_crud_spec.rb +++ b/modules/meeting/spec/features/recurring_meetings/recurring_meeting_crud_spec.rb @@ -80,7 +80,7 @@ it "can delete a recurring meeting from the show page and return to the index page" do show_page.visit! - click_on "action-menu" + click_on "recurring-meeting-action-menu" accept_confirm(I18n.t("text_are_you_sure")) do click_on "Delete meeting series" @@ -162,4 +162,30 @@ show_page.expect_rescheduled_meeting old_date: "12/31/2024 01:30 PM", new_date: "01/01/2025 01:30 PM" end + + context "with view permissions only" do + let(:current_user) { other_user } + + it "does not allow to act on the recurring meeting" do + show_page.visit! + + expect(page).to have_no_content "Create from template" + show_page.expect_open_meeting date: "12/31/2024 01:30 PM" + + within("li", text: "12/31/2024 01:30 PM") do + click_on "more-button" + + expect(page).to have_css(".ActionListItem-label", count: 1) + expect(page).to have_css(".ActionListItem-label", text: "Download iCalendar event") + + # Close it again + click_on "more-button" + end + + show_page.expect_scheduled_meeting date: "01/07/2025 01:30 PM" + show_page.expect_scheduled_meeting date: "01/14/2025 01:30 PM" + + expect(page).not_to have_test_selector "recurring-meeting-action-menu" + end + end end diff --git a/modules/meeting/spec/support/pages/recurring_meeting/show.rb b/modules/meeting/spec/support/pages/recurring_meeting/show.rb index 90611881d1a5..0c9fe779ac3c 100644 --- a/modules/meeting/spec/support/pages/recurring_meeting/show.rb +++ b/modules/meeting/spec/support/pages/recurring_meeting/show.rb @@ -101,7 +101,7 @@ def expect_subtitle(text:) end def edit_meeting_series - page.find_test_selector("action-menu").click + page.find_test_selector("recurring-meeting-action-menu").click click_on "Edit meeting series" expect(page).to have_css("#new-meeting-dialog") @@ -111,6 +111,12 @@ def in_edit_dialog(&) page.within("#new-meeting-dialog", &) end + def expect_no_actions(date:) + within("li", text: date) do + expect(page).not_to have_test_selector("more-button") + end + end + def expect_open_actions(date:) within("li", text: date) do click_on "more-button" From d739bcaa5aed48b7c201e23dd24c959f434d8e98 Mon Sep 17 00:00:00 2001 From: Mir Bhatia Date: Wed, 4 Dec 2024 11:38:18 +0100 Subject: [PATCH 118/129] Fix cancellation of scheduled meetings --- .../recurring_meetings_controller.rb | 26 +++++++++---------- .../meeting/app/models/scheduled_meeting.rb | 2 +- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/modules/meeting/app/controllers/recurring_meetings_controller.rb b/modules/meeting/app/controllers/recurring_meetings_controller.rb index eeb149147653..b8298a44a4e4 100644 --- a/modules/meeting/app/controllers/recurring_meetings_controller.rb +++ b/modules/meeting/app/controllers/recurring_meetings_controller.rb @@ -9,6 +9,7 @@ class RecurringMeetingsController < ApplicationController before_action :find_optional_project, only: %i[index show new create update details_dialog destroy edit delete_scheduled] before_action :authorize_global, only: %i[index new create] before_action :authorize, except: %i[index new create] + before_action :get_scheduled_meeting, only: %i[delete_scheduled] before_action :convert_params, only: %i[create update] @@ -142,22 +143,13 @@ def destroy end def delete_scheduled # rubocop:disable Metrics/AbcSize - call = ::RecurringMeetings::InitOccurrenceService - .new(user: current_user, recurring_meeting: @recurring_meeting) - .call(start_time: DateTime.iso8601(params[:start_time])) - - if call.success? - ::Meetings::DeleteService - .new(model: call.result, user: current_user) - .call - .on_success { flash[:notice] = I18n.t(:notice_successful_cancel) } - .on_failure { |delete_call| flash[:error] = delete_call.message } - - redirect_to polymorphic_path([@project, @recurring_meeting]), status: :see_other + if @scheduled.update(cancelled: true) + flash[:notice] = I18n.t(:notice_successful_cancel) else - flash[:error] = call.message - redirect_to action: :show, id: @recurring_meeting + flash[:error] = I18n.t(:error_failed_to_delete_entry) end + + redirect_to polymorphic_path([@project, @recurring_meeting]), status: :see_other end private @@ -181,6 +173,12 @@ def scheduled_meeting(start_time) ScheduledMeeting.new(start_time:, recurring_meeting: @recurring_meeting) end + def get_scheduled_meeting + @scheduled = @recurring_meeting.scheduled_meetings.find_or_initialize_by(start_time: params[:start_time]) + + render_400 unless @scheduled.meeting_id.nil? + end + def find_optional_project @project = Project.find(params[:project_id]) if params[:project_id].present? rescue ActiveRecord::RecordNotFound diff --git a/modules/meeting/app/models/scheduled_meeting.rb b/modules/meeting/app/models/scheduled_meeting.rb index 7e83b5b27ab4..48b03c3e1a78 100644 --- a/modules/meeting/app/models/scheduled_meeting.rb +++ b/modules/meeting/app/models/scheduled_meeting.rb @@ -35,6 +35,6 @@ class ScheduledMeeting < ApplicationRecord scope :cancelled, -> { where(cancelled: true) } - validates_uniqueness_of :meeting + validates_uniqueness_of :meeting, allow_nil: true validates_presence_of :start_time end From 807c94e41751c1ad40303fd56913a21d63ebfc08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 4 Dec 2024 12:41:08 +0100 Subject: [PATCH 119/129] Render meetings with correct project link --- .../components/recurring_meetings/row_component.rb | 13 +++++++++++-- .../recurring_meetings/table_component.rb | 2 ++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/modules/meeting/app/components/recurring_meetings/row_component.rb b/modules/meeting/app/components/recurring_meetings/row_component.rb index 16c7dc0104b5..109d6b92979c 100644 --- a/modules/meeting/app/components/recurring_meetings/row_component.rb +++ b/modules/meeting/app/components/recurring_meetings/row_component.rb @@ -35,6 +35,7 @@ class RowComponent < ::OpPrimer::BorderBoxRowComponent delegate :recurring_meeting, to: :model delegate :project, to: :recurring_meeting delegate :schedule, to: :meeting + delegate :current_project, to: :table def instantiated? meeting.present? @@ -50,12 +51,20 @@ def column_args(column) def start_time if instantiated? - link_to start_time_title, meeting_path(meeting) + link_to start_time_title, current_project_meeting_path(meeting) else start_time_title end end + def current_project_meeting_path(meeting) + if current_project + project_meeting_path(current_project, meeting) + else + meeting_path(meeting) + end + end + def user_time_zone(time) helpers.in_user_zone(time) end @@ -181,7 +190,7 @@ def delete_action(menu) menu.with_item( label: I18n.t(:label_recurring_meeting_cancel), scheme: :danger, - href: meeting_path(meeting), + href: current_project_meeting_path(meeting), form_arguments: { method: :delete, data: { confirm: I18n.t("text_are_you_sure"), turbo: false } } diff --git a/modules/meeting/app/components/recurring_meetings/table_component.rb b/modules/meeting/app/components/recurring_meetings/table_component.rb index 3d5aea0673c5..cd446823d142 100644 --- a/modules/meeting/app/components/recurring_meetings/table_component.rb +++ b/modules/meeting/app/components/recurring_meetings/table_component.rb @@ -30,6 +30,8 @@ module RecurringMeetings class TableComponent < ::OpPrimer::BorderBoxTableComponent + options :current_project + columns :start_time, :relative_time, :last_edited, :status, :create def has_actions? From a3706f045110e57268e952a7d4698e818e1db9c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 4 Dec 2024 12:41:36 +0100 Subject: [PATCH 120/129] Remove unused disable --- .../meeting/app/controllers/recurring_meetings_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/meeting/app/controllers/recurring_meetings_controller.rb b/modules/meeting/app/controllers/recurring_meetings_controller.rb index b8298a44a4e4..f84f380d548b 100644 --- a/modules/meeting/app/controllers/recurring_meetings_controller.rb +++ b/modules/meeting/app/controllers/recurring_meetings_controller.rb @@ -142,7 +142,7 @@ def destroy end end - def delete_scheduled # rubocop:disable Metrics/AbcSize + def delete_scheduled if @scheduled.update(cancelled: true) flash[:notice] = I18n.t(:notice_successful_cancel) else From eb0dab0da71297d9ea5861c334331aac642cc58a Mon Sep 17 00:00:00 2001 From: Mir Bhatia Date: Fri, 27 Dec 2024 13:37:08 +0100 Subject: [PATCH 121/129] Hide past cancelled occurrences --- modules/meeting/app/models/recurring_meeting.rb | 1 + modules/meeting/app/models/scheduled_meeting.rb | 1 + 2 files changed, 2 insertions(+) diff --git a/modules/meeting/app/models/recurring_meeting.rb b/modules/meeting/app/models/recurring_meeting.rb index ad71950ff323..a93e1bf1fc9a 100644 --- a/modules/meeting/app/models/recurring_meeting.rb +++ b/modules/meeting/app/models/recurring_meeting.rb @@ -130,6 +130,7 @@ def scheduled_instances(upcoming: true) scheduled_meetings .includes(:meeting) .public_send(filter_scope) + .then { |o| filter_scope == :past ? o.not_cancelled : o } .order(start_time: direction) end diff --git a/modules/meeting/app/models/scheduled_meeting.rb b/modules/meeting/app/models/scheduled_meeting.rb index 48b03c3e1a78..1b11cce49b07 100644 --- a/modules/meeting/app/models/scheduled_meeting.rb +++ b/modules/meeting/app/models/scheduled_meeting.rb @@ -34,6 +34,7 @@ class ScheduledMeeting < ApplicationRecord scope :past, -> { where(start_time: ...Time.current) } scope :cancelled, -> { where(cancelled: true) } + scope :not_cancelled, -> { where(cancelled: false) } validates_uniqueness_of :meeting, allow_nil: true validates_presence_of :start_time From a053a35775a0fa4d7ad16c37d5d087aff55745ac Mon Sep 17 00:00:00 2001 From: Mir Bhatia Date: Fri, 27 Dec 2024 13:38:53 +0100 Subject: [PATCH 122/129] Show different delete labels and messages --- .../recurring_meetings/row_component.rb | 35 ++++++++++--------- modules/meeting/config/locales/en.yml | 3 ++ 2 files changed, 21 insertions(+), 17 deletions(-) diff --git a/modules/meeting/app/components/recurring_meetings/row_component.rb b/modules/meeting/app/components/recurring_meetings/row_component.rb index 109d6b92979c..9e553f1ee657 100644 --- a/modules/meeting/app/components/recurring_meetings/row_component.rb +++ b/modules/meeting/app/components/recurring_meetings/row_component.rb @@ -158,25 +158,16 @@ def action_menu # rubocop:disable Metrics/AbcSize "test-selector": "more-button" }) - if delete_allowed? && !cancelled? && !instantiated? - delete_scheduled_action(menu) - end - - if instantiated? && !cancelled? - ical_action(menu) - - if delete_allowed? - delete_action(menu) - end - end - - if cancelled? - restore_action(menu) - end + delete_scheduled_action(menu) + ical_action(menu) + delete_action(menu) + restore_action(menu) end end def ical_action(menu) + return unless instantiated? && !cancelled? + menu.with_item(label: I18n.t(:label_icalendar_download), href: download_ics_meeting_path(meeting), content_arguments: { @@ -187,12 +178,14 @@ def ical_action(menu) end def delete_action(menu) + return unless delete_allowed? && !cancelled? && instantiated? + menu.with_item( - label: I18n.t(:label_recurring_meeting_cancel), + label: past? ? I18n.t(:label_recurring_meeting_delete) : I18n.t(:label_recurring_meeting_cancel), scheme: :danger, href: current_project_meeting_path(meeting), form_arguments: { - method: :delete, data: { confirm: I18n.t("text_are_you_sure"), turbo: false } + method: :delete, data: { confirm: I18n.t(:label_recurring_occurrence_delete_confirmation), turbo: false } } ) do |item| item.with_leading_visual_icon(icon: :trash) @@ -200,6 +193,8 @@ def delete_action(menu) end def delete_scheduled_action(menu) + return unless delete_allowed? && !cancelled? && !instantiated? + menu.with_item( label: I18n.t(:label_recurring_meeting_cancel), scheme: :danger, @@ -213,6 +208,8 @@ def delete_scheduled_action(menu) end def restore_action(menu) + return unless cancelled? + menu.with_item( label: I18n.t(:label_recurring_meeting_restore), href: init_recurring_meeting_path(recurring_meeting, start_time: model.start_time.iso8601), @@ -235,5 +232,9 @@ def copy_allowed? def start_time_changed? meeting && meeting.start_time != model.start_time end + + def past? + model.start_time < Time.current + end end end diff --git a/modules/meeting/config/locales/en.yml b/modules/meeting/config/locales/en.yml index bf2cad1d5b9d..437e2daea45b 100644 --- a/modules/meeting/config/locales/en.yml +++ b/modules/meeting/config/locales/en.yml @@ -139,6 +139,9 @@ en: This meeting is part of a series called %{name}. This will only delete this particular occurrence and not the entire series. Do you want to continue? + label_recurring_occurrence_delete_confirmation: > + Any meeting information not in the template will be lost. + Do you want to continue? label_recurring_meeting_restore: "Restore this occurrence" label_recurring_meeting_series_edit: "Edit meeting series" label_recurring_meeting_series_delete: "Delete meeting series" From 42129b5408f34811c504f618ca83c59526dc9ac0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 4 Dec 2024 14:51:16 +0100 Subject: [PATCH 123/129] Move to request spec --- .../recurring_meeting_crud_spec.rb | 10 -- .../recurring_meetings_index_spec.rb | 69 ++++++++ .../recurring_meetings_show_spec.rb | 155 ++++++++++++++++++ .../support/pages/recurring_meeting/show.rb | 10 ++ 4 files changed, 234 insertions(+), 10 deletions(-) create mode 100644 modules/meeting/spec/requests/recurring_meetings/recurring_meetings_index_spec.rb create mode 100644 modules/meeting/spec/requests/recurring_meetings/recurring_meetings_show_spec.rb diff --git a/modules/meeting/spec/features/recurring_meetings/recurring_meeting_crud_spec.rb b/modules/meeting/spec/features/recurring_meetings/recurring_meeting_crud_spec.rb index a74b32d54bca..45ae1263f0c6 100644 --- a/modules/meeting/spec/features/recurring_meetings/recurring_meeting_crud_spec.rb +++ b/modules/meeting/spec/features/recurring_meetings/recurring_meeting_crud_spec.rb @@ -153,16 +153,6 @@ show_page.expect_cancelled_actions date: "12/31/2024 01:30 PM" end - it "shows rescheduled occurrences" do - last = Meeting.reorder(id: :asc).last - last.start_time += 1.day - last.save! - - show_page.visit! - - show_page.expect_rescheduled_meeting old_date: "12/31/2024 01:30 PM", new_date: "01/01/2025 01:30 PM" - end - context "with view permissions only" do let(:current_user) { other_user } diff --git a/modules/meeting/spec/requests/recurring_meetings/recurring_meetings_index_spec.rb b/modules/meeting/spec/requests/recurring_meetings/recurring_meetings_index_spec.rb new file mode 100644 index 000000000000..62b8d8c990c4 --- /dev/null +++ b/modules/meeting/spec/requests/recurring_meetings/recurring_meetings_index_spec.rb @@ -0,0 +1,69 @@ +#-- 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 "Recurring meetings index", + :skip_csrf, + type: :rails_request do + shared_let(:project) { create(:project, enabled_module_names: %i[meetings]) } + shared_let(:user) { create(:user, member_with_permissions: { project => %i[view_meetings create_meetings edit_meetings] }) } + shared_let(:series) { create(:recurring_meeting, project:, author: user) } + + let(:current_user) { user } + + before do + login_as(current_user) + end + + context "when user has permissions to access" do + it "does not show the recurring meetings" do + get recurring_meetings_path + expect(response).to have_http_status(:ok) + end + + it "does not show project recurring meetings" do + get project_recurring_meetings_path(project) + expect(response).to have_http_status(:ok) + end + end + + context "when user has no permissions to access" do + let(:current_user) { create(:user) } + + it "does not show the recurring meetings" do + get recurring_meetings_path + expect(response).to have_http_status(:forbidden) + end + + it "does not show project recurring meetings" do + get project_recurring_meetings_path(project) + expect(response).to have_http_status(:forbidden) + end + end +end diff --git a/modules/meeting/spec/requests/recurring_meetings/recurring_meetings_show_spec.rb b/modules/meeting/spec/requests/recurring_meetings/recurring_meetings_show_spec.rb new file mode 100644 index 000000000000..f8f9fc9a242c --- /dev/null +++ b/modules/meeting/spec/requests/recurring_meetings/recurring_meetings_show_spec.rb @@ -0,0 +1,155 @@ +#-- 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" +require_relative "../../support/pages/recurring_meeting/show" + +RSpec.describe "Recurring meetings show", + :skip_csrf, + type: :rails_request do + include Redmine::I18n + + shared_let(:project) { create(:project, enabled_module_names: %i[meetings]) } + shared_let(:user) { create(:user, member_with_permissions: { project => %i[view_meetings create_meetings edit_meetings] }) } + shared_let(:recurring_meeting) do + create :recurring_meeting, + project:, + author: user, + start_time: Time.zone.today - 10.days + 10.hours, + frequency: "daily" + end + + let(:current_user) { user } + let(:show_page) { Pages::RecurringMeeting::Show.new(recurring_meeting).with_capybara_page(page) } + + before do + login_as(current_user) + end + + context "when user has permissions to access" do + it "shows the recurring meetings" do + get recurring_meeting_path(recurring_meeting) + expect(response).to have_http_status(:ok) + end + + it "shows project recurring meetings" do + get project_recurring_meeting_path(project, recurring_meeting) + expect(response).to have_http_status(:ok) + end + end + + describe "past quick filter" do + let!(:past_instance) { create(:structured_meeting, recurring_meeting:, start_time: 1.day.ago + 10.hours) } + let!(:past_schedule) do + create :scheduled_meeting, + meeting: past_instance, + recurring_meeting:, + start_time: 1.day.ago + 10.hours + end + + let!(:past_schedule_cancelled) do + create :scheduled_meeting, + recurring_meeting:, + start_time: 2.days.ago + 10.hours, + cancelled: true + end + + it "does not show the cancelled meeting" do + get recurring_meeting_path(recurring_meeting, direction: "past") + + expect(page).to have_text format_time(past_instance.start_time) + expect(page).to have_no_text format_time(past_schedule_cancelled.start_time) + expect(page).to have_no_css("li", text: "Cancelled") + end + end + + describe "upcoming quick filter" do + context "with a rescheduled meeting" do + let!(:rescheduled_instance) do + create :structured_meeting, + recurring_meeting:, + start_time: Time.zone.today + 2.days + 10.hours + end + let!(:rescheduled) do + create :scheduled_meeting, + meeting: rescheduled_instance, + recurring_meeting:, + start_time: Time.zone.today + 1.day + 10.hours + end + + it "shows rescheduled occurrences" do + get recurring_meeting_path(recurring_meeting) + + old_date = format_time(rescheduled.start_time) + new_date = format_time(rescheduled_instance.start_time) + expect(page).to have_css("li s", text: old_date) + expect(page).to have_text("#{old_date}\n#{new_date}") + end + end + + context "with a cancelled meeting" do + let!(:rescheduled) do + create :scheduled_meeting, + :cancelled, + recurring_meeting:, + start_time: Time.zone.today + 1.day + 10.hours + end + + it "shows the cancelled occurrences" do + get recurring_meeting_path(recurring_meeting) + + expect(page).to have_css("li", text: format_time(rescheduled.start_time)) + expect(page).to have_css("li", text: "Cancelled") + end + end + + context "with no scheduled meetings" do + it "shows the next five occurrences" do + get recurring_meeting_path(recurring_meeting) + + (1..5).each do |date| + expect(page).to have_text format_time(Time.zone.today + date.days + 10.hours) + end + end + end + end + + context "when user has no permissions to access" do + let(:current_user) { create(:user) } + + it "does not show the recurring meetings" do + get recurring_meeting_path(recurring_meeting) + expect(response).to have_http_status(:not_found) + end + + it "does not show project recurring meetings" do + get project_recurring_meeting_path(project, recurring_meeting) + expect(response).to have_http_status(:not_found) + end + end +end diff --git a/modules/meeting/spec/support/pages/recurring_meeting/show.rb b/modules/meeting/spec/support/pages/recurring_meeting/show.rb index 0c9fe779ac3c..f3319dac3318 100644 --- a/modules/meeting/spec/support/pages/recurring_meeting/show.rb +++ b/modules/meeting/spec/support/pages/recurring_meeting/show.rb @@ -76,6 +76,12 @@ def expect_cancelled_meeting(date:) end end + def expect_no_cancelled_meeting(date:) + within("li", text: date) do + expect(page).to have_no_css(".status", text: "Cancelled") + end + end + def expect_rescheduled_meeting(old_date:, new_date:) within("li", text: old_date) do expect(page).to have_css("s", text: old_date) @@ -111,6 +117,10 @@ def in_edit_dialog(&) page.within("#new-meeting-dialog", &) end + def expect_no_meeting(date:) + expect(page).to have_no_css("li", text: date) + end + def expect_no_actions(date:) within("li", text: date) do expect(page).not_to have_test_selector("more-button") From 485ef521d04e5cf685cbc451d4d7b0b2aaba8e7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 4 Dec 2024 15:10:16 +0100 Subject: [PATCH 124/129] Remove useless cop disable --- .../meeting/app/components/recurring_meetings/row_component.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/meeting/app/components/recurring_meetings/row_component.rb b/modules/meeting/app/components/recurring_meetings/row_component.rb index 9e553f1ee657..fc11d9415199 100644 --- a/modules/meeting/app/components/recurring_meetings/row_component.rb +++ b/modules/meeting/app/components/recurring_meetings/row_component.rb @@ -149,7 +149,7 @@ def button_links ] end - def action_menu # rubocop:disable Metrics/AbcSize + def action_menu render(Primer::Alpha::ActionMenu.new) do |menu| menu.with_show_button(icon: "kebab-horizontal", "aria-label": "More", From 88965c5655ad724d2b6db2aac7f7b603474eac93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 4 Dec 2024 15:12:14 +0100 Subject: [PATCH 125/129] Add contract specs --- .../create_contract_spec.rb | 58 +++++++++++++++++++ .../update_contract_spec.rb | 58 +++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 modules/meeting/spec/contracts/recurring_meetings/create_contract_spec.rb create mode 100644 modules/meeting/spec/contracts/recurring_meetings/update_contract_spec.rb diff --git a/modules/meeting/spec/contracts/recurring_meetings/create_contract_spec.rb b/modules/meeting/spec/contracts/recurring_meetings/create_contract_spec.rb new file mode 100644 index 000000000000..432e637d9d1d --- /dev/null +++ b/modules/meeting/spec/contracts/recurring_meetings/create_contract_spec.rb @@ -0,0 +1,58 @@ +# 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" +require "contracts/shared/model_contract_shared_context" + +RSpec.describe RecurringMeetings::CreateContract do + include_context "ModelContract shared context" + + shared_let(:project) { create(:project) } + let(:meeting) { build(:recurring_meeting, project:) } + let(:contract) { described_class.new(meeting, user) } + + context "with permission" do + let(:user) do + create(:user, member_with_permissions: { project => %i[view_meetings create_meetings] }) + end + + it_behaves_like "contract is valid" + end + + context "without permission" do + let(:user) { build_stubbed(:user) } + + it_behaves_like "contract is invalid", base: :error_unauthorized + end + + include_examples "contract reuses the model errors" do + let(:user) { build_stubbed(:user) } + end +end diff --git a/modules/meeting/spec/contracts/recurring_meetings/update_contract_spec.rb b/modules/meeting/spec/contracts/recurring_meetings/update_contract_spec.rb new file mode 100644 index 000000000000..683ee89dd5b1 --- /dev/null +++ b/modules/meeting/spec/contracts/recurring_meetings/update_contract_spec.rb @@ -0,0 +1,58 @@ +# 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" +require "contracts/shared/model_contract_shared_context" + +RSpec.describe RecurringMeetings::UpdateContract do + include_context "ModelContract shared context" + + shared_let(:project) { create(:project) } + shared_let(:meeting) { create(:recurring_meeting, project:) } + let(:contract) { described_class.new(meeting, user) } + + context "with permission" do + let(:user) do + create(:user, member_with_permissions: { project => [:edit_meetings] }) + end + + it_behaves_like "contract is valid" + end + + context "without permission" do + let(:user) { build_stubbed(:user) } + + it_behaves_like "contract is invalid", base: :error_unauthorized + end + + include_examples "contract reuses the model errors" do + let(:user) { build_stubbed(:user) } + end +end From 27a517d773bd7a5ce10f0aabee82fbeefb83fae8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 4 Dec 2024 15:13:44 +0100 Subject: [PATCH 126/129] Add delete contract spec --- .../delete_contract_spec.rb | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 modules/meeting/spec/contracts/recurring_meetings/delete_contract_spec.rb diff --git a/modules/meeting/spec/contracts/recurring_meetings/delete_contract_spec.rb b/modules/meeting/spec/contracts/recurring_meetings/delete_contract_spec.rb new file mode 100644 index 000000000000..57670e0bb1a8 --- /dev/null +++ b/modules/meeting/spec/contracts/recurring_meetings/delete_contract_spec.rb @@ -0,0 +1,59 @@ +# 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" +require "contracts/shared/model_contract_shared_context" + +RSpec.describe RecurringMeetings::DeleteContract do + include_context "ModelContract shared context" + + shared_let(:project) { create(:project) } + shared_let(:meeting) { create(:structured_meeting, project:) } + shared_let(:item) { create(:meeting_agenda_item, meeting:) } + let(:contract) { described_class.new(item, user) } + + context "with permission" do + let(:user) do + create(:user, member_with_permissions: { project => [:delete_meetings] }) + end + + it_behaves_like "contract is valid" + end + + context "without permission" do + let(:user) { build_stubbed(:user) } + + it_behaves_like "contract is invalid", base: :error_unauthorized + end + + include_examples "contract reuses the model errors" do + let(:user) { build_stubbed(:user) } + end +end From 072a3cedb03260a533cb2d30560e2c88912e48b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 4 Dec 2024 15:14:29 +0100 Subject: [PATCH 127/129] Revert "Add delete contract spec" This reverts commit 27a517d773bd7a5ce10f0aabee82fbeefb83fae8. --- .../delete_contract_spec.rb | 59 ------------------- 1 file changed, 59 deletions(-) delete mode 100644 modules/meeting/spec/contracts/recurring_meetings/delete_contract_spec.rb diff --git a/modules/meeting/spec/contracts/recurring_meetings/delete_contract_spec.rb b/modules/meeting/spec/contracts/recurring_meetings/delete_contract_spec.rb deleted file mode 100644 index 57670e0bb1a8..000000000000 --- a/modules/meeting/spec/contracts/recurring_meetings/delete_contract_spec.rb +++ /dev/null @@ -1,59 +0,0 @@ -# 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" -require "contracts/shared/model_contract_shared_context" - -RSpec.describe RecurringMeetings::DeleteContract do - include_context "ModelContract shared context" - - shared_let(:project) { create(:project) } - shared_let(:meeting) { create(:structured_meeting, project:) } - shared_let(:item) { create(:meeting_agenda_item, meeting:) } - let(:contract) { described_class.new(item, user) } - - context "with permission" do - let(:user) do - create(:user, member_with_permissions: { project => [:delete_meetings] }) - end - - it_behaves_like "contract is valid" - end - - context "without permission" do - let(:user) { build_stubbed(:user) } - - it_behaves_like "contract is invalid", base: :error_unauthorized - end - - include_examples "contract reuses the model errors" do - let(:user) { build_stubbed(:user) } - end -end From 8e014c55e9b6369eb4fa22d21d5c5972cb33a851 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 4 Dec 2024 15:17:37 +0100 Subject: [PATCH 128/129] Add delete contract spec for meetings itself --- .../meetings/delete_contract_spec.rb | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 modules/meeting/spec/contracts/meetings/delete_contract_spec.rb diff --git a/modules/meeting/spec/contracts/meetings/delete_contract_spec.rb b/modules/meeting/spec/contracts/meetings/delete_contract_spec.rb new file mode 100644 index 000000000000..794cabd4b632 --- /dev/null +++ b/modules/meeting/spec/contracts/meetings/delete_contract_spec.rb @@ -0,0 +1,58 @@ +# 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" +require "contracts/shared/model_contract_shared_context" + +RSpec.describe Meetings::DeleteContract do + include_context "ModelContract shared context" + + shared_let(:project) { create(:project) } + shared_let(:meeting) { create(:meeting, project:) } + let(:contract) { described_class.new(meeting, user) } + + context "with permission" do + let(:user) do + create(:user, member_with_permissions: { project => [:delete_meetings] }) + end + + it_behaves_like "contract is valid" + end + + context "without permission" do + let(:user) { build_stubbed(:user) } + + it_behaves_like "contract is invalid", base: :error_unauthorized + end + + include_examples "contract reuses the model errors" do + let(:user) { build_stubbed(:user) } + end +end From 9eb9e043ebbe9c9913338569a84b8c0bc99bb21f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 4 Dec 2024 16:20:47 +0100 Subject: [PATCH 129/129] Fix spec --- .../recurring_meetings/recurring_meeting_create_spec.rb | 3 ++- .../recurring_meetings/recurring_meeting_crud_spec.rb | 8 +++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/modules/meeting/spec/features/recurring_meetings/recurring_meeting_create_spec.rb b/modules/meeting/spec/features/recurring_meetings/recurring_meeting_create_spec.rb index e79194f05337..a06ce9be1882 100644 --- a/modules/meeting/spec/features/recurring_meetings/recurring_meeting_create_spec.rb +++ b/modules/meeting/spec/features/recurring_meetings/recurring_meeting_create_spec.rb @@ -82,6 +82,7 @@ meetings_page.set_end_date "2025-01-15" click_on "Create meeting" + wait_for_network_idle expect_and_dismiss_flash(type: :success, message: "Successful creation.") # Does not send invitation mails by default @@ -105,7 +106,7 @@ login_as current_user meetings_page.visit! expect(page).to have_current_path(meetings_page.path) - expect(page).not_to have_test_selctor("add-meeting-button") + expect(page).not_to have_test_selector("add-meeting-button") end end end diff --git a/modules/meeting/spec/features/recurring_meetings/recurring_meeting_crud_spec.rb b/modules/meeting/spec/features/recurring_meetings/recurring_meeting_crud_spec.rb index 45ae1263f0c6..ad4e95347e54 100644 --- a/modules/meeting/spec/features/recurring_meetings/recurring_meeting_crud_spec.rb +++ b/modules/meeting/spec/features/recurring_meetings/recurring_meeting_crud_spec.rb @@ -106,7 +106,7 @@ it "can cancel an occurrence" do show_page.visit! - accept_confirm(I18n.t("text_are_you_sure")) do + accept_confirm(I18n.t(:label_recurring_occurrence_delete_confirmation)) do show_page.cancel_occurrence date: "12/31/2024 01:30 PM" end @@ -130,7 +130,8 @@ page.select("A number of occurrences", from: "End after") page.fill_in("Occurrences", with: "8") - click_on("Save") + sleep 0.5 + click_link_or_button("Save") end wait_for_network_idle show_page.expect_subtitle text: "Daily at 11:00 AM, ends on 01/07/2025" @@ -145,10 +146,11 @@ show_page.expect_scheduled_meeting date: "01/07/2025 01:30 PM" show_page.expect_scheduled_actions date: "01/07/2025 01:30 PM" - accept_confirm(I18n.t("text_are_you_sure")) do + accept_confirm(I18n.t(:label_recurring_occurrence_delete_confirmation)) do show_page.cancel_occurrence date: "12/31/2024 01:30 PM" end + wait_for_network_idle show_page.expect_cancelled_meeting date: "12/31/2024 01:30 PM" show_page.expect_cancelled_actions date: "12/31/2024 01:30 PM" end