0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-08 17:53:55 +08:00
discourse/plugins/discourse-calendar/spec/system/post_event_spec.rb
Régis Hanol 2fb587c4fe
UX: Fix several event editor, builder, and calendar card issues (#41080)
Previously, the renewed calendar event UI had a number of rough edges
reported in the [Event (calendar) UX
issues](https://meta.discourse.org/t/event-calendar-ux-issues/405388)
topic on Meta. The calendar card's hero image and the "Advanced
settings" toggle have since been addressed separately (#41135 and
#41138), so this change focuses on the remaining issues:

- **Name placeholder** — editing an event with no explicit name now
shows the topic title as a placeholder (mirroring the composer) without
writing it as a value, keeping the event name decoupled from the topic
title.
- **Two-click focus** — flushing the event block triggered
`composer:replace-text`, whose `insertAtTextarea` unconditionally
focused the textarea and stole the click; a new opt-in `skipFocus`
option mutates the value with `setRangeText` + an `input` event so focus
never moves. The rich-text editor was never affected (it commits through
ProseMirror transactions).
- **Readable custom fields** — event custom fields are now serialized by
`BasicEventSerializer`, so they are available from event listings and
not just the full event view.
- **Extensibility** — a `discourse-post-event-builder-custom-fields`
outlet wraps the builder's custom fields section so it can be replaced
or hidden, and a `discourse-post-event-composer-editor` outlet is
exposed on the composer event editor.

Covered by new system, request, and serializer specs. Also adds a system
spec for the calendar card image behavior shipped in #41135, which
previously had no coverage.
2026-07-02 13:04:23 +02:00

611 lines
21 KiB
Ruby
Vendored

# frozen_string_literal: true
describe "Post event" do
fab!(:admin)
fab!(:user, :admin)
fab!(:group)
let(:composer) { PageObjects::Components::Composer.new }
let(:post_event_page) { PageObjects::Pages::DiscourseCalendar::PostEvent.new }
let(:post_event_form_page) { PageObjects::Pages::DiscourseCalendar::PostEventForm.new }
let(:bulk_invite_modal_page) { PageObjects::Pages::DiscourseCalendar::BulkInviteModal.new }
before do
SiteSetting.calendar_enabled = true
SiteSetting.discourse_post_event_enabled = true
SiteSetting.discourse_post_event_allowed_custom_fields = "custom"
sign_in(admin)
end
context "with location" do
let(:location) { "123 Main St, Brisbane, Australia http://example.com" }
it "can save a location" do
title = "My test meetup event"
raw = "[event start='2222-02-22 14:22']\n[/event]"
post = PostCreator.create(admin, title:, raw:)
visit(post.topic.url)
post_event_page.edit
post_event_form_page.fill_location(location)
post_event_form_page.submit
expect(post_event_page).to have_location(location)
expect(page).to have_css(".event-location a[href='http://example.com']")
end
end
context "with description" do
it "can save a description" do
title = "My descriptive meetup event"
raw = "[event start='2222-02-22 14:22']\n[/event]"
post = PostCreator.create(admin, title:, raw:)
visit(post.topic.url)
post_event_page.edit
post_event_form_page.fill_description(
"this is a test description\n and a link http://example.com",
)
post_event_form_page.submit
expect(post_event_page).to have_description(
%r{this is a test description\s+and a link http://example.com},
)
end
it "shows the full description without a toggle in the topic view" do
title = "Event with full description"
description = "A short event description"
raw = "[event start='2222-02-22 14:22']\n#{description}\n[/event]"
post = PostCreator.create(admin, title:, raw:)
visit(post.topic.url)
expect(post_event_page).to have_description(description)
expect(post_event_page).to have_no_description_toggle
end
it "correctly builds a multiline description" do
timezone = "Europe/Paris"
admin.user_option.update!(timezone:)
time = Time.new(2025, 6, 15, 14, 30, 0, "+02:00")
freeze_time(time)
page.driver.with_playwright_page { |pw_page| pw_page.clock.install(time:) }
visit("/new-topic")
find(".toolbar-menu__options-trigger").click
click_button(I18n.t("js.discourse_post_event.builder_modal.attach"))
# Toolbar inserts inline; fill the description in the preview editor and
# focus out to flush back to raw.
composer.preview.find(".composer-event__description-textarea").fill_in(with: "foo\nbar")
find(".d-editor-input").click
expect(composer).to have_value <<~EVENT.strip
[event start="2025-06-15 15:00" status="public" timezone="#{timezone}" end="2025-06-15 16:00" reminders="notification.15.minutes"]
foo
bar
[/event]
EVENT
end
end
context "with markdown-mode preview" do
it "inserts BBCode inline and renders the compact editor in the preview pane pre-populated" do
visit("/new-topic")
find(".toolbar-menu__options-trigger").click
click_button(I18n.t("js.discourse_post_event.builder_modal.attach"))
preview = composer.preview
expect(preview).to have_css(".composer-event-node")
expect(preview.find(".composer-event__date-input", match: :first).value).not_to(
be_empty,
"start date should be populated from the inserted BBCode",
)
expect(composer).to have_value(/\[event /)
end
it "preserves a multi-day end date when toggling all-day on" do
visit("/new-topic")
composer.fill_title("Multi-day all-day toggle")
composer.fill_content <<~MD
[event start='2024-06-01 10:00' end='2024-06-03 12:00' timezone='UTC' status='public']
[/event]
MD
preview = composer.preview
expect(preview).to have_css(".composer-event-node")
preview.find(".composer-event__all-day-toggle .d-toggle-switch__label").click
find(".d-editor-input").click
expect(composer).to have_value(/start="2024-06-01"/)
expect(composer).to have_value(/end="2024-06-03"/)
end
context "when showLocalTime is set and the event crosses midnight relative to the viewer",
timezone: "Australia/Brisbane" do
it "renders the preview badge in the event timezone, not the viewer's" do
# 2025-09-07 22:30 Europe/Paris == 2025-09-08 06:30 Australia/Brisbane,
# so without showLocalTime the viewer would see "Sep 8" in the badge.
# With showLocalTime, the preview must stay anchored to the event timezone.
visit("/new-topic")
composer.fill_title("Local time preview test")
composer.fill_content <<~MD
[event start='2025-09-07 22:30' timezone='Europe/Paris' showLocalTime='true' status='public']
[/event]
MD
preview = composer.preview
expect(preview).to have_css(".composer-event__month", text: "SEP")
expect(preview).to have_css(".composer-event__day", text: "7")
end
end
end
context "with max attendees" do
it "updates the going button label from Full after toggling" do
going = I18n.t("js.discourse_post_event.models.invitee.status.going")
full = I18n.t("js.discourse_post_event.models.event.full")
raw = "[event status='public' start='2222-02-22 14:22' max-attendees='1']\n[/event]"
post = PostCreator.create(admin, title: "My test meetup event", raw:)
visit(post.topic.url)
# Wait for the component to settle before clicking
expect(page).to have_css(".going-button", text: going)
post_event_page.going
expect(page).to have_css(".going-button", text: full)
post_event_page.going
expect(page).to have_css(".going-button", text: going)
expect(page).to have_no_css(".going-button", text: full)
end
end
context "when defaulting timezone" do
it "uses the user's profile timezone in the builder" do
admin.user_option.update!(timezone: "Europe/Paris")
visit("/new-topic")
find(".toolbar-menu__options-trigger").click
click_button(I18n.t("js.discourse_post_event.builder_modal.attach"))
# Toolbar inserts inline; click gear to open the modal on advanced.
find(".d-editor-preview .composer-event__more-dropdown button").click
tz_select =
PageObjects::Components::SelectKit.new(".post-event-builder-modal .timezone-input")
# Expect the timezone select to default to the user's timezone
expect(tz_select.value).to eq("Europe/Paris")
end
end
context "when showing local time", timezone: "Australia/Brisbane" do
it "correctly shows month/day" do
page.driver.with_playwright_page do |pw_page|
pw_page.clock.install(time: Time.new(2025, 6, 5, 22, 0, 0))
end
title = "My FR test meetup event"
raw = <<~MD
[event showLocalTime='true' start='2025-09-07 17:30' timezone='Europe/Paris']
[/event]
MD
post = PostCreator.create(admin, title:, raw:)
visit(post.topic.url)
expect(page).to have_css(".event-date .month", text: "SEP")
expect(page).to have_css(".event-date .day", text: "7")
end
it "shows correct date" do
title = "My JP test meetup event"
raw = "[event timezone='Japan' showLocalTime='true' start='2222-02-22 14:22']\n[/event]"
post = PostCreator.create(admin, title:, raw:)
visit(post.topic.url)
expect(page).to have_css(".discourse-local-date", text: "Japan")
end
end
it "safely renders event name" do
title = "My XSS test meetup event"
raw = "[event name=':cat: <script>alert(1);</script>' start='2222-02-22 00:00']\n[/event]"
post = PostCreator.create(admin, title:, raw:)
visit(post.topic.url)
expect(page).to have_css(".event-info .name img.emoji[title='cat']")
expect(page).to have_css(".event-info .name", text: "<script>alert(1);</script>")
end
it "can create, close, and open an event" do
visit "/new-topic"
title = "My upcoming l33t event"
tomorrow = 1.day.from_now.strftime("%Y-%m-%d")
composer.fill_title(title)
composer.fill_content <<~MD
[event start="#{tomorrow} 13:37" status="public"]
[/event]
MD
composer.submit
expect(page).to have_content(title)
post_event_page.close
post_event_page.open
try_until_success { post_event_page.going }.open_more_menu do
locator(".show-all-participants").click
end
find(".d-modal input.filter").fill_in(with: user.username)
find(".d-modal .add-invitee").click
topic_page = PageObjects::Pages::Topic.new
topic = Topic.find(topic_page.current_topic_id)
event = topic.posts.first.event
expect(event.invitees.count).to eq(2)
end
it "does not show participants button when event is standalone" do
title = "My standalone event"
raw = "[event name='standalone-event' status='standalone' start='2222-02-22 00:00']\n[/event]"
post = PostCreator.create(admin, title:, raw:)
visit(post.topic.url)
post_event_page.open_more_menu
expect(page).to have_no_css(".show-all-participants")
end
it "does not show 'send pm' button to the user who created the event" do
title = "My test meetup event"
raw = "[event name='cool-event' status='public' start='2222-02-22 00:00']\n[/event]"
post = PostCreator.create(admin, title:, raw:)
visit(post.topic.url)
post_event_page.going.open_more_menu
expect(page).to have_no_css(".send-pm-to-creator")
end
context "with the Going button" do
fab!(:rsvp_user, :user)
before { sign_in(rsvp_user) }
it "creates a recurring going invitee when picking 'this event and all following events'" do
raw = "[event status='public' start='2222-02-22 14:22' recurrence='every_week']\n[/event]"
post = PostCreator.create!(admin, title: "My recurring meetup event", raw:)
visit(post.topic.url)
post_event_page.going_all_following
expect(post_event_page).to have_going_status
invitee = DiscoursePostEvent::Invitee.find_by(user_id: rsvp_user.id, post_id: post.id)
expect(invitee.status).to eq(DiscoursePostEvent::Invitee.statuses[:going])
expect(invitee.recurring).to eq(true)
end
it "creates a non-recurring going invitee when picking 'this event'" do
raw = "[event status='public' start='2222-02-22 14:22' recurrence='every_week']\n[/event]"
post = PostCreator.create!(admin, title: "My recurring meetup event", raw:)
visit(post.topic.url)
post_event_page.going_this_event
expect(post_event_page).to have_going_status
invitee = DiscoursePostEvent::Invitee.find_by(user_id: rsvp_user.id, post_id: post.id)
expect(invitee.status).to eq(DiscoursePostEvent::Invitee.statuses[:going])
expect(invitee.recurring).to eq(false)
end
it "renders without a dropdown on non-recurring events" do
raw = "[event status='public' start='2222-02-22 14:22']\n[/event]"
post = PostCreator.create!(admin, title: "My one-off meetup event", raw:)
visit(post.topic.url)
expect(post_event_page).to have_going_button
expect(post_event_page).to have_no_going_menu
end
it "opens the menu when tapping the whole going button on mobile", mobile: true do
raw = "[event status='public' start='2222-02-22 14:22' recurrence='every_week']\n[/event]"
post = PostCreator.create!(admin, title: "My recurring meetup event", raw:)
visit(post.topic.url)
post_event_page.going
expect(page).to have_css(".discourse-post-event-going-menu-content")
end
end
it "shows '-' for expired recurring events instead of dates" do
title = "An expired recurring event"
raw = <<~MD
[event start='2024-01-01 10:00' recurrenceUntil='2025-07-31' recurrence='every_week']
[/event]
MD
post = PostCreator.create!(admin, title:, raw:)
visit(post.topic.url)
expect(page).to have_css(".discourse-post-event")
expect(page).to have_css(".event-date .month", text: "-")
expect(page).to have_css(".event-date .day", text: "-")
expect(page).to have_css(".event-dates", text: "-")
end
context "with DST handling for recurring events" do
fab!(:eu_viewer) do
user = Fabricate(:user)
user.user_option.update!(timezone: "Europe/Paris")
user
end
fab!(:us_viewer) do
user = Fabricate(:user)
user.user_option.update!(timezone: "America/New_York")
user
end
def verify_event_time(viewer:, time:, expected_month: nil, expected_day: nil, expected_time:)
freeze_time(time) do
title = "Weekly recurring event #{SecureRandom.hex(4)}"
raw = <<~MD
[event start='2025-10-15 11:00' timezone='America/New_York' recurrence='every_week']
[/event]
MD
post = PostCreator.create!(admin, title:, raw:)
event = DiscoursePostEvent::Event.find_by(post:)
event.set_next_date
sign_in(viewer)
# Freeze browser clock **after** sign_in (which may navigate/reset state)
# so moment() uses the same time as the server.
page.driver.with_playwright_page { |pw_page| pw_page.clock.install(time:) }
visit(post.topic.url)
expect(page).to have_css(".event-date .month", text: expected_month) if expected_month
expect(page).to have_css(".event-date .day", text: expected_day) if expected_day
expect(page).to have_css(".discourse-local-date", text: expected_time)
end
end
context "when viewer is in Europe/Paris timezone", timezone: "Europe/Paris" do
it "shows correct time before any DST transition" do
# Oct 14, 2025 - Both Europe and US are in summer time
# 11:00 AM EDT = 15:00 UTC = 17:00 CEST = 5:00 PM
verify_event_time(
viewer: eu_viewer,
time: Time.new(2025, 10, 14, 10, 0, 0, "+02:00"),
expected_month: "OCT",
expected_day: "15",
expected_time: "5:00 PM",
)
end
it "shows correct time after Europe DST ends but before US DST ends" do
# Oct 28, 2025 - Europe CET (UTC+1), US still EDT (UTC-4)
# 11:00 AM EDT = 15:00 UTC = 16:00 CET = 4:00 PM
verify_event_time(
viewer: eu_viewer,
time: Time.new(2025, 10, 28, 10, 0, 0, "+01:00"),
expected_month: "OCT",
expected_day: "29",
expected_time: "4:00 PM",
)
end
it "shows correct time after both DST transitions" do
# Nov 4, 2025 - Europe CET (UTC+1), US EST (UTC-5)
# 11:00 AM EST = 16:00 UTC = 17:00 CET = 5:00 PM
verify_event_time(
viewer: eu_viewer,
time: Time.new(2025, 11, 4, 10, 0, 0, "+01:00"),
expected_month: "NOV",
expected_day: "5",
expected_time: "5:00 PM",
)
end
end
context "when viewer is in America/New_York timezone", timezone: "America/New_York" do
it "shows 11:00 AM before US DST ends" do
verify_event_time(
viewer: us_viewer,
time: Time.new(2025, 10, 28, 10, 0, 0, "-04:00"),
expected_time: "11:00 AM",
)
end
it "shows 11:00 AM after US DST ends" do
verify_event_time(
viewer: us_viewer,
time: Time.new(2025, 11, 4, 10, 0, 0, "-05:00"),
expected_time: "11:00 AM",
)
end
end
end
it "persists changes" do
visit "/new-topic"
composer.fill_title("Test event with updates")
find(".toolbar-menu__options-trigger").click
find("button[title='#{I18n.t("js.discourse_post_event.builder_modal.attach")}']").click
# Toolbar inserts inline; click gear to open the modal on advanced.
find(".d-editor-preview .composer-event__more-dropdown button").click
form = PageObjects::Components::FormKit.new(".d-modal form")
form.field("eventType").select("private")
find(".group-selector").click
find(".d-multi-select__search-input").send_keys(group.name)
find(".d-multi-select__result", text: group.name).click
form.field("customFields.custom").fill_in("custom value")
form.field("recurrence").select("every_day")
find(".d-modal .recurrence-until .date-picker").fill_in(with: "#{1.year.from_now.year}-12-30")
find(".d-modal .btn-primary").click
composer.submit
expect(page).to have_css(".discourse-post-event")
post_event_page.edit
find(".d-modal .d-modal__footer .advanced-mode-btn").click
form = PageObjects::Components::FormKit.new(".d-modal form")
expect(form.field("eventType")).to have_value("private")
expect(find(".group-selector .d-multi-select-trigger__selection")).to have_text(group.name)
expect(form.field("customFields.custom")).to have_value("custom value")
expect(page).to have_selector(".d-modal .recurrence-until .date-picker") do |input|
input.value == "#{1.year.from_now.year}-12-30"
end
end
context "when using bulk inline invite" do
let!(:post) do
PostCreator.create(
admin,
title: "My test meetup event",
raw: "[event name='cool-event' status='public' start='2222-02-22 00:00' ]\n[/event]",
)
end
fab!(:invitable_user_1, :user)
fab!(:invitable_user_2, :user)
it "can invite users to an event" do
visit(post.topic.url)
post_event_page.open_bulk_invite_modal
bulk_invite_modal_page
.set_invitee_at_row(invitable_user_1.username, "going", 1)
.add_invitee
.set_invitee_at_row(invitable_user_2.username, "not_going", 2)
.send_invites
expect(bulk_invite_modal_page).to be_closed
end
it "keeps a single row when removing the last invitee" do
visit(post.topic.url)
post_event_page.open_bulk_invite_modal
bulk_invite_modal_page
.set_invitee_at_row(invitable_user_1.username, "going", 1)
.add_invitee
.set_invitee_at_row(invitable_user_2.username, "not_going", 2)
.remove_invitee_row(2)
.remove_invitee_row(1)
# the collection never collapses, an empty row is always available
expect(bulk_invite_modal_page).to have_invitee_rows(1)
end
it "closes the modal and shows a toast after a CSV bulk invite" do
visit(post.topic.url)
post_event_page.open_bulk_invite_modal
bulk_invite_modal_page.upload_csv(
"#{Rails.root.join("plugins/discourse-calendar/spec/fixtures/csv/bulk_invite.csv")}",
)
PageObjects::Components::Dialog.new.click_yes
expect(bulk_invite_modal_page).to be_closed
expect(PageObjects::Components::Toasts.new).to have_success(
I18n.t("js.discourse_post_event.bulk_invite_modal.success"),
)
end
end
context "when inviting a user or group" do
let!(:post) do
PostCreator.create(
admin,
title: "My test meetup event",
raw: "[event name='cool-event' status='public' start='2222-02-22 00:00' ]\n[/event]",
)
end
fab!(:invitable_user, :user)
it "notifies the invited user and closes the modal" do
visit(post.topic.url)
post_event_page.open_invite_user_or_group_modal
chooser =
PageObjects::Components::SelectKit.new(
".post-event-invite-user-or-group .email-group-user-chooser",
)
chooser.expand
chooser.search(invitable_user.username)
chooser.select_row_by_value(invitable_user.username)
chooser.collapse
find(".post-event-invite-user-or-group .d-modal__footer .btn-primary").click
expect(page).to have_no_css(".post-event-invite-user-or-group")
expect(PageObjects::Components::Toasts.new).to have_success(
I18n.t("js.discourse_post_event.invite_user_or_group.success"),
)
expect(
invitable_user.notifications.where(
notification_type: Notification.types[:event_invitation],
),
).to be_present
end
end
context "with add to calendar from more menu" do
it "includes rrule for recurring events" do
admin.user_option.update!(default_calendar: "ics")
title = "Weekly standup"
raw = <<~MD
[event start='2222-02-22 14:00' recurrence='every_week']
[/event]
MD
post = PostCreator.create!(admin, title:, raw:)
visit(post.topic.url)
ics_content = nil
page.driver.with_playwright_page do |pw_page|
download = pw_page.expect_download { post_event_page.add_to_calendar }
ics_content = download.path.then { |path| File.read(path) }
end
expect(ics_content).to include("RRULE:")
expect(ics_content).to include("FREQ=WEEKLY")
end
end
context "when editing an event without an explicit name" do
it "shows the topic title as the event name placeholder" do
title = "Nameless meetup"
raw = "[event start='2222-02-22 14:22']\n[/event]"
post = PostCreator.create!(admin, title:, raw:)
visit(post.topic.url)
post_event_page.edit
expect(page).to have_css(
".post-event-builder-modal .composer-event__name-input[placeholder='#{title}']",
)
end
end
end