diff --git a/modules/meeting/app/components/meeting_agenda_items/blank_slate_component.html.erb b/modules/meeting/app/components/meeting_agenda_items/blank_slate_component.html.erb new file mode 100644 index 000000000000..3380f57b5620 --- /dev/null +++ b/modules/meeting/app/components/meeting_agenda_items/blank_slate_component.html.erb @@ -0,0 +1,30 @@ +<%= + render(Primer::Beta::Blankslate.new) do |component| + component.with_visual_icon(icon: :book) + component.with_heading(tag: :h2).with_content(title) + component.with_description do + flex_layout do |flex| + if template? + flex.with_row(mb: 2) do + render(Primer::Beta::Text.new(color: :subtle)) { t(:"recurring_meeting.template.description") } + end + end + + flex.with_row(mb: 2) do + render(Primer::Beta::Text.new(color: :subtle)) { t(:text_meeting_empty_description_1) } + end + flex.with_row do + render(Primer::Beta::Text.new(color: :subtle)) { t(:text_meeting_empty_description_2) } + end + if can_finalize_template? + flex.with_row(mt: 2) do + render(Primer::Beta::Text.new(font_weight: :bold, color: :subtle)) do + t(:"recurring_meeting.template.blankslate_finalize", + button_title: t("recurring_meeting.template.button_finalize")) + end + end + end + end + end + end +%> diff --git a/modules/meeting/app/components/meeting_agenda_items/blank_slate_component.rb b/modules/meeting/app/components/meeting_agenda_items/blank_slate_component.rb new file mode 100644 index 000000000000..4ca1a3fd17e5 --- /dev/null +++ b/modules/meeting/app/components/meeting_agenda_items/blank_slate_component.rb @@ -0,0 +1,59 @@ +#-- 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 MeetingAgendaItems + class BlankSlateComponent < ApplicationComponent + include ApplicationHelper + include OpTurbo::Streamable + include OpPrimer::ComponentHelpers + + attr_reader :meeting + + def initialize(meeting:) + super + + @meeting = meeting + end + + delegate :template?, to: :meeting + + def title + if template? + t(:"recurring_meeting.template.blank_title") + else + t(:text_meeting_empty_heading) + end + end + + def can_finalize_template? + template? && + User.current.allowed_in_project?(:create_meetings, @meeting.project) && + @meeting.recurring_meeting.scheduled_meetings.none? + end + end +end 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 d418ed0d9533..3941939e1767 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 @@ -28,20 +28,7 @@ border_box.with_body( scheme: :default ) do - render(Primer::Beta::Blankslate.new) do |component| - component.with_visual_icon(icon: :book) - component.with_heading(tag: :h2).with_content(t("text_meeting_empty_heading")) - component.with_description do - flex_layout do |flex| - flex.with_row(mb: 2) do - render(Primer::Beta::Text.new(color: :subtle)) { t("text_meeting_empty_description_1") } - end - flex.with_row do - render(Primer::Beta::Text.new(color: :subtle)) { t("text_meeting_empty_description_2") } - end - end - end - end + render(MeetingAgendaItems::BlankSlateComponent.new(meeting: @meeting)) end end border_box.with_row(p: 0, border_top: 0) do diff --git a/modules/meeting/app/components/meetings/header_component.html.erb b/modules/meeting/app/components/meetings/header_component.html.erb index f434c2a4f07a..844cc6970163 100644 --- a/modules/meeting/app/components/meetings/header_component.html.erb +++ b/modules/meeting/app/components/meetings/header_component.html.erb @@ -30,6 +30,21 @@ end header.with_breadcrumbs(breadcrumb_items) header.with_description { render(Meetings::HeaderInfolineComponent.new(@meeting)) } + if finish_setup_enabled? + header.with_action_button( + tag: :a, + scheme: :primary, + mobile_label: I18n.t("recurring_meeting.template.button_finalize"), + mobile_icon: :check, + size: :medium, + data: { "turbo-method": :post }, + href: template_completed_recurring_meeting_path(@meeting.recurring_meeting), + ) do |button| + button.with_leading_visual_icon(icon: :check) + I18n.t("recurring_meeting.template.button_finalize") + end + end + header.with_action_menu(menu_arguments: {}, button_arguments: { icon: "kebab-horizontal", "aria-label": t("label_meeting_actions"), diff --git a/modules/meeting/app/components/meetings/header_component.rb b/modules/meeting/app/components/meetings/header_component.rb index 751a02d4929f..38c7cf45bc2c 100644 --- a/modules/meeting/app/components/meetings/header_component.rb +++ b/modules/meeting/app/components/meetings/header_component.rb @@ -55,7 +55,13 @@ def check_for_updates_interval private def delete_enabled? - !@meeting.templated? && User.current.allowed_in_project?(:delete_meetings, @meeting.project) + !@meeting.template? && User.current.allowed_in_project?(:delete_meetings, @meeting.project) + end + + def finish_setup_enabled? + @meeting.template? && + User.current.allowed_in_project?(:create_meetings, @meeting.project) && + @series.scheduled_meetings.none? end def breadcrumb_items diff --git a/modules/meeting/app/controllers/recurring_meetings_controller.rb b/modules/meeting/app/controllers/recurring_meetings_controller.rb index f84f380d548b..87e44a178686 100644 --- a/modules/meeting/app/controllers/recurring_meetings_controller.rb +++ b/modules/meeting/app/controllers/recurring_meetings_controller.rb @@ -5,13 +5,14 @@ class RecurringMeetingsController < ApplicationController include OpTurbo::FlashStreamHelper include OpTurbo::DialogStreamHelper - before_action :find_meeting, only: %i[show update details_dialog destroy edit init delete_scheduled] + before_action :find_meeting, only: %i[show update details_dialog destroy edit init delete_scheduled template_completed] 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] + before_action :check_template_completable, only: %i[template_completed] menu_item :meetings @@ -142,6 +143,20 @@ def destroy end end + def template_completed + call = ::RecurringMeetings::InitOccurrenceService + .new(user: current_user, recurring_meeting: @recurring_meeting) + .call(start_time: @first_occurrence.to_time) + + if call.success? + flash[:success] = I18n.t("recurring_meeting.occurrence.first_created") + else + flash[:error] = call.message + end + + redirect_to action: :show, id: @recurring_meeting, status: :see_other + end + def delete_scheduled if @scheduled.update(cancelled: true) flash[:notice] = I18n.t(:notice_successful_cancel) @@ -222,4 +237,23 @@ def structured_meeting_params .require(:structured_meeting) end end + + def check_template_completable + @first_occurrence = @recurring_meeting.next_occurrence&.to_time + if @first_occurrence.nil? + render_400(message: I18n.t("recurring_meeting.occurrence.error_no_next")) + return + end + + is_scheduled = @recurring_meeting + .scheduled_meetings + .where(start_time: @first_occurrence) + .where.not(meeting_id: nil) + .exists? + + if is_scheduled + flash[:info] = I18n.t("recurring_meeting.occurrence.first_already_exists") + redirect_to action: :show, status: :see_other + end + end end diff --git a/modules/meeting/config/locales/en.yml b/modules/meeting/config/locales/en.yml index 437e2daea45b..4e623130e279 100644 --- a/modules/meeting/config/locales/en.yml +++ b/modules/meeting/config/locales/en.yml @@ -216,7 +216,20 @@ en: Enter the number of days or weeks between each occurrence. occurrence: infoline: "This meeting is part of a recurring meeting series." + error_no_next: "There is no next occurrence for this meeting." + first_already_exists: "The first occurrence of this meeting series is already instantiated." + first_created: > + The first meeting has been successfuly created from template. + All future meetings will be created automatically at the time of the previous occurrence. template: + button_finalize: "Finish template" + blank_title: "Your meeting series template is empty" + description: > + This template will be used whenever new meetings in the series get created. + You can add agenda items, participants, and attachments to this template. + blankslate_finalize: > + When you're done preparing this template, + click the '%{button_title}' button above to finish the setup and schedule the first meeting of the series. label_view_template: "View template" label_edit_template: "Edit template" banner_html: > diff --git a/modules/meeting/config/routes.rb b/modules/meeting/config/routes.rb index 3be786f35dcc..cde421953dc2 100644 --- a/modules/meeting/config/routes.rb +++ b/modules/meeting/config/routes.rb @@ -61,6 +61,7 @@ get :details_dialog post :init post :delete_scheduled + post :template_completed end end diff --git a/modules/meeting/lib/open_project/meeting/engine.rb b/modules/meeting/lib/open_project/meeting/engine.rb index a4fb9f2bf15b..a822c4e11b5f 100644 --- a/modules/meeting/lib/open_project/meeting/engine.rb +++ b/modules/meeting/lib/open_project/meeting/engine.rb @@ -50,7 +50,7 @@ class Engine < ::Rails::Engine permission :create_meetings, { meetings: %i[new create copy new_dialog], - recurring_meetings: %i[new create copy init], + recurring_meetings: %i[new create copy init template_completed], "meetings/menus": %i[show] }, permissible_on: :project, diff --git a/modules/meeting/spec/factories/recurring_meeting_factory.rb b/modules/meeting/spec/factories/recurring_meeting_factory.rb index 204860667b8e..770f69bb6515 100644 --- a/modules/meeting/spec/factories/recurring_meeting_factory.rb +++ b/modules/meeting/spec/factories/recurring_meeting_factory.rb @@ -44,7 +44,12 @@ after(:create) do |recurring_meeting, evaluator| project = evaluator.project recurring_meeting.project = project - recurring_meeting.template = create(:structured_meeting_template, recurring_meeting:, project:) + + # create template + template = create(:structured_meeting_template, recurring_meeting:, project:) + + # create agenda item + create(:meeting_agenda_item, meeting: template, title: "My template item") end end end diff --git a/modules/meeting/spec/features/meetings_close_spec.rb b/modules/meeting/spec/features/meetings_close_spec.rb index 049571bead0e..46b1351e5e7c 100644 --- a/modules/meeting/spec/features/meetings_close_spec.rb +++ b/modules/meeting/spec/features/meetings_close_spec.rb @@ -58,6 +58,7 @@ # Go to minutes, expect uneditable find(".op-tab-row--link", text: "MINUTES").click + wait_for_network_idle expect(page).to have_css(".button", text: "Close the agenda to begin the Minutes") # Close the meeting diff --git a/modules/meeting/spec/features/recurring_meetings/recurring_meeting_create_spec.rb b/modules/meeting/spec/features/recurring_meetings/recurring_meeting_create_spec.rb index c9facc14b36d..d7fdb139152b 100644 --- a/modules/meeting/spec/features/recurring_meetings/recurring_meeting_create_spec.rb +++ b/modules/meeting/spec/features/recurring_meetings/recurring_meeting_create_spec.rb @@ -85,12 +85,18 @@ wait_for_network_idle expect_and_dismiss_flash(type: :success, message: "Successful creation.") + # Use is redirected to the template + expect(page).to have_current_path(project_meeting_path(project, meeting.template)) + expect(page).to have_content(I18n.t("recurring_meeting.template.description")) + expect(page).to have_link("Finish template") + + click_link_or_button "Finish template" + # 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" diff --git a/modules/meeting/spec/requests/recurring_meetings/recurring_meetings_template_completed_spec.rb b/modules/meeting/spec/requests/recurring_meetings/recurring_meetings_template_completed_spec.rb new file mode 100644 index 000000000000..f2beccd77791 --- /dev/null +++ b/modules/meeting/spec/requests/recurring_meetings/recurring_meetings_template_completed_spec.rb @@ -0,0 +1,120 @@ +#-- 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 complete template", + :skip_csrf, + type: :rails_request, + with_settings: { date_format: "%Y-%m-%d" } 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: DateTime.parse("2024-12-05T10:00:00Z"), + frequency: "daily" + end + + let(:current_user) { user } + let(:show_page) { Pages::RecurringMeeting::Show.new(recurring_meeting).with_capybara_page(page) } + let(:request) do + post template_completed_recurring_meeting_path(recurring_meeting) + end + + before do + login_as(current_user) + end + + context "when first occurrence is not existing" do + it "instantiates the first occurrence from template" do + expect { request }.to change(recurring_meeting.scheduled_meetings, :count).by(1) + expect(response).to be_redirect + + expect(recurring_meeting.scheduled_meetings.count).to eq(1) + first = recurring_meeting.scheduled_meetings.first + expect(first.start_time).to eq(DateTime.parse("2024-12-05T10:00:00Z")) + expect(first.start_time).to eq(recurring_meeting.first_occurrence.to_time) + + meeting = first.meeting + expect(meeting.agenda_items.count).to eq(1) + expect(meeting.agenda_items.first.title).to eq("My template item") + end + end + + context "when first occurrence is already created" do + let!(:meeting) { create(:structured_meeting, recurring_meeting:, start_time: recurring_meeting.start_time) } + let!(:schedule) do + create :scheduled_meeting, + meeting:, + recurring_meeting:, + start_time: recurring_meeting.start_time + end + + it "does not create a new meeting" do + expect { request }.not_to change(recurring_meeting.scheduled_meetings, :count) + expect(response).to be_redirect + + expect(recurring_meeting.scheduled_meetings.count).to eq(1) + first = recurring_meeting.scheduled_meetings.first.meeting + expect(first).to eq(meeting) + end + end + + context "when first occurrence is cancelled" do + let!(:schedule) do + create :scheduled_meeting, + :cancelled, + recurring_meeting:, + start_time: recurring_meeting.start_time + end + + it "takes over that occurrence" do + expect { request }.to change(recurring_meeting.meetings, :count).by(1) + expect(response).to be_redirect + + expect(recurring_meeting.scheduled_meetings.count).to eq(1) + first = recurring_meeting.scheduled_meetings.first + expect(first).not_to be_cancelled + expect(first.meeting).to be_present + end + end + + context "when user has no permissions to access" do + let(:current_user) { create(:user) } + + it "does not authorize" do + request + expect(response).to have_http_status(:not_found) + end + end +end