Merge pull request #21826 from opf/bug/71089-move-to-next-meeting-and-duplicate-in-next-meeting-select-cancelled-meeting

[#71089] "Move to next meeting" and "Duplicate in next meeting" select cancelled meeting
This commit is contained in:
Mir Bhatia
2026-02-03 12:50:45 +01:00
committed by GitHub
10 changed files with 212 additions and 30 deletions
@@ -32,7 +32,7 @@ See COPYRIGHT and LICENSE files for more details.
dialog.with_show_button { title }
dialog.with_feedback_message(icon_arguments: { icon: :none }) do |message|
message.with_heading(tag: :h2).with_content(title)
message.with_description_content(confirmation_message)
message.with_description_content(simple_format(confirmation_message))
end
dialog.with_footer do
concat(
@@ -33,11 +33,12 @@ module MeetingAgendaItems
include ApplicationHelper
include OpTurbo::Streamable
def initialize(agenda_item:, datetime:)
def initialize(agenda_item:, datetime:, skipped: nil)
super
@agenda_item = agenda_item
@datetime = datetime
@skipped = skipped
end
private
@@ -47,11 +48,25 @@ module MeetingAgendaItems
def title = I18n.t(:label_agenda_item_duplicate_in_next_title)
def confirmation_message
I18n.t(
base_message = I18n.t(
:text_agenda_item_duplicate_in_next_meeting,
date: format_date(@datetime),
time: format_time(@datetime, include_date: false)
)
if @skipped.present?
"#{base_message}\n\n#{skipped_message}"
else
base_message
end
end
def skipped_message
if @skipped.one?
I18n.t(:text_agenda_item_dialog_skipping_cancelled_one, date: format_date(DateTime.iso8601(@skipped.first)))
else
I18n.t(:text_agenda_item_dialog_skipping_cancelled_many, count: @skipped.size)
end
end
end
end
@@ -233,14 +233,19 @@ module MeetingAgendaItems
def next_meeting_action_item(menu, label:, action:, icon:)
return unless has_next_occurrence?
next_date = @series.next_occurrence(from_time: next_occurrence_from_time)
from_time = @meeting.start_time.past? ? Time.current : @meeting.start_time
result = @series.first_non_cancelled_occurrence(from_time:)
return if result.nil?
next_date = result[:occurrence]
skipped_dates = result[:skipped]
menu.with_item(
label:,
tag: :button,
content_arguments: { data: {
action: "click->meetings--submit#intercept",
href: path_for_next_button(action: action, next_date: next_date),
href: path_for_next_button(action:, next_date:, skipped_dates:),
method: "GET"
} }
) do |item|
@@ -424,18 +429,22 @@ module MeetingAgendaItems
end
end
def path_for_next_button(action:, next_date:)
def path_for_next_button(action:, next_date:, skipped_dates:)
skipped_iso_dates = skipped_dates.map(&:iso8601) if skipped_dates.present?
case action
when :move_to_next
move_to_next_dialog_project_meeting_agenda_item_path(@meeting.project,
@meeting,
@meeting_agenda_item,
datetime: next_date.iso8601)
datetime: next_date.iso8601,
skipped: skipped_iso_dates)
when :duplicate_in_next
duplicate_in_next_dialog_project_meeting_agenda_item_path(@meeting.project,
@meeting,
@meeting_agenda_item,
datetime: next_date.iso8601)
datetime: next_date.iso8601,
skipped: skipped_iso_dates)
end
end
end
@@ -47,7 +47,7 @@ See COPYRIGHT and LICENSE files for more details.
) do |dialog|
dialog.with_confirmation_message do |message|
message.with_heading(tag: :h2) { title }
message.with_description_content(confirmation_message)
message.with_description_content(simple_format(confirmation_message))
end
end
%>
@@ -33,11 +33,12 @@ module MeetingAgendaItems
include ApplicationHelper
include OpTurbo::Streamable
def initialize(agenda_item:, datetime:)
def initialize(agenda_item:, datetime:, skipped: nil)
super
@agenda_item = agenda_item
@datetime = datetime
@skipped = skipped
end
private
@@ -45,11 +46,25 @@ module MeetingAgendaItems
def title = I18n.t(:label_agenda_item_move_to_next_title)
def confirmation_message
I18n.t(
base_message = I18n.t(
:text_agenda_item_move_next_meeting,
date: format_date(@datetime),
time: format_time(@datetime, include_date: false)
)
if @skipped.present?
"#{base_message}\n\n#{skipped_message}"
else
base_message
end
end
def skipped_message
if @skipped.one?
I18n.t(:text_agenda_item_dialog_skipping_cancelled_one, date: format_date(DateTime.iso8601(@skipped.first)))
else
I18n.t(:text_agenda_item_dialog_skipping_cancelled_many, count: @skipped.size)
end
end
end
end
@@ -221,14 +221,16 @@ class MeetingAgendaItemsController < ApplicationController
def move_to_next_meeting_dialog
respond_with_dialog MeetingAgendaItems::MoveToNextMeetingDialogComponent.new(
agenda_item: @meeting_agenda_item,
datetime: params[:datetime]
datetime: params[:datetime],
skipped: params[:skipped]
)
end
def duplicate_in_next_meeting_dialog
respond_with_dialog MeetingAgendaItems::DuplicateInNextMeetingDialogComponent.new(
agenda_item: @meeting_agenda_item,
datetime: params[:datetime]
datetime: params[:datetime],
skipped: params[:skipped]
)
end
@@ -240,7 +242,7 @@ class MeetingAgendaItemsController < ApplicationController
if update_call.success?
render_success_flash_message_via_turbo_stream(
message: I18n.t(:text_agenda_item_moved_to_next_meeting, date: format_date(next_occurrence.start_time))
message: message_for_next_meeting_action(:text_agenda_item_moved_to_next_meeting, next_occurrence)
)
remove_item_via_turbo_stream(clear_slate: @meeting.agenda_items.empty?)
update_header_component_via_turbo_stream
@@ -259,7 +261,7 @@ class MeetingAgendaItemsController < ApplicationController
if duplicate_call.success?
close_dialog_via_turbo_stream("#duplicate-in-next-meeting-dialog")
render_success_flash_message_via_turbo_stream(
message: I18n.t(:text_agenda_item_duplicated_in_next_meeting, date: format_date(next_occurrence.start_time))
message: message_for_next_meeting_action(:text_agenda_item_duplicated_in_next_meeting, next_occurrence)
)
update_header_component_via_turbo_stream
respond_with_turbo_streams
@@ -376,13 +378,23 @@ class MeetingAgendaItemsController < ApplicationController
def find_existing_occurrence
next_occurrence = @series.scheduled_meetings.find_by(start_time: @next_meeting_time)
return if next_occurrence.nil?
if next_occurrence.cancelled?
respond_with_flash_error(message: I18n.t(:text_agenda_item_move_next_meeting_cancelled))
else
@next_occurrence = next_occurrence.meeting
if next_occurrence&.cancelled?
result = @series.first_non_cancelled_occurrence(from_time: @next_meeting_time)
if result.nil?
return respond_with_flash_error(message: I18n.t(:text_agenda_item_no_available_occurrence))
end
@next_meeting_time = result[:occurrence]
next_occurrence = @series.scheduled_meetings.find_by(start_time: @next_meeting_time)
end
@next_occurrence = next_occurrence&.meeting
end
def message_for_next_meeting_action(base_key, next_occurrence)
I18n.t(base_key, date: format_date(next_occurrence.start_time))
end
def assign_drop_params # rubocop:disable Metrics/AbcSize
@@ -256,6 +256,22 @@ class RecurringMeeting < ApplicationRecord
schedule.next_occurrence(from_time)&.to_time
end
def first_non_cancelled_occurrence(from_time: Time.current)
skipped = []
time = from_time
while (occurrence = next_occurrence(from_time: time))
if scheduled_meetings.cancelled.exists?(start_time: occurrence)
skipped << occurrence
time = occurrence
else
return { occurrence:, skipped: }
end
end
nil
end
def previous_occurrence(from_time: Time.current)
schedule.previous_occurrence(from_time)&.to_time
end
+3 -1
View File
@@ -683,7 +683,9 @@ en:
text_agenda_item_duplicate_in_next_meeting: "Are you sure you want to add a copy of this agenda item to the next meeting, on %{date} at %{time}? Outcomes will not be duplicated."
text_agenda_item_duplicated_in_next_meeting: "Agenda item duplicated in the next meeting, on %{date}"
text_work_package_has_no_upcoming_meeting_agenda_items: "This work package is not scheduled in an upcoming meeting agenda yet."
text_agenda_item_move_next_meeting_cancelled: "Unable to move to the next meeting since it has been cancelled."
text_agenda_item_no_available_occurrence: "All upcoming occurrences have been cancelled."
text_agenda_item_dialog_skipping_cancelled_one: "Note: Skipping cancelled occurrence on %{date}."
text_agenda_item_dialog_skipping_cancelled_many: "Note: Skipping %{count} cancelled occurrences."
text_work_package_add_to_meeting_hint: 'Use the "Add to meeting" button to add this work package to an upcoming meeting.'
text_work_package_has_no_past_meeting_agenda_items: "This work package was not added as an agenda item in a past meeting."
@@ -132,23 +132,92 @@ RSpec.describe "Recurring meetings duplicate in next meeting", :js do
context "with manage_agendas permission, but next occurrence is cancelled" do
let(:current_user) { user_with_manage_permissions }
let(:first_occurrence_time) { series.next_occurrence(from_time: Time.current) }
let(:second_occurrence_time) { series.next_occurrence(from_time: first_occurrence_time) }
let!(:next_meeting) { nil }
let!(:target_meeting) do
RecurringMeetings::InitNextOccurrenceJob.perform_now(series, second_occurrence_time)
series.meetings.not_templated.find_by(start_time: second_occurrence_time)
end
let!(:cancelled_occurrence) do
create(:scheduled_meeting,
:cancelled,
recurring_meeting: series,
start_time: series.next_occurrence)
start_time: first_occurrence_time)
end
it "shows an error message" do
let(:target_meeting_page) { Pages::Meetings::Show.new(target_meeting) }
it "skips the cancelled occurrence and duplicates to the next available one" do
meeting_page.visit!
meeting_page.expect_agenda_item(title: "Test agenda item")
meeting_page.duplicate_item_in_next_meeting(agenda_item)
meeting_page.open_menu(agenda_item) do
click_on "Duplicate"
click_on "Duplicate in next occurrence"
end
expect(page).to have_text "Unable to move to the next meeting since it has been cancelled."
expect(page).to have_text("Duplicate in next occurrence?")
expect(page).to have_text("Note: Skipping cancelled occurrence")
page.within_modal "Duplicate in next occurrence?" do
click_on "Duplicate"
end
expect_and_dismiss_flash(message: "Agenda item duplicated in the next meeting")
target_meeting_page.visit!
target_meeting_page.expect_agenda_item(title: "Test agenda item")
end
end
context "with manage_agendas permission, but multiple next occurrences are cancelled" do
let(:current_user) { user_with_manage_permissions }
let(:first_occurrence_time) { series.next_occurrence(from_time: Time.current) }
let(:second_occurrence_time) { series.next_occurrence(from_time: first_occurrence_time) }
let(:third_occurrence_time) { series.next_occurrence(from_time: second_occurrence_time) }
let!(:target_meeting) do
RecurringMeetings::InitNextOccurrenceJob.perform_now(series, third_occurrence_time)
series.meetings.not_templated.find_by(start_time: third_occurrence_time)
end
let!(:first_cancelled_occurrence) do
create(:scheduled_meeting,
:cancelled,
recurring_meeting: series,
start_time: first_occurrence_time)
end
let!(:second_cancelled_occurrence) do
create(:scheduled_meeting,
:cancelled,
recurring_meeting: series,
start_time: second_occurrence_time)
end
let(:target_meeting_page) { Pages::Meetings::Show.new(target_meeting) }
it "skips all cancelled occurrences and shows the count in the dialog" do
meeting_page.visit!
meeting_page.expect_agenda_item(title: "Test agenda item")
meeting_page.open_menu(agenda_item) do
click_on "Duplicate"
click_on "Duplicate in next occurrence"
end
expect(page).to have_text("Duplicate in next occurrence?")
expect(page).to have_text("Note: Skipping 2 cancelled occurrences")
page.within_modal "Duplicate in next occurrence?" do
click_on "Duplicate"
end
expect_and_dismiss_flash(message: "Agenda item duplicated in the next meeting")
target_meeting_page.visit!
target_meeting_page.expect_agenda_item(title: "Test agenda item")
end
end
@@ -102,20 +102,64 @@ RSpec.describe "Recurring meetings move to next meeting", :js do
context "with manage_agendas permission, but next occurrence is cancelled" do
let(:current_user) { user_with_manage_permissions }
let(:first_occurrence_time) { series.next_occurrence(from_time: Time.current) }
let!(:cancelled_occurrence) do
create(:scheduled_meeting,
:cancelled,
recurring_meeting: series,
start_time: series.next_occurrence(from_time: Time.current))
start_time: first_occurrence_time)
end
it "shows the move to next meeting option" do
it "skips the cancelled occurrence and moves to the next available one" do
meeting_page.visit!
meeting_page.expect_agenda_item(title: "Test notes")
meeting_page.move_item_to_next_meeting(agenda_item)
meeting_page.select_action(agenda_item, "Move to next meeting")
expect(page).to have_text "Unable to move to the next meeting since it has been cancelled."
expect(page).to have_text("Move to next meeting?")
expect(page).to have_text("Note: Skipping cancelled occurrence")
page.within_modal "Move to next meeting?" do
click_on "Move"
end
expect_and_dismiss_flash(message: "Agenda item moved to the next meeting")
meeting_page.expect_no_agenda_item(title: "Test notes")
end
end
context "with manage_agendas permission, but multiple next occurrences are cancelled" do
let(:current_user) { user_with_manage_permissions }
let(:first_occurrence_time) { series.next_occurrence(from_time: Time.current) }
let(:second_occurrence_time) { series.next_occurrence(from_time: first_occurrence_time) }
let!(:first_cancelled_occurrence) do
create(:scheduled_meeting,
:cancelled,
recurring_meeting: series,
start_time: first_occurrence_time)
end
let!(:second_cancelled_occurrence) do
create(:scheduled_meeting,
:cancelled,
recurring_meeting: series,
start_time: second_occurrence_time)
end
it "skips all cancelled occurrences and shows the count in the dialog" do
meeting_page.visit!
meeting_page.expect_agenda_item(title: "Test notes")
meeting_page.select_action(agenda_item, "Move to next meeting")
expect(page).to have_text("Move to next meeting?")
expect(page).to have_text("Note: Skipping 2 cancelled occurrences")
page.within_modal "Move to next meeting?" do
click_on "Move"
end
expect_and_dismiss_flash(message: "Agenda item moved to the next meeting")
meeting_page.expect_no_agenda_item(title: "Test notes")
end
end