From acb0681ff93bd0fddc7d54b6697736d9138a0e8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Hanol?= Date: Tue, 14 May 2024 10:12:47 +0200 Subject: [PATCH] FEATURE: closed events (#564) Adds support for "closed" events instead of just relying on the start & end dates. An event might now be "expired" when it happened in the past. An event might now also be "closed", if for whatever reasons, the author decides to "cancel" it. Context - https://meta.discourse.org/t/when-closing-event-it-moves-it-to-todays-date-time/307292 --- app/models/discourse_post_event/event.rb | 35 ++++----- .../discourse_post_event/event_serializer.rb | 49 ++++++------ .../discourse/lib/raw-event-helper.js | 4 + .../widgets/discourse-post-event-dates.js | 15 ++-- .../discourse/widgets/discourse-post-event.js | 77 ++++++++++++++----- .../discourse/widgets/more-dropdown.js | 61 +++++++++------ .../common/discourse-post-event.scss | 3 +- config/locales/client.en.yml | 5 ++ ...0542_add_closed_to_discourse_post_event.rb | 7 ++ lib/discourse_post_event/event_parser.rb | 1 + .../models/discourse_post_event/event_spec.rb | 8 +- spec/system/post_event_spec.rb | 41 ++++++++++ 12 files changed, 205 insertions(+), 101 deletions(-) create mode 100644 db/migrate/20240513140542_add_closed_to_discourse_post_event.rb create mode 100644 spec/system/post_event_spec.rb diff --git a/app/models/discourse_post_event/event.rb b/app/models/discourse_post_event/event.rb index 8d7461b9c..08d6a0ca6 100644 --- a/app/models/discourse_post_event/event.rb +++ b/app/models/discourse_post_event/event.rb @@ -230,9 +230,9 @@ def create_notification!(user, post, predefined_attendance: false) end def ongoing? - ( - self.ends_at ? (self.starts_at..self.ends_at).cover?(Time.now) : self.starts_at >= Time.now - ) && !self.expired? + return false if self.closed || self.expired? + finishes_at = self.ends_at || self.starts_at.end_of_day + (self.starts_at..finishes_at).cover?(Time.now) end def self.statuses @@ -284,16 +284,13 @@ def enforce_private_invitees! end def can_user_update_attendance(user) - !self.expired? && + return false if self.closed || self.expired? + return true if self.public? + + self.private? && ( - self.public? || - ( - self.private? && - ( - self.invitees.exists?(user_id: user.id) || - (user.groups.pluck(:name) & self.raw_invitees).any? - ) - ) + self.invitees.exists?(user_id: user.id) || + (user.groups.pluck(:name) & self.raw_invitees).any? ) end @@ -315,18 +312,11 @@ def self.update_from_raw(post) url: event_params[:url], recurrence: event_params[:recurrence], timezone: event_params[:timezone], - status: - ( - if event_params[:status].present? - Event.statuses[event_params[:status].to_sym] - else - event.status - end - ), + status: Event.statuses[event_params[:status]&.to_sym] || event.status, reminders: event_params[:reminders], - raw_invitees: - event_params[:"allowed-groups"] ? event_params[:"allowed-groups"].split(",") : nil, + raw_invitees: event_params[:"allowed-groups"]&.split(","), minimal: event_params[:minimal], + closed: event_params[:closed] || false, } params[:custom_fields] = {} @@ -417,4 +407,5 @@ def calculate_next_date(start_date: nil) # recurrence :string # timezone :string # minimal :boolean default(FALSE), not null +# closed :boolean default(FALSE), not null # diff --git a/app/serializers/discourse_post_event/event_serializer.rb b/app/serializers/discourse_post_event/event_serializer.rb index 36c806428..f0d36bf81 100644 --- a/app/serializers/discourse_post_event/event_serializer.rb +++ b/app/serializers/discourse_post_event/event_serializer.rb @@ -2,33 +2,34 @@ module DiscoursePostEvent class EventSerializer < ApplicationSerializer - attributes :id - attributes :creator - attributes :sample_invitees - attributes :watching_invitee - attributes :starts_at - attributes :ends_at - attributes :timezone - attributes :stats - attributes :status - attributes :raw_invitees - attributes :post - attributes :name attributes :can_act_on_discourse_post_event attributes :can_update_attendance + attributes :category_id + attributes :creator + attributes :custom_fields + attributes :ends_at + attributes :id + attributes :is_closed attributes :is_expired attributes :is_ongoing - attributes :should_display_invitees - attributes :url - attributes :custom_fields - attributes :is_public attributes :is_private + attributes :is_public attributes :is_standalone - attributes :reminders - attributes :recurrence attributes :minimal - attributes :category_id + attributes :name + attributes :post + attributes :raw_invitees + attributes :recurrence attributes :recurrence_rule + attributes :reminders + attributes :sample_invitees + attributes :should_display_invitees + attributes :starts_at + attributes :stats + attributes :status + attributes :timezone + attributes :url + attributes :watching_invitee def can_act_on_discourse_post_event scope.can_act_on_discourse_post_event?(object) @@ -55,15 +56,19 @@ def is_ongoing end def is_public - object.status === Event.statuses[:public] + object.public? end def is_private - object.status === Event.statuses[:private] + object.private? end def is_standalone - object.status === Event.statuses[:standalone] + object.standalone? + end + + def is_closed + object.closed end def status diff --git a/assets/javascripts/discourse/lib/raw-event-helper.js b/assets/javascripts/discourse/lib/raw-event-helper.js index aaa5bb083..29bbc82cc 100644 --- a/assets/javascripts/discourse/lib/raw-event-helper.js +++ b/assets/javascripts/discourse/lib/raw-event-helper.js @@ -5,6 +5,10 @@ export function buildParams(startsAt, endsAt, eventModel, siteSettings) { params.start = moment(startsAt).tz(eventTz).format("YYYY-MM-DD HH:mm"); + if (eventModel.closed) { + params.closed = "true"; + } + if (eventModel.status) { params.status = eventModel.status; } diff --git a/assets/javascripts/discourse/widgets/discourse-post-event-dates.js b/assets/javascripts/discourse/widgets/discourse-post-event-dates.js index 5003bdeee..323ddb456 100644 --- a/assets/javascripts/discourse/widgets/discourse-post-event-dates.js +++ b/assets/javascripts/discourse/widgets/discourse-post-event-dates.js @@ -15,27 +15,28 @@ export default createWidget("discourse-post-event-dates", { }); }, - html(attrs) { + html({ localDates, eventModel }) { const content = [ iconNode("clock"), - h("span.date", new RawHtml({ html: `${attrs.localDates}` })), + h("span.date", new RawHtml({ html: `${localDates}` })), ]; if ( - attrs.eventModel.is_expired && - attrs.eventModel.status !== "standalone" + eventModel.is_expired && + !eventModel.is_closed && + !eventModel.is_standalone ) { let participants; const label = I18n.t( "discourse_calendar.discourse_post_event.event_ui.participants", { - count: attrs.eventModel.stats.going, + count: eventModel.stats.going, } ); - if (attrs.eventModel.stats.going > 0) { + if (eventModel.stats.going > 0) { participants = this.attach("link", { action: "showAllParticipatingInvitees", - actionParam: attrs.eventModel.id, + actionParam: eventModel.id, contents: () => label, }); } else { diff --git a/assets/javascripts/discourse/widgets/discourse-post-event.js b/assets/javascripts/discourse/widgets/discourse-post-event.js index 9c5cbfb64..72e9debb0 100644 --- a/assets/javascripts/discourse/widgets/discourse-post-event.js +++ b/assets/javascripts/discourse/widgets/discourse-post-event.js @@ -82,23 +82,58 @@ export default createWidget("discourse-post-event", { ), didConfirm: () => { return this.store.find("post", eventModel.id).then((post) => { - const raw = post.raw; - const startsAt = eventModel.starts_at - ? moment(eventModel.starts_at) - : moment(); + eventModel.closed = true; + const eventParams = buildParams( - moment().isBefore(startsAt) ? moment() : startsAt, - moment().isBefore(startsAt) ? moment().add(1, "minute") : moment(), + eventModel.starts_at, + eventModel.ends_at, eventModel, this.siteSettings ); - const newRaw = replaceRaw(eventParams, raw); + + const newRaw = replaceRaw(eventParams, post.raw); + + if (newRaw) { + const props = { + raw: newRaw, + edit_reason: I18n.t( + "discourse_calendar.discourse_post_event.edit_reason_closed" + ), + }; + + return cook(newRaw).then((cooked) => { + props.cooked = cooked.string; + return post.save(props); + }); + } + }); + }, + }); + }, + + openEvent(eventModel) { + this.dialog.yesNoConfirm({ + message: I18n.t( + "discourse_calendar.discourse_post_event.builder_modal.confirm_open" + ), + didConfirm: () => { + return this.store.find("post", eventModel.id).then((post) => { + eventModel.closed = false; + + const eventParams = buildParams( + eventModel.starts_at, + eventModel.ends_at, + eventModel, + this.siteSettings + ); + + const newRaw = replaceRaw(eventParams, post.raw); if (newRaw) { const props = { raw: newRaw, edit_reason: I18n.t( - "discourse_calendar.discourse_post_event.edit_reason" + "discourse_calendar.discourse_post_event.edit_reason_opened" ), }; @@ -249,18 +284,20 @@ export default createWidget("discourse-post-event", { {{{transformed.eventName}}}
- {{#unless transformed.isStandaloneEvent}} - {{#if state.eventModel.is_expired}} - - {{i18n "discourse_calendar.discourse_post_event.models.event.expired"}} - - {{else}} - - {{transformed.eventStatusLabel}} - - {{/if}} - · - {{/unless}} + {{#if state.eventModel.is_expired}} + + {{i18n "discourse_calendar.discourse_post_event.models.event.expired"}} + + {{else if state.eventModel.is_closed}} + + {{i18n "discourse_calendar.discourse_post_event.models.event.closed"}} + + {{else}} + + {{transformed.eventStatusLabel}} + + {{/if}} + · {{i18n "discourse_calendar.discourse_post_event.event_ui.created_by"}} {{attach widget="discourse-post-event-creator" attrs=(hash user=state.eventModel.creator)}} diff --git a/assets/javascripts/discourse/widgets/more-dropdown.js b/assets/javascripts/discourse/widgets/more-dropdown.js index cb80c94bd..bf183e53a 100644 --- a/assets/javascripts/discourse/widgets/more-dropdown.js +++ b/assets/javascripts/discourse/widgets/more-dropdown.js @@ -36,10 +36,11 @@ export default createWidget("more-dropdown", { } }, - _buildContent(attrs) { + _buildContent({ canActOnEvent, isPublicEvent, eventModel }) { const content = []; + const expiredOrClosed = eventModel.is_expired || eventModel.is_closed; - if (!attrs.eventModel.is_expired) { + if (!expiredOrClosed) { content.push({ id: "addToCalendar", icon: "file", @@ -54,30 +55,30 @@ export default createWidget("more-dropdown", { icon: "envelope", translatedLabel: I18n.t( "discourse_calendar.discourse_post_event.event_ui.send_pm_to_creator", - { username: attrs.eventModel.creator.username } + { username: eventModel.creator.username } ), }); } - if (!attrs.is_expired && attrs.canActOnEvent && attrs.isPublicEvent) { + if (!expiredOrClosed && canActOnEvent && isPublicEvent) { content.push({ id: "inviteUserOrGroup", icon: "user-plus", label: "discourse_calendar.discourse_post_event.event_ui.invite", - param: attrs.eventModel.id, + param: eventModel.id, }); } - if (attrs.eventModel.watching_invitee && attrs.isPublicEvent) { + if (eventModel.watching_invitee && isPublicEvent) { content.push({ id: "leaveEvent", icon: "times", label: "discourse_calendar.discourse_post_event.event_ui.leave", - param: attrs.eventModel.id, + param: eventModel.id, }); } - if (attrs.eventModel.recurrence) { + if (!eventModel.is_closed && eventModel.recurrence) { content.push({ id: "upcomingEvents", icon: "far-calendar-plus", @@ -85,40 +86,50 @@ export default createWidget("more-dropdown", { }); } - if (attrs.canActOnEvent) { + if (canActOnEvent) { content.push("separator"); content.push({ icon: "file-csv", id: "exportPostEvent", label: "discourse_calendar.discourse_post_event.event_ui.export_event", - param: attrs.eventModel.id, + param: eventModel.id, }); - if (!attrs.eventModel.is_expired && !attrs.eventModel.is_standalone) { + if (!expiredOrClosed && !eventModel.is_standalone) { content.push({ icon: "file-upload", id: "bulkInvite", label: "discourse_calendar.discourse_post_event.event_ui.bulk_invite", - param: attrs.eventModel, + param: eventModel, }); } - content.push({ - icon: "pencil-alt", - id: "editPostEvent", - label: "discourse_calendar.discourse_post_event.event_ui.edit_event", - param: attrs.eventModel.id, - }); - - if (!attrs.eventModel.is_expired) { + if (eventModel.is_closed) { content.push({ - icon: "times", - id: "closeEvent", - label: "discourse_calendar.discourse_post_event.event_ui.close_event", - class: "danger", - param: attrs.eventModel, + icon: "unlock", + id: "openEvent", + label: "discourse_calendar.discourse_post_event.event_ui.open_event", + param: eventModel, }); + } else { + content.push({ + icon: "pencil-alt", + id: "editPostEvent", + label: "discourse_calendar.discourse_post_event.event_ui.edit_event", + param: eventModel.id, + }); + + if (!eventModel.is_expired) { + content.push({ + icon: "times", + id: "closeEvent", + label: + "discourse_calendar.discourse_post_event.event_ui.close_event", + class: "danger", + param: eventModel, + }); + } } } diff --git a/assets/stylesheets/common/discourse-post-event.scss b/assets/stylesheets/common/discourse-post-event.scss index 893aabbcd..aed0181dd 100644 --- a/assets/stylesheets/common/discourse-post-event.scss +++ b/assets/stylesheets/common/discourse-post-event.scss @@ -133,7 +133,8 @@ $interested: #fb985d; } .status { - &.expired { + &.expired, + &.closed { color: var(--danger-medium); } diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index ba8f6cd55..58aa2673e 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -337,6 +337,7 @@ en: interested: "Interested" event: expired: "Expired" + closed: "Closed" status: standalone: title: "Standalone" @@ -361,6 +362,7 @@ en: created_by: "Created by" bulk_invite: "Bulk Invite" close_event: "Close event" + open_event: "Open event" invitees_modal: title_invited: "List of RSVPed users" title_participated: "List of users who participated" @@ -389,6 +391,7 @@ en: update_event_title: "Edit Event" confirm_delete: "Are you sure you want to delete this event?" confirm_close: "Are you sure you want to close this event?" + confirm_open: "Are you sure you want to open this event?" create: "Create" update: "Save" attach: "Create event" @@ -466,6 +469,8 @@ en: preview: more_than_one_event: "You can’t have more than one event." edit_reason: "Event updated" + edit_reason_closed: "Event closed" + edit_reason_opened: "Event opened" topic_title: starts_at: "Event will start: %{date}" ended_at: "Event ended: %{date}" diff --git a/db/migrate/20240513140542_add_closed_to_discourse_post_event.rb b/db/migrate/20240513140542_add_closed_to_discourse_post_event.rb new file mode 100644 index 000000000..9abf97337 --- /dev/null +++ b/db/migrate/20240513140542_add_closed_to_discourse_post_event.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddClosedToDiscoursePostEvent < ActiveRecord::Migration[7.0] + def change + add_column :discourse_post_event_events, :closed, :boolean, default: false, null: false + end +end diff --git a/lib/discourse_post_event/event_parser.rb b/lib/discourse_post_event/event_parser.rb index ea723ff48..85c1a66dd 100644 --- a/lib/discourse_post_event/event_parser.rb +++ b/lib/discourse_post_event/event_parser.rb @@ -13,6 +13,7 @@ class EventParser :recurrence, :timezone, :minimal, + :closed, ] def self.extract_events(post) diff --git a/spec/models/discourse_post_event/event_spec.rb b/spec/models/discourse_post_event/event_spec.rb index 9d2d87453..4620deedb 100644 --- a/spec/models/discourse_post_event/event_spec.rb +++ b/spec/models/discourse_post_event/event_spec.rb @@ -272,10 +272,10 @@ context "without ends_at date" do context "when starts_at < current date" do - it "is not ongoing" do + it "is ongoing" do post_event = Event.create!(original_starts_at: 2.hours.ago, post: first_post) - expect(post_event.ongoing?).to be(false) + expect(post_event.ongoing?).to be(true) end end @@ -288,10 +288,10 @@ end context "when starts_at > current date" do - it "is ongoing" do + it "is not ongoing" do post_event = Event.create!(original_starts_at: 1.hours.from_now, post: first_post) - expect(post_event.ongoing?).to be(true) + expect(post_event.ongoing?).to be(false) end end end diff --git a/spec/system/post_event_spec.rb b/spec/system/post_event_spec.rb new file mode 100644 index 000000000..f40b15550 --- /dev/null +++ b/spec/system/post_event_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +describe "Post event", type: :system do + fab!(:admin) + let(:composer) { PageObjects::Components::Composer.new } + + before do + SiteSetting.calendar_enabled = true + SiteSetting.discourse_post_event_enabled = true + sign_in(admin) + visit "/new-topic" + end + + it "can create, close, and open an event" do + title = "My upcoming l33t event" + tomorrow = (Time.zone.now + 1.day).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) + + page.find("#more-dropdown").click + page.find(".item-closeEvent").click + page.find("#dialog-holder .btn-primary").click + + expect(page).to have_css(".discourse-post-event .status-and-creators .status.closed") + + page.find("#more-dropdown").click + page.find(".item-openEvent").click + page.find("#dialog-holder .btn-primary").click + + expect(page).to have_css(".discourse-post-event .status-and-creators .status.public") + end +end