diff --git a/Gemfile b/Gemfile index f8e962882816..0e6b318ffe35 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", "~> 0.17.0" + group :production do # we use dalli as standard memcache client # requires memcached 1.4+ diff --git a/Gemfile.lock b/Gemfile.lock index b58762173e66..ffce7edab9c9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1270,6 +1270,7 @@ DEPENDENCIES httpx i18n-js (~> 4.2.3) i18n-tasks (~> 1.0.13) + ice_cube (~> 0.17.0) json_schemer (~> 2.3.0) json_spec (~> 1.1.4) ladle diff --git a/app/components/op_primer/border_box_table_component.rb b/app/components/op_primer/border_box_table_component.rb index b005fe4be6d0..9b8dcc8135e9 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/app/menus/submenu.rb b/app/menus/submenu.rb index 8909c15f32fa..6bd27e89a4a5 100644 --- a/app/menus/submenu.rb +++ b/app/menus/submenu.rb @@ -29,7 +29,7 @@ class Submenu include Rails.application.routes.url_helpers attr_reader :view_type, :project, :params - def initialize(view_type:, project: nil, params: nil) + def initialize(view_type:, params:, project: nil) @view_type = view_type @project = project @params = params @@ -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 @@ -144,4 +145,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/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/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/config/initializers/feature_decisions.rb b/config/initializers/feature_decisions.rb index 1af9d4f3b80d..67c21b08c82b 100644 --- a/config/initializers/feature_decisions.rb +++ b/config/initializers/feature_decisions.rb @@ -50,6 +50,8 @@ 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." # TODO: Remove once the feature flag primerized_work_package_activities is removed altogether OpenProject::FeatureDecisions.define_singleton_method(:primerized_work_package_activities_active?) do Rails.env.production? || diff --git a/config/locales/en.yml b/config/locales/en.yml index 4f88191d792c..80aa3ad276ed 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1025,6 +1025,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}." @@ -3102,6 +3103,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/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/lib_static/redmine/i18n.rb b/lib_static/redmine/i18n.rb index 243027c58fbd..52dfcf29864d 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_user_zone(time).to_date if format local_date.strftime(format) @@ -148,21 +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 - zone = User.current.time_zone - local = time.in_time_zone(zone) + 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+'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_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#{User.current.time_zone.now.formatted_offset}" + "UTC#{user.time_zone.now.formatted_offset}" end def day_name(day) 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. 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..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 @@ -1,6 +1,17 @@ <%= 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 + t("recurring_meeting.template.banner_html", + link: link_to(@meeting.recurring_meeting.title, + recurring_meeting_path(@meeting.recurring_meeting))) + 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/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..786ef0632e1e --- /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:, params:, project: nil) + 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/header_component.html.erb b/modules/meeting/app/components/meetings/header_component.html.erb index a3c97958fa88..f434c2a4f07a 100644 --- a/modules/meeting/app/components/meetings/header_component.html.erb +++ b/modules/meeting/app/components/meetings/header_component.html.erb @@ -19,7 +19,14 @@ 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)})" + 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 end header.with_breadcrumbs(breadcrumb_items) header.with_description { render(Meetings::HeaderInfolineComponent.new(@meeting)) } @@ -34,28 +41,31 @@ data: { 'turbo-stream': true } }) do |item| item.with_leading_visual_icon(icon: :pencil) - end if @meeting.editable? + end if @meeting.editable? && !@series - 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: copy_label, + 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, @@ -68,7 +78,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 4aba72081558..751a02d4929f 100644 --- a/modules/meeting/app/components/meetings/header_component.rb +++ b/modules/meeting/app/components/meetings/header_component.rb @@ -32,14 +32,17 @@ class HeaderComponent < ApplicationComponent include OpTurbo::Streamable include OpPrimer::ComponentHelpers include Primer::FetchOrFallbackHelper + include Redmine::I18n STATE_DEFAULT = :show STATE_EDIT = :edit STATE_OPTIONS = [STATE_DEFAULT, STATE_EDIT].freeze + 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 @@ -52,14 +55,33 @@ 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 - [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) + elsif @series.present? + format_date(@meeting.start_time) + else + @meeting.title + end + end + + def meeting_series_element + if @series.present? + { href: recurring_meeting_path(@series), text: @series.title } + end end def parent_element @@ -69,5 +91,21 @@ def parent_element { href: home_path, text: helpers.organization_name } end end + + def delete_label + 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/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/index/dialog_component.html.erb b/modules/meeting/app/components/meetings/index/dialog_component.html.erb index d66c3ab90b5b..7eafcf9ffac2 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,39 @@ <%= 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)) + 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/dialog_component.rb b/modules/meeting/app/components/meetings/index/dialog_component.rb index ad90c65469d1..011851a817b9 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,15 @@ def render? end def title - @type == :new ? I18n.t("label_meeting_new_dynamic") : "Copy meeting" + return I18n.t(:label_meeting_copy) if @copy_from + return I18n.t(:label_meeting_edit) if @meeting.persisted? + + case @meeting + when RecurringMeeting + I18n.t("label_meeting_new_recurring") + else + I18n.t("label_meeting_new_dynamic") + 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..ee26235bf1e4 100644 --- a/modules/meeting/app/components/meetings/index/form_component.html.erb +++ b/modules/meeting/app/components/meetings/index/form_component.html.erb @@ -3,77 +3,113 @@ primer_form_with( scope: :meeting, model: @meeting, - method: :post, - data: { turbo: true }, - html: { :id => 'meeting-form' }, - url: {:controller => '/meetings', :action => 'create', :project_id => @project} + method: form_method, + data: { + turbo: true, + controller: "show-when-value-selected" + }, + html: { + id: 'meeting-form' + }, + url: { + controller: form_controller, + action: form_action, + 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 @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 + 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 - modal_body.with_row(mt: 3) do - render(Meeting::StartDate.new(f, initial_value: start_date_initial_value)) - 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::StartTime.new(f, initial_value: start_time_initial_value)) - end + modal_body.with_row(mt: 3) do + render(Meeting::Title.new(f)) + end - modal_body.with_row(mt: 3) do - render(Meeting::Duration.new(f)) - end + modal_body.with_row(mt: 3) do + render(Meeting::TimeGroup.new(f, meeting: @meeting)) + end - modal_body.with_row(mt: 3) do - render(Meeting::Location.new(f)) - end + modal_body.with_row(mt: 3) do + render(Meeting::Duration.new(f, meeting: @meeting)) + end - modal_body.with_row do - render(Meeting::Type.new(f)) - end + modal_body.with_row(mt: 3) do + render(Meeting::Location.new(f, meeting: @meeting)) + end - unless @type == :new - modal_body.with_row do - render(Meeting::CopiedFrom.new(f, id: @type.id)) - 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) do - render(Meeting::CopyItems.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(Meeting::CopyParticipants.new(f)) - end + modal_body.with_row(mt: 3) do + render(RecurringMeeting::EndAfter.new(f)) + end - modal_body.with_row(mt: 3) do - render(Meeting::CopyAttachments.new(f)) - end + modal_body.with_row(mt: 3, + hidden: @meeting.end_after_iterations?, + data: { + value: "specific_date", + target_name: "end_after", + "show-when-value-selected-target": "effect" } + ) do + render(RecurringMeeting::SpecificDate.new(f, meeting: @meeting)) + end - modal_body.with_row(mt: 3) do - render(Meeting::EmailParticipants.new(f)) - end - end + modal_body.with_row(mt: 3, + hidden: @meeting.end_after_specific_date?, + 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 - 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 + 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_footer.with_component(Primer::ButtonComponent.new(scheme: :primary, type: :submit)) do - I18n.t("label_meeting_create") - end + modal_body.with_row(mt: 3) do + render(Meeting::EmailParticipants.new(f)) 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 8f277a1024f0..aaf81f9b69f9 100644 --- a/modules/meeting/app/components/meetings/index/form_component.rb +++ b/modules/meeting/app/components/meetings/index/form_component.rb @@ -32,22 +32,38 @@ 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 - def start_date_initial_value - format_time_as_date(@meeting.start_time, format: "%Y-%m-%d") + def form_controller + if @meeting.is_a?(RecurringMeeting) + "/recurring_meetings" + else + "/meetings" + end end - def start_time_initial_value - format_time(@meeting.start_time, include_date: false, format: "%H:%M") + def form_method + if @meeting.new_record? + :post + else + :put + end + end + + def form_action + if @meeting.new_record? + :create + else + :update + end 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 dc6622c3c428..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? @@ -25,10 +25,18 @@ menu.with_item(label: I18n.t("meeting.types.structured"), tag: :a, - href: new_dialog_meetings_path(project_id: @project&.id), + href: new_dialog_meetings_path(project_id: @project&.id, type: :structured), content_arguments: { data: { controller: "async-dialog" }} ) + if OpenProject::FeatureDecisions.recurring_meetings_active? + menu.with_item(label: I18n.t("meeting.types.recurring"), + tag: :a, + href: new_dialog_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: dynamic_path 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..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,10 +34,11 @@ class IndexSubHeaderComponent < ApplicationComponent # rubocop:enable OpenProject/AddPreviewForViewComponent include ApplicationHelper - def initialize(query:, project: nil) + def initialize(query:, params:, project: nil) 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 f40c91d9aaa6..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,7 @@ def allowed_filter?(filter) Queries::Meetings::Filters::AttendedUserFilter, Queries::Meetings::Filters::AuthorFilter, Queries::Meetings::Filters::InvitedUserFilter, - Queries::Meetings::Filters::TimeFilter + Queries::Meetings::Filters::RecurringFilter ] if project.nil? diff --git a/modules/meeting/app/components/meetings/row_component.rb b/modules/meeting/app/components/meetings/row_component.rb index 0e5778dfbfe9..88377b0f65ec 100644 --- a/modules/meeting/app/components/meetings/row_component.rb +++ b/modules/meeting/app/components/meetings/row_component.rb @@ -35,11 +35,28 @@ def project_name end def title - link_to model.title, project_meeting_path(model.project, model) + if recurring? + link_to model.title, recurring_meeting_path(model) + elsif recurring_meeting.present? + occurrence_title + else + 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 - 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 @@ -47,11 +64,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 @@ -66,19 +89,30 @@ def action_menu data: { "test-selector": "more-button" }) - if copy_allowed? + + if recurring? + nil + elsif recurring_meeting.present? + view_meeting_series(menu) + else copy_action(menu) end - ical_action(menu) + ical_action(menu) unless recurring? + delete_action(menu) + end + end - if delete_allowed? - delete_action(menu) - end + 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: { @@ -102,16 +136,33 @@ 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 + 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 + def delete_allowed? User.current.allowed_in_project?(:delete_meetings, model.project) end @@ -119,5 +170,15 @@ def delete_allowed? def copy_allowed? User.current.allowed_in_project?(:create_meetings, model.project) 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/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..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 @@ -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), @@ -17,10 +24,38 @@ 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(:calendar) do + render(Primer::Beta::Text.new) do + @meeting.recurring_meeting.human_day_of_week + end + 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 + format_date(@meeting.start_time) + end end end end @@ -30,7 +65,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/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/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 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/components/meetings/table_component.rb b/modules/meeting/app/components/meetings/table_component.rb index cd18de9b8913..56a1433c6c7d 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, :start_time, :project_name, :duration, :location + columns :title, :start_time, :project_name, :duration, :location, :frequency mobile_columns :title, :start_time, :project_name @@ -41,11 +41,7 @@ class TableComponent < ::OpPrimer::BorderBoxTableComponent main_column :title def sortable? - true - end - - def initial_sort - %i[start_time asc] + false end def has_actions? @@ -59,7 +55,9 @@ def mobile_title def headers @headers ||= [ [:title, { caption: Meeting.human_attribute_name(:title) }], - [:start_time, { caption: I18n.t(:label_meeting_date_and_time) }], + 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, [:duration, { caption: Meeting.human_attribute_name(:duration) }], [:location, { caption: Meeting.human_attribute_name(:location) }] @@ -69,5 +67,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/row_component.rb b/modules/meeting/app/components/recurring_meetings/row_component.rb new file mode 100644 index 000000000000..fc11d9415199 --- /dev/null +++ b/modules/meeting/app/components/recurring_meetings/row_component.rb @@ -0,0 +1,240 @@ +# 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 + delegate :meeting, to: :model + delegate :cancelled?, to: :model + delegate :recurring_meeting, to: :model + delegate :project, to: :recurring_meeting + delegate :schedule, to: :meeting + delegate :current_project, to: :table + + def instantiated? + meeting.present? + end + + def column_args(column) + if column == :title + { style: "grid-column: span 2" } + else + super + end + end + + def start_time + if instantiated? + 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 + + 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 + if start_time_changed? + old_time + simple_format("\n#{formatted_time(meeting.start_time)}") + else + formatted_time(model.start_time) + end + end + + def relative_time + 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 + return unless instantiated? + + 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(state) + + render(Primer::Beta::Label.new(title:, scheme:)) do + 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 + return unless copy_allowed? + return if instantiated? || cancelled? + + render( + Primer::Beta::Button.new( + scheme: :default, + size: :medium, + tag: :a, + data: { "turbo-method": "post" }, + href: init_recurring_meeting_path(model.recurring_meeting.id, start_time: model.start_time.iso8601) + ) + ) do |_c| + I18n.t(:label_recurring_meeting_create) + 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" + }) + + 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: { + data: { turbo: false } + }) do |item| + item.with_leading_visual_icon(icon: :download) + end + end + + def delete_action(menu) + return unless delete_allowed? && !cancelled? && instantiated? + + menu.with_item( + 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(:label_recurring_occurrence_delete_confirmation), turbo: false } + } + ) do |item| + item.with_leading_visual_icon(icon: :trash) + end + end + + def delete_scheduled_action(menu) + return unless delete_allowed? && !cancelled? && !instantiated? + + 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) + 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), + form_arguments: { + method: :post + } + ) do |item| + item.with_leading_visual_icon(icon: :history) + end + end + + def delete_allowed? + User.current.allowed_in_project?(:delete_meetings, project) + end + + def copy_allowed? + User.current.allowed_in_project?(:create_meetings, project) + end + + 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/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/show_page_header_component.html.erb b/modules/meeting/app/components/recurring_meetings/show_page_header_component.html.erb new file mode 100644 index 000000000000..93121f0e7d07 --- /dev/null +++ b/modules/meeting/app/components/recurring_meetings/show_page_header_component.html.erb @@ -0,0 +1,44 @@ +<%= render(Primer::OpenProject::PageHeader.new) do |header| + 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_view_template"), + mobile_icon: :eye, + size: :medium, + href: meeting_path(@meeting.template) + ) { I18n.t("recurring_meeting.template.label_view_template") } + + 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", + data: { + "test-selector": "recurring-meeting-action-menu" + } }) 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: polymorphic_path([@project, @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/show_page_header_component.rb b/modules/meeting/app/components/recurring_meetings/show_page_header_component.rb new file mode 100644 index 000000000000..20cc8aa68e4a --- /dev/null +++ b/modules/meeting/app/components/recurring_meetings/show_page_header_component.rb @@ -0,0 +1,90 @@ +# 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 ShowPageHeaderComponent < ApplicationComponent + include OpPrimer::ComponentHelpers + include ApplicationHelper + + def initialize(project: nil, meeting: nil) + super + + @project = project + @meeting = meeting + 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 + @meeting.present? ? "#{@meeting.title} (Meeting series)" : I18n.t(:label_recurring_meeting_plural) + end + + def page_description + @meeting.schedule_in_words + 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 + + 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/components/recurring_meetings/show_page_sub_header_component.html.erb b/modules/meeting/app/components/recurring_meetings/show_page_sub_header_component.html.erb new file mode 100644 index 000000000000..bb7af0046c89 --- /dev/null +++ b/modules/meeting/app/components/recurring_meetings/show_page_sub_header_component.html.erb @@ -0,0 +1,20 @@ +<%= 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(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 new file mode 100644 index 000000000000..8d7f8c856985 --- /dev/null +++ b/modules/meeting/app/components/recurring_meetings/show_page_sub_header_component.rb @@ -0,0 +1,57 @@ +# 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 ShowPageSubHeaderComponent < ApplicationComponent + # rubocop:enable OpenProject/AddPreviewForViewComponent + include ApplicationHelper + + def initialize(meeting:, params:, project: nil) + super + + @meeting = meeting + @project = project + @params = params + 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/table_component.rb b/modules/meeting/app/components/recurring_meetings/table_component.rb new file mode 100644 index 000000000000..cd446823d142 --- /dev/null +++ b/modules/meeting/app/components/recurring_meetings/table_component.rb @@ -0,0 +1,67 @@ +# 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 + options :current_project + + columns :start_time, :relative_time, :last_edited, :status, :create + + def has_actions? + true + end + + def header_args(column) + if column == :title + { style: "grid-column: span 2" } + else + super + 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, { caption: "" }] + ].compact + end + + def columns + @columns ||= headers.map(&:first) + 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 1592c764b24b..ff0ecac37477 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,8 @@ class CreateContract < BaseContract def self.assignable_meetings(user) StructuredMeeting .open + .not_templated + .not_cancelled .visible(user) end 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/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/contracts/recurring_meetings/base_contract.rb b/modules/meeting/app/contracts/recurring_meetings/base_contract.rb new file mode 100644 index 000000000000..56316cc19d61 --- /dev/null +++ b/modules/meeting/app/contracts/recurring_meetings/base_contract.rb @@ -0,0 +1,51 @@ +#-- 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 + attribute :interval + + # 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 new file mode 100644 index 000000000000..5b32613aa598 --- /dev/null +++ b/modules/meeting/app/contracts/recurring_meetings/create_contract.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 CreateContract < BaseContract + validate :user_allowed_to_add + + 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/meetings_controller.rb b/modules/meeting/app/controllers/meetings_controller.rb index 7a1f8801e56f..b708fac84c00 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 @@ -68,12 +69,16 @@ 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}" 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 @@ -127,7 +132,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 +144,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,15 +169,31 @@ 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 - def destroy - @meeting.destroy - flash[:notice] = I18n.t(:notice_successful_delete) - redirect_to action: "index", project_id: @project + def destroy # rubocop:disable Metrics/AbcSize + recurring = @meeting.recurring_meeting + + # rubocop:disable Rails/ActionControllerFlashBeforeRender + Meetings::DeleteService + .new(model: @meeting, user: User.current) + .call + .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 + + if recurring + redirect_to polymorphic_path([@project, recurring]), status: :see_other + else + redirect_to polymorphic_path([@project, :meetings]), status: :see_other + end end def edit @@ -306,19 +330,26 @@ 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_and_sort(query) + query.where("project_id", "=", @project.id) if @project query end + 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 + 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 @@ -329,11 +360,22 @@ 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 + end + def global_upcoming_meetings projects = Project.allowed_in_project(User.current, :view_meetings) @@ -349,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 @@ -365,6 +407,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 @@ -383,15 +428,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 @@ -449,4 +485,8 @@ def copy_attributes send_notifications: copy_param(:send_notifications) } end + + def prevent_template_destruction + render_400 if @meeting.templated? + 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..f84f380d548b --- /dev/null +++ b/modules/meeting/app/controllers/recurring_meetings_controller.rb @@ -0,0 +1,225 @@ +class RecurringMeetingsController < ApplicationController + include Layout + include PaginationHelper + include OpTurbo::ComponentStream + include OpTurbo::FlashStreamHelper + include OpTurbo::DialogStreamHelper + + 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] + before_action :get_scheduled_meeting, only: %i[delete_scheduled] + + before_action :convert_params, only: %i[create update] + + menu_item :meetings + + def index + @recurring_meetings = + if @project + RecurringMeeting.visible.where(project_id: @project.id) + 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 # rubocop:disable Metrics/AbcSize + @direction = params[:direction] + if params[:direction] == "past" + @meetings = @recurring_meeting + .scheduled_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 do + render :show, locals: { menu_name: project_or_global_menu } + end + end + end + + def init + call = ::RecurringMeetings::InitOccurrenceService + .new(user: current_user, recurring_meeting: @recurring_meeting) + .call(start_time: DateTime.iso8601(params[:start_time])) + + 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 details_dialog + respond_with_dialog Meetings::Index::DialogComponent.new( + meeting: @recurring_meeting, + project: @recurring_meeting.project + ) + end + + def create # rubocop:disable Metrics/AbcSize + call = ::RecurringMeetings::CreateService + .new(user: current_user) + .call(@converted_params) + + if call.success? + flash[:notice] = I18n.t(:notice_successful_create).html_safe + redirect_to polymorphic_path([@project, :meeting], { id: call.result.template.id }), + status: :see_other + else + 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 + + 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) + .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 + + def destroy + if @recurring_meeting.destroy + flash[:notice] = I18n.t(:notice_successful_delete) + else + flash[:error] = I18n.t(:error_failed_to_delete_entry) + end + + respond_to do |format| + format.html do + redirect_to polymorphic_path([@project, :meetings]) + end + end + end + + def delete_scheduled + if @scheduled.update(cancelled: true) + flash[:notice] = I18n.t(:notice_successful_cancel) + else + flash[:error] = I18n.t(:error_failed_to_delete_entry) + end + + redirect_to polymorphic_path([@project, @recurring_meeting]), status: :see_other + end + + private + + def upcoming_meetings + meetings = @recurring_meeting + .scheduled_instances(upcoming: true) + .index_by(&:start_time) + + merged = @recurring_meeting + .scheduled_occurrences(limit: 5) + .map do |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 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 + render_404 + end + + def find_meeting + @recurring_meeting = RecurringMeeting.visible.find(params[:id]) + rescue ActiveRecord::RecordNotFound + 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(:meeting) + .permit(:title, :location, :start_time_hour, :duration, :start_date, + :interval, :frequency, :end_after, :end_date, :iterations) + end + + 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/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/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..996b17e033b9 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.location + else + meeting.location + end + end end diff --git a/modules/meeting/app/forms/meeting/time_group.rb b/modules/meeting/app/forms/meeting/time_group.rb new file mode 100644 index 000000000000..20aee89aeb3b --- /dev/null +++ b/modules/meeting/app/forms/meeting/time_group.rb @@ -0,0 +1,64 @@ +#-- 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::TimeGroup < ApplicationForm + include Redmine::I18n + + form do |meeting_form| + 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(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 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/forms/recurring_meeting/end_after.rb b/modules/meeting/app/forms/recurring_meeting/end_after.rb new file mode 100644 index 000000000000..7b4725341663 --- /dev/null +++ b/modules/meeting/app/forms/recurring_meeting/end_after.rb @@ -0,0 +1,43 @@ +#-- 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: { + target_name: "end_after", + "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/recurring_meeting/frequency.rb b/modules/meeting/app/forms/recurring_meeting/frequency.rb new file mode 100644 index 000000000000..06992662d220 --- /dev/null +++ b/modules/meeting/app/forms/recurring_meeting/frequency.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. +#++ + +class RecurringMeeting::Frequency < ApplicationForm + form do |meeting_form| + 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.each_key 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/start_time.rb b/modules/meeting/app/forms/recurring_meeting/interval.rb similarity index 72% rename from modules/meeting/app/forms/meeting/start_time.rb rename to modules/meeting/app/forms/recurring_meeting/interval.rb index 36b43092839e..9d598246dd6b 100644 --- a/modules/meeting/app/forms/meeting/start_time.rb +++ b/modules/meeting/app/forms/recurring_meeting/interval.rb @@ -26,23 +26,13 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class Meeting::StartTime < ApplicationForm - include Redmine::I18n - +class RecurringMeeting::Interval < ApplicationForm 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 + name: :interval, + type: :number, + label: I18n.t("activerecord.attributes.recurring_meeting.interval"), + caption: I18n.t("recurring_meeting.interval.instructions") ) end - - def initialize(initial_value: DateTime.now.strftime("%H:%M")) - @initial_value = initial_value - end end diff --git a/modules/meeting/app/forms/recurring_meeting/iterations.rb b/modules/meeting/app/forms/recurring_meeting/iterations.rb new file mode 100644 index 000000000000..a9f1feb2c5af --- /dev/null +++ b/modules/meeting/app/forms/recurring_meeting/iterations.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. +#++ + +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/meeting/start_date.rb b/modules/meeting/app/forms/recurring_meeting/specific_date.rb similarity index 78% rename from modules/meeting/app/forms/meeting/start_date.rb rename to modules/meeting/app/forms/recurring_meeting/specific_date.rb index ff5824535374..8860012a3f74 100644 --- a/modules/meeting/app/forms/meeting/start_date.rb +++ b/modules/meeting/app/forms/recurring_meeting/specific_date.rb @@ -26,21 +26,24 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class Meeting::StartDate < ApplicationForm +class RecurringMeeting::SpecificDate < ApplicationForm form do |meeting_form| meeting_form.text_field( - name: :start_date, + name: :end_date, type: "date", - value: @initial_value, - placeholder: Meeting.human_attribute_name(:start_date), - label: Meeting.human_attribute_name(:start_date), + value: @value, + placeholder: Meeting.human_attribute_name(:end_date), + label: Meeting.human_attribute_name(:end_date), leading_visual: { icon: :calendar }, - required: true, + required: false, autofocus: false ) end - def initialize(initial_value: DateTime.now.strftime("%Y-%m-%d")) - @initial_value = initial_value + def initialize(meeting:) + super() + + end_time = meeting.end_date || 1.year.from_now + @value = end_time.strftime("%Y-%m-%d") end end diff --git a/modules/meeting/app/menus/meetings/menu.rb b/modules/meeting/app/menus/meetings/menu.rb index 9afeca06170d..a1de94d18fb0 100644 --- a/modules/meeting/app/menus/meetings/menu.rb +++ b/modules/meeting/app/menus/meetings/menu.rb @@ -27,42 +27,70 @@ # ++ 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(params:, project: nil) + super(view_type: nil, project:, params:) end 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 - 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 + my_meetings_href = polymorphic_path([project, :meetings]) [ - 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" }) - ] + 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 }) + ].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? + + 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 + invitation_filter = [{ invited_user_id: { operator: "=", values: [User.current.id.to_s] } }].to_json + [ - menu_item(title: I18n.t(:label_upcoming_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), + menu_item(title: I18n.t(:label_created_by_me), query_params: { filters: author_filter }) ] end @@ -89,5 +117,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/meeting.rb b/modules/meeting/app/models/meeting.rb index 27697a97fc57..8d3bf6814a05 100644 --- a/modules/meeting/app/models/meeting.rb +++ b/modules/meeting/app/models/meeting.rb @@ -27,13 +27,17 @@ #++ class Meeting < ApplicationRecord - include VirtualAttribute + include VirtualStartTime include OpenProject::Journal::AttachmentHelper self.table_name = "meetings" 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" has_many :contents, -> { readonly }, class_name: "MeetingContent" @@ -49,8 +53,22 @@ 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 :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]) } + + 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) @@ -89,24 +107,14 @@ 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: { open: 0, # 0 -> default, leave values for future states between open and closed + scheduled: 1, + cancelled: 4, closed: 5 } @@ -124,21 +132,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 @@ -159,6 +152,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 @@ -249,82 +246,8 @@ def allowed_participants .where(user_id: available_members) end - 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) @@ -332,12 +255,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, 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..68bbdf198e8b --- /dev/null +++ b/modules/meeting/app/models/meeting/virtual_start_time.rb @@ -0,0 +1,137 @@ +#-- 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 + before_save :update_start_time! + 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 + return @start_date if @start_date.is_a?(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/queries/meetings.rb b/modules/meeting/app/models/queries/meetings.rb index 1e73d1c94f52..e70e0704e12b 100644 --- a/modules/meeting/app/models/queries/meetings.rb +++ b/modules/meeting/app/models/queries/meetings.rb @@ -34,5 +34,8 @@ module Queries::Meetings filter Filters::InvitedUserFilter filter Filters::AuthorFilter filter Filters::DatesIntervalFilter + filter Filters::RecurringFilter + + order Orders::DefaultOrder end end 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..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,7 +40,10 @@ 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"), + "#{MeetingParticipant.table_name}.invited" + ].join(" AND ") end def joins @@ -50,8 +53,4 @@ def joins def self.key :invited_user_id end - - def available_operators - [::Queries::Operators::Equals] - end end diff --git a/modules/meeting/app/models/queries/meetings/filters/recurring_filter.rb b/modules/meeting/app/models/queries/meetings/filters/recurring_filter.rb new file mode 100644 index 000000000000..01279912b04d --- /dev/null +++ b/modules/meeting/app/models/queries/meetings/filters/recurring_filter.rb @@ -0,0 +1,51 @@ +#-- 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::RecurringFilter < Queries::Meetings::Filters::MeetingFilter + include Queries::Filters::Shared::BooleanFilter + + def self.key + :type + end + + def human_name + I18n.t("label_recurring_meeting_part_of") + end + + def available? + OpenProject::FeatureDecisions.recurring_meetings_active? + end + + def apply_to(query_scope) + if allowed_values.first.intersect?(values) + query_scope.recurring + else + query_scope.not_recurring + end + end +end 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/models/queries/meetings/meeting_query.rb b/modules/meeting/app/models/queries/meetings/meeting_query.rb index eb65640b5ea0..55b39517f656 100644 --- a/modules/meeting/app/models/queries/meetings/meeting_query.rb +++ b/modules/meeting/app/models/queries/meetings/meeting_query.rb @@ -41,7 +41,11 @@ def results end def default_scope - Meeting.visible(user) + Meeting + .not_templated + .not_cancelled + .visible(user) + .unscope(:order) # remove default scope order end 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 diff --git a/modules/meeting/app/models/recurring_meeting.rb b/modules/meeting/app/models/recurring_meeting.rb new file mode 100644 index 000000000000..a93e1bf1fc9a --- /dev/null +++ b/modules/meeting/app/models/recurring_meeting.rb @@ -0,0 +1,186 @@ +class RecurringMeeting < ApplicationRecord + include ::Meeting::VirtualStartTime + include Redmine::I18n + + 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? } + + validate :end_date_constraints, + if: -> { end_after_specific_date? } + + after_save :unset_schedule + + enum frequency: { + daily: 0, + working_days: 1, + weekly: 2 + }.freeze, _prefix: true, _default: "weekly" + + enum end_after: { + specific_date: 0, + iterations: 1 + }.freeze, _prefix: true, _default: "specific_date" + + has_many :meetings, + inverse_of: :recurring_meeting, + dependent: :destroy + + has_many :scheduled_meetings, + inverse_of: :recurring_meeting, + dependent: :delete_all + + has_one :template, -> { where(template: true) }, + class_name: "Meeting" + + scope :visible, ->(*args) { + includes(:project) + .references(:projects) + .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 human_frequency + I18n.t("recurring_meeting.frequency.#{frequency}") + end + + 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 date + start_time.day.ordinalize + end + + 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 # 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: format_date(last_occurrence)) + end + + def scheduled_occurrences(limit:) + schedule.next_occurrences(limit, Time.current) + end + + 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) + else + schedule.remaining_occurrences(Time.current) + end + end + + def scheduled_instances(upcoming: true) + filter_scope = upcoming ? :upcoming : :past + direction = upcoming ? :asc : :desc + + scheduled_meetings + .includes(:meeting) + .public_send(filter_scope) + .then { |o| filter_scope == :past ? o.not_cancelled : o } + .order(start_time: direction) + end + + private + + def unset_schedule + @schedule = nil + end + + 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...) + .pluck(:date) + .each do |date| + schedule.add_exception_time(date.to_time(:utc)) + end + end + + def frequency_rule + 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) + if end_after_iterations? + rule.count(iterations) + else + rule.until(end_date) + end + end +end diff --git a/modules/meeting/app/models/scheduled_meeting.rb b/modules/meeting/app/models/scheduled_meeting.rb new file mode 100644 index 000000000000..1b11cce49b07 --- /dev/null +++ b/modules/meeting/app/models/scheduled_meeting.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. +#++ + +class ScheduledMeeting < ApplicationRecord + belongs_to :meeting + belongs_to :recurring_meeting + + scope :upcoming, -> { where(start_time: Time.current..) } + 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 +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..55642242f49a --- /dev/null +++ b/modules/meeting/app/services/meetings/delete_service.rb @@ -0,0 +1,40 @@ +#-- 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 DeleteService < ::BaseServices::Delete + protected + + def after_validate(_, call) + schedule = model.scheduled_meeting + schedule.update_column(:cancelled, true) if schedule.present? + + call + end + end +end 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..a29d6af935a5 --- /dev/null +++ b/modules/meeting/app/services/recurring_meetings/create_service.rb @@ -0,0 +1,68 @@ +#-- 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 + include WithTemplate + + protected + + def after_perform(call) + return call unless call.success? + + recurring_meeting = call.result + call.merge! create_meeting_template(recurring_meeting) if call.success? + schedule_init_job(recurring_meeting) if call.success? + + call + end + + ## + # 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::InitNextOccurrenceJob + .set(wait_until: first_occurrence.to_time) + .perform_later(recurring_meeting) + end + + def create_meeting_template(recurring_meeting) + 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/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..098533a52948 --- /dev/null +++ b/modules/meeting/app/services/recurring_meetings/init_occurrence_service.rb @@ -0,0 +1,81 @@ +#-- 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(start_time:) + in_context(recurring_meeting, send_notifications: false) do + call = instantiate(start_time) + create_schedule(call) if call.success? + + call + end + end + + def instantiate(start_time) + ::Meetings::CopyService + .new(user:, model: recurring_meeting.template) + .call(attributes: instantiate_params(start_time), + copy_agenda: true, + copy_attachments: true, + send_notifications: false) + end + + def instantiate_params(start_time) + { + start_time:, + recurring_meeting: + } + end + + def create_schedule(call) + meeting = call.result + + schedule = ScheduledMeeting.find_or_initialize_by( + recurring_meeting: recurring_meeting, + start_time: meeting.start_time + ) + + unless schedule.update(meeting:, cancelled: false) + call.merge!(ServiceResult.failure(errors: schedule.errors)) + end + 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..9f232da2b303 --- /dev/null +++ b/modules/meeting/app/services/recurring_meetings/set_attributes_service.rb @@ -0,0 +1,49 @@ +#-- 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 + 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 + 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..80fcf24e97de --- /dev/null +++ b/modules/meeting/app/services/recurring_meetings/update_service.rb @@ -0,0 +1,86 @@ +#-- 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 + include WithTemplate + + protected + + def after_perform(call) + return call unless call.success? + + cleanup_cancelled_schedules(call.result) + reschedule_init_job(call.result) + 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 + + 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 + + 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/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/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/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 %> 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..8d4f0af4b8d5 --- /dev/null +++ b/modules/meeting/app/views/recurring_meetings/index.html.erb @@ -0,0 +1,37 @@ +<%#-- 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(Meetings::IndexPageHeaderComponent.new(project: @project)) %> + +<% if @recurring_meetings.empty? -%> + <%= no_results_box %> +<% else -%> + <%= render Meetings::TableComponent.new(rows: @recurring_meetings, current_project: @project) %> +<% 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/app/views/recurring_meetings/show.html.erb b/modules/meeting/app/views/recurring_meetings/show.html.erb new file mode 100644 index 000000000000..21fcdc45647c --- /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::ShowPageHeaderComponent.new(project: @project, meeting: @recurring_meeting)) %> +<%= render(RecurringMeetings::ShowPageSubHeaderComponent.new(project: @project, meeting: @recurring_meeting, params:)) %> + +<% if @recurring_meeting.nil? -%> + <%= no_results_box %> +<% else -%> + <%= render RecurringMeetings::TableComponent.new(rows: @meetings, current_project: @project) %> +<% 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 new file mode 100644 index 000000000000..8d818cad011e --- /dev/null +++ b/modules/meeting/app/workers/recurring_meetings/init_next_occurrence_job.rb @@ -0,0 +1,125 @@ +#-- 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.unique_key(arguments.first) } + ) + + def self.unique_key(recurring_meeting) + "RecurringMeetings::InitNextOccurrenceJob-#{recurring_meeting.id}" + end + + 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 + 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/config/locales/en.yml b/modules/meeting/config/locales/en.yml index c5c7f6beee96..437e2daea45b 100644 --- a/modules/meeting/config/locales/en.yml +++ b/modules/meeting/config/locales/en.yml @@ -61,10 +61,16 @@ en: presenter: "Presenter" meeting_section: title: "Title" + recurring_meeting: + frequency: "Frequency" + interval: "Interval" + end_after: "End after" + iterations: "Occurrences" errors: 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" @@ -105,6 +111,7 @@ en: label_meeting_plural: "Meetings" label_meeting_new: "New Meeting" label_meeting_new_dynamic: "New dynamic meeting" + label_meeting_new_recurring: "New recurring meeting" label_meeting_create: "Create meeting" label_meeting_copy: "Copy meeting" label_meeting_edit: "Edit Meeting" @@ -118,25 +125,52 @@ 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_part_of: "Part of a meeting series" + 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_copy: "Copy as one-off" + 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_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" + 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" + label_attended: "Attended" + label_created_by_me: "Created by me" label_notify: "Send for review" 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" 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}" @@ -165,6 +199,7 @@ en: classic: "Classic" classic_text: "Organize your meeting in a formattable text agenda and protocol." structured: "Dynamic" + 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}" @@ -175,6 +210,36 @@ en: placeholder_title: "New section" 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: + label_view_template: "View 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. + Changes will not affect past or already created meetings. + frequency: + every_weekday: "Every %{day_of_the_week}" + daily: "Daily" + working_days: "Working Days" + weekly: "Weekly" + end_after: + specific_date: "A specific date" + iterations: "A number of occurrences" + starts: "Starts" + in_words: + 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" 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." @@ -243,6 +308,10 @@ 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_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." diff --git a/modules/meeting/config/routes.rb b/modules/meeting/config/routes.rb index a22ef0828d5d..3be786f35dcc 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 destroy] end resources :work_packages, only: %i[] do @@ -55,6 +56,14 @@ resource :menu, only: %[show] end + resources :recurring_meetings do + member do + get :details_dialog + post :init + post :delete_scheduled + end + end + 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..6cbe26431257 --- /dev/null +++ b/modules/meeting/db/migrate/20240426073948_create_recurring_meetings.rb @@ -0,0 +1,19 @@ +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.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 } + + t.timestamps + end + + add_reference :meetings, :recurring_meeting, index: true + add_column :meetings, :template, :boolean, default: false, null: false + end +end 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 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..b9e9368521ca --- /dev/null +++ b/modules/meeting/db/migrate/20241128190428_create_scheduled_meetings.rb @@ -0,0 +1,22 @@ +class CreateScheduledMeetings < ActiveRecord::Migration[7.1] + def change + create_table :scheduled_meetings do |t| + 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.datetime :start_time, null: false + t.boolean :cancelled, default: false, null: false + + t.timestamps + end + + add_index :scheduled_meetings, + %i[recurring_meeting_id start_time], + unique: true + end +end diff --git a/modules/meeting/lib/open_project/meeting/engine.rb b/modules/meeting/lib/open_project/meeting/engine.rb index 4c60ed2fedef..a4fb9f2bf15b 100644 --- a/modules/meeting/lib/open_project/meeting/engine.rb +++ b/modules/meeting/lib/open_project/meeting/engine.rb @@ -44,23 +44,31 @@ 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], - "meetings/menus": %i[show] }, + { + meetings: %i[new create copy new_dialog], + recurring_meetings: %i[new create copy init], + "meetings/menus": %i[show] + }, permissible_on: :project, require: :member, contract_actions: { meetings: %i[create] } 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, require: :member permission :delete_meetings, - { meetings: [:destroy] }, + { + meetings: [:destroy], + recurring_meetings: %i[destroy delete_scheduled] + }, permissible_on: :project, require: :member permission :meetings_send_invite, 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 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 diff --git a/modules/meeting/spec/factories/meeting_factory.rb b/modules/meeting/spec/factories/meeting_factory.rb index 415af6bccb2d..68dd2c14897b 100644 --- a/modules/meeting/spec/factories/meeting_factory.rb +++ b/modules/meeting/spec/factories/meeting_factory.rb @@ -39,8 +39,19 @@ 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 |structured_meeting| + structured_meeting.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 new file mode 100644 index 000000000000..fc1188dd8430 --- /dev/null +++ b/modules/meeting/spec/factories/recurring_meeting_factory.rb @@ -0,0 +1,56 @@ +#-- 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| + project = evaluator.project + recurring_meeting.project = project + recurring_meeting.template = create(:structured_meeting_template, recurring_meeting:, project:) + end + + after(:stub) do |recurring_meeting, evaluator| + 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/factories/scheduled_meeting_factory.rb b/modules/meeting/spec/factories/scheduled_meeting_factory.rb new file mode 100644 index 000000000000..b963ce8333cd --- /dev/null +++ b/modules/meeting/spec/factories/scheduled_meeting_factory.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. +#++ + +FactoryBot.define do + factory :scheduled_meeting, class: "ScheduledMeeting" do + recurring_meeting + cancelled { false } + meeting { nil } + start_time { Date.tomorrow + 10.hours } + + trait :scheduled + + trait :cancelled do + cancelled { true } + end + + trait :persisted do + meeting factory: :structured_meeting + end + end +end 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 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/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..a06ce9be1882 --- /dev/null +++ b/modules/meeting/spec/features/recurring_meetings/recurring_meeting_create_spec.rb @@ -0,0 +1,112 @@ +#-- 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" + wait_for_network_idle + 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_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 new file mode 100644 index 000000000000..ad4e95347e54 --- /dev/null +++ b/modules/meeting/spec/features/recurring_meetings/recurring_meeting_crud_spec.rb @@ -0,0 +1,183 @@ +#-- 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 CRUD", + :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", + 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, + lastname: "Second", + member_with_permissions: { project => %i[view_meetings] }) + end + shared_let(:no_member_user) do + 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(:show_page) { Pages::RecurringMeeting::Show.new(meeting) } + let(:meetings_page) { Pages::Meetings::Index.new(project:) } + + before do + login_as current_user + + # 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 + show_page.visit! + + click_on "recurring-meeting-action-menu" + + accept_confirm(I18n.t("text_are_you_sure")) do + click_on "Delete meeting series" + end + + expect(page).to have_current_path meetings_path # check path + end + + it "can use the 'Create from template' button" do + show_page.visit! + + 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: "01/07/2025 01:30 PM" + show_page.expect_open_meeting date: "01/07/2025 01:30 PM" + end + + it "can cancel an occurrence" do + show_page.visit! + + accept_confirm(I18n.t(:label_recurring_occurrence_delete_confirmation)) do + show_page.cancel_occurrence date: "12/31/2024 01:30 PM" + end + + expect_flash(type: :success, message: "Successful cancellation.") + + 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" + end + + 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/14/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") + + 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" + end + + it "shows the correct actions based on status" 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" + + 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(: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 + + 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/features/structured_meetings/work_package_meetings_tab_spec.rb b/modules/meeting/spec/features/structured_meetings/work_package_meetings_tab_spec.rb index a853bd2e4e0e..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 @@ -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") } @@ -256,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"] } # rubocop:disable Rails/Pluck + expect(meeting_containers).to contain_exactly("op-meeting-container-#{first_meeting.id}", + "op-meeting-container-#{second_meeting.id}") end end @@ -339,11 +343,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 @@ -352,11 +355,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 @@ -370,7 +372,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) @@ -409,7 +412,9 @@ retry_block do click_on("Save") - expect(page).to have_content("Meeting can't be blank") + wait_for_network_idle + + raise "Expected error message to be shown" unless page.has_content?("Meeting can't be blank") end end @@ -421,7 +426,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/models/recurring_meeting_spec.rb b/modules/meeting/spec/models/recurring_meeting_spec.rb new file mode 100644 index 000000000000..415b9c9c2472 --- /dev/null +++ b/modules/meeting/spec/models/recurring_meeting_spec.rb @@ -0,0 +1,130 @@ +require "spec_helper" +require_module_spec_helper + +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 + + 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 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/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..deb3eba6caf8 --- /dev/null +++ b/modules/meeting/spec/services/recurring_meetings/create_service_integration_spec.rb @@ -0,0 +1,87 @@ +#-- 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(:service_result) { subject } + let(:series) { service_result.result } + let(:params) { {} } + + subject { instance.call(**params) } + + 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 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(1) + expect(series.meetings.first).to be_template + 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 { subject }.not_to have_enqueued_job(RecurringMeetings::InitNextOccurrenceJob) + expect(service_result).not_to be_success + expect(series).to be_new_record + end + 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..11aab02ae46e --- /dev/null +++ b/modules/meeting/spec/services/recurring_meetings/update_service_integration_spec.rb @@ -0,0 +1,122 @@ +#-- 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 edit_meetings) }) + end + shared_let(:series, refind: true) do + create(:recurring_meeting, + project:, + start_time: Time.zone.today + 10.hours, + frequency: "daily", + interval: 1, + end_after: "specific_date", + end_date: 1.month.from_now) + end + + let(:instance) { described_class.new(model: series, user:) } + let(:params) { {} } + + let(:service_result) { instance.call(**params) } + let(:updated_meeting) { service_result.result } + + 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 + + context "when updating the start_date to the same time" do + let(:params) do + { 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.day + 10.hours) + + expect { scheduled_meeting.reload }.not_to raise_error + end + end + + context "when updating the start_date to further in the future" do + let(:params) do + { start_date: Time.zone.today + 2.days } + end + + 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) + + expect { scheduled_meeting.reload }.to raise_error(ActiveRecord::RecordNotFound) + 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 diff --git a/modules/meeting/spec/support/pages/meetings/index.rb b/modules/meeting/spec/support/pages/meetings/index.rb index e8633c583891..40fcfdf69d41 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, @@ -137,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) 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..f3319dac3318 --- /dev/null +++ b/modules/meeting/spec/support/pages/recurring_meeting/show.rb @@ -0,0 +1,171 @@ +#-- 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/base" + +module Pages::RecurringMeeting + class Show < ::Pages::Meetings::Base + attr_accessor :meeting + + def initialize(meeting) + super + + self.meeting = meeting + end + + 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") + 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 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) + 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" + end + end + + def cancel_occurrence(date:) + within("li", text: date) do + click_on "more-button" + click_on "Cancel this occurrence" + end + end + + def expect_subtitle(text:) + expect(page).to have_css(".PageHeader-description", text: text) + end + + def edit_meeting_series + page.find_test_selector("recurring-meeting-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_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") + end + 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") + + # Close it again + click_on "more-button" + 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: "Cancel this occurrence") + + # Close it again + click_on "more-button" + 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") + + # Close it again + click_on "more-button" + end + end + + # def for_meeting(date:, &) + # within("li", text: date, &) + # end + 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 118b7b9a53ee..bf081bafa8cb 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) - 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") + def fill_and_submit_meeting_dialog(meeting, notes, counter) + 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 + raise "Expected counter to eq #{counter}" unless page.has_content?(counter) + end + end end private 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 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')