FEATURE: Add event location/description and "My Events" filter (#746)

This pull request introduces two major new features to the calendar plugin: the ability to add a location/description to an event, and a new "My Events" view on the upcoming events page.

### Event Location

You can now add a `location` to an event. This is a free-text field that can be used for a physical address, a URL, or any other location details.

*   A `location` field has been added to the event builder modal.
*   The location is displayed in the event details in the post, complete with a new "location-pin" icon. URLs within the location field are automatically linked.
*   This is supported by a database migration to add the `location` column, and updates to the event model, serializer, and parser.

### Event description 

You can now add a `description ` to an event. This is a free-text field that can be used to describe your event.

*   A `description` field has been added to the event builder modal.
*   The description  is displayed in the event details in the post, complete with a new "circle-info" icon. URLs within the location field are automatically linked. It supports linebreaks.
*   This is supported by a database migration to add the `description` column, and updates to the event model, serializer, and parser.

### "My Events" Filter

The `/upcoming-events` page now includes tabs to switch between "All events" and "My events".

*   The "My events" tab shows all upcoming events that the current user is "Going" to.
*   This creates a personalized calendar for users to easily see their own upcoming schedule.
*   A new `/upcoming-events/mine` route has been added, and the backend event finder now supports filtering by an `attending_user`.


### Other Improvements

*   The calendar view on the `/upcoming-events` page now defaults to a "list" view on mobile for a better experience.
*   The "Open Event" and "Close Event" actions now have a disabled/saving state to provide better feedback during the operation.
*   System tests have been added to cover the new functionality.
This commit is contained in:
Sam 2025-06-25 18:20:38 +10:00 committed by GitHub
parent 781ecefc62
commit bdf8869a01
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 516 additions and 80 deletions

View File

@ -123,6 +123,7 @@ module DiscoursePostEvent
:include_expired, :include_expired,
:limit, :limit,
:before, :before,
:attending_user,
) )
end end
end end

View File

@ -309,6 +309,8 @@ module DiscoursePostEvent
original_starts_at: parsed_starts_at, original_starts_at: parsed_starts_at,
original_ends_at: parsed_ends_at, original_ends_at: parsed_ends_at,
url: event_params[:url], url: event_params[:url],
description: event_params[:description],
location: event_params[:location],
recurrence: event_params[:recurrence], recurrence: event_params[:recurrence],
recurrence_until: parsed_recurrence_until, recurrence_until: parsed_recurrence_until,
timezone: event_params[:timezone], timezone: event_params[:timezone],
@ -420,6 +422,8 @@ end
# raw_invitees :string is an Array # raw_invitees :string is an Array
# name :string # name :string
# url :string(1000) # url :string(1000)
# description :string(1000)
# location :string(1000)
# custom_fields :jsonb not null # custom_fields :jsonb not null
# reminders :string # reminders :string
# recurrence :string # recurrence :string

View File

@ -31,6 +31,8 @@ module DiscoursePostEvent
attributes :timezone attributes :timezone
attributes :show_local_time attributes :show_local_time
attributes :url attributes :url
attributes :description
attributes :location
attributes :watching_invitee attributes :watching_invitee
attributes :chat_enabled attributes :chat_enabled
attributes :channel attributes :channel
@ -135,6 +137,10 @@ module DiscoursePostEvent
object.post.topic.category_id object.post.topic.category_id
end end
def include_url?
object.url.present?
end
def include_recurrence_rule? def include_recurrence_rule?
object.recurring? object.recurring?
end end

View File

@ -0,0 +1,11 @@
import CookText from "discourse/components/cook-text";
const DiscoursePostEventDescription = <template>
{{#if @description}}
<section class="event__section event-description">
<CookText @rawText={{@description}} />
</section>
{{/if}}
</template>;
export default DiscoursePostEventDescription;

View File

@ -10,8 +10,10 @@ import routeAction from "discourse/helpers/route-action";
import ChatChannel from "./chat-channel"; import ChatChannel from "./chat-channel";
import Creator from "./creator"; import Creator from "./creator";
import Dates from "./dates"; import Dates from "./dates";
import Description from "./description";
import EventStatus from "./event-status"; import EventStatus from "./event-status";
import Invitees from "./invitees"; import Invitees from "./invitees";
import Location from "./location";
import MoreMenu from "./more-menu"; import MoreMenu from "./more-menu";
import Status from "./status"; import Status from "./status";
import Url from "./url"; import Url from "./url";
@ -130,16 +132,20 @@ export default class DiscoursePostEvent extends Component {
event=@event event=@event
Section=(component InfoSection event=@event) Section=(component InfoSection event=@event)
Url=(component Url url=@event.url) Url=(component Url url=@event.url)
Description=(component Description description=@event.description)
Location=(component Location location=@event.location)
Dates=(component Dates event=@event) Dates=(component Dates event=@event)
Invitees=(component Invitees event=@event) Invitees=(component Invitees event=@event)
Status=(component Status event=@event) Status=(component Status event=@event)
ChatChannel=(component ChatChannel event=@event) ChatChannel=(component ChatChannel event=@event)
}} }}
> >
<Url @url={{@event.url}} />
<Dates @event={{@event}} /> <Dates @event={{@event}} />
<Location @location={{@event.location}} />
<Url @url={{@event.url}} />
<ChatChannel @event={{@event}} /> <ChatChannel @event={{@event}} />
<Invitees @event={{@event}} /> <Invitees @event={{@event}} />
<Description @description={{@event.description}} />
{{#if @event.canUpdateAttendance}} {{#if @event.canUpdateAttendance}}
<Status @event={{@event}} /> <Status @event={{@event}} />
{{/if}} {{/if}}

View File

@ -0,0 +1,14 @@
import CookText from "discourse/components/cook-text";
import icon from "discourse/helpers/d-icon";
const DiscoursePostEventLocation = <template>
{{#if @location}}
<section class="event__section event-location">
{{icon "location-pin"}}
<CookText @rawText={{@location}} />
</section>
{{/if}}
</template>;
export default DiscoursePostEventLocation;

View File

@ -1,9 +1,11 @@
import Component from "@glimmer/component"; import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { hash } from "@ember/helper"; import { hash } from "@ember/helper";
import EmberObject, { action } from "@ember/object"; import EmberObject, { action } from "@ember/object";
import { service } from "@ember/service"; import { service } from "@ember/service";
import DButton from "discourse/components/d-button"; import DButton from "discourse/components/d-button";
import DropdownMenu from "discourse/components/dropdown-menu"; import DropdownMenu from "discourse/components/dropdown-menu";
import concatClass from "discourse/helpers/concat-class";
import { popupAjaxError } from "discourse/lib/ajax-error"; import { popupAjaxError } from "discourse/lib/ajax-error";
import { downloadCalendar } from "discourse/lib/download-calendar"; import { downloadCalendar } from "discourse/lib/download-calendar";
import { exportEntity } from "discourse/lib/export-csv"; import { exportEntity } from "discourse/lib/export-csv";
@ -27,6 +29,8 @@ export default class DiscoursePostEventMoreMenu extends Component {
@service siteSettings; @service siteSettings;
@service store; @service store;
@tracked isSavingEvent = false;
get expiredOrClosed() { get expiredOrClosed() {
return this.args.event.isExpired || this.args.event.isClosed; return this.args.event.isExpired || this.args.event.isClosed;
} }
@ -147,6 +151,8 @@ export default class DiscoursePostEventMoreMenu extends Component {
this.dialog.yesNoConfirm({ this.dialog.yesNoConfirm({
message: i18n("discourse_post_event.builder_modal.confirm_open"), message: i18n("discourse_post_event.builder_modal.confirm_open"),
didConfirm: async () => { didConfirm: async () => {
this.isSavingEvent = true;
try { try {
const post = await this.store.find("post", this.args.event.id); const post = await this.store.find("post", this.args.event.id);
this.args.event.isClosed = false; this.args.event.isClosed = false;
@ -172,6 +178,8 @@ export default class DiscoursePostEventMoreMenu extends Component {
} }
} catch (e) { } catch (e) {
popupAjaxError(e); popupAjaxError(e);
} finally {
this.isSavingEvent = false;
} }
}, },
}); });
@ -208,6 +216,7 @@ export default class DiscoursePostEventMoreMenu extends Component {
this.dialog.yesNoConfirm({ this.dialog.yesNoConfirm({
message: i18n("discourse_post_event.builder_modal.confirm_close"), message: i18n("discourse_post_event.builder_modal.confirm_close"),
didConfirm: () => { didConfirm: () => {
this.isSavingEvent = true;
return this.store.find("post", this.args.event.id).then((post) => { return this.store.find("post", this.args.event.id).then((post) => {
this.args.event.isClosed = true; this.args.event.isClosed = true;
@ -226,9 +235,13 @@ export default class DiscoursePostEventMoreMenu extends Component {
edit_reason: i18n("discourse_post_event.edit_reason_closed"), edit_reason: i18n("discourse_post_event.edit_reason_closed"),
}; };
return cook(newRaw).then((cooked) => { return cook(newRaw)
.then((cooked) => {
props.cooked = cooked.string; props.cooked = cooked.string;
return post.save(props); return post.save(props);
})
.finally(() => {
this.isSavingEvent = false;
}); });
} }
}); });
@ -239,7 +252,10 @@ export default class DiscoursePostEventMoreMenu extends Component {
<template> <template>
<DMenu <DMenu
@identifier="discourse-post-event-more-menu" @identifier="discourse-post-event-more-menu"
@triggerClass="more-dropdown" @triggerClass={{concatClass
"more-dropdown"
(if this.isSavingEvent "--saving")
}}
@icon="ellipsis" @icon="ellipsis"
@onRegisterApi={{this.registerMenuApi}} @onRegisterApi={{this.registerMenuApi}}
> >
@ -333,6 +349,7 @@ export default class DiscoursePostEventMoreMenu extends Component {
class="btn-transparent" class="btn-transparent"
@label="discourse_post_event.open_event" @label="discourse_post_event.open_event"
@action={{this.openEvent}} @action={{this.openEvent}}
@disabled={{this.isSavingEvent}}
/> />
</dropdown.item> </dropdown.item>
{{else}} {{else}}
@ -351,6 +368,7 @@ export default class DiscoursePostEventMoreMenu extends Component {
@icon="xmark" @icon="xmark"
@label="discourse_post_event.close_event" @label="discourse_post_event.close_event"
@action={{this.closeEvent}} @action={{this.closeEvent}}
@disabled={{this.isSavingEvent}}
class="btn-transparent btn-danger" class="btn-transparent btn-danger"
/> />
</dropdown.item> </dropdown.item>

View File

@ -1,6 +1,6 @@
import Component from "@glimmer/component"; import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking"; import { tracked } from "@glimmer/tracking";
import { Input } from "@ember/component"; import { Input, Textarea } from "@ember/component";
import { concat, fn, get } from "@ember/helper"; import { concat, fn, get } from "@ember/helper";
import { on } from "@ember/modifier"; import { on } from "@ember/modifier";
import { action } from "@ember/object"; import { action } from "@ember/object";
@ -110,6 +110,10 @@ export default class PostEventBuilder extends Component {
]; ];
} }
get shouldRenderUrl() {
return this.args.model.event.url !== undefined;
}
get availableRecurrences() { get availableRecurrences() {
return [ return [
{ {
@ -363,6 +367,19 @@ export default class PostEventBuilder extends Component {
/> />
</EventField> </EventField>
<EventField
@label="discourse_post_event.builder_modal.location.label"
class="location"
>
<Input
@value={{@model.event.location}}
placeholder={{i18n
"discourse_post_event.builder_modal.location.placeholder"
}}
/>
</EventField>
{{#if this.shouldRenderUrl}}
<EventField <EventField
@label="discourse_post_event.builder_modal.url.label" @label="discourse_post_event.builder_modal.url.label"
class="url" class="url"
@ -374,6 +391,19 @@ export default class PostEventBuilder extends Component {
}} }}
/> />
</EventField> </EventField>
{{/if}}
<EventField
@label="discourse_post_event.builder_modal.description.label"
class="description"
>
<Textarea
@value={{@model.event.description}}
placeholder={{i18n
"discourse_post_event.builder_modal.description.placeholder"
}}
/>
</EventField>
<EventField <EventField
class="timezone" class="timezone"
@ -383,7 +413,6 @@ export default class PostEventBuilder extends Component {
@value={{@model.event.timezone}} @value={{@model.event.timezone}}
@onChange={{this.setNewTimezone}} @onChange={{this.setNewTimezone}}
@none="discourse_post_event.builder_modal.timezone.remove_timezone" @none="discourse_post_event.builder_modal.timezone.remove_timezone"
class="input-xxlarge"
/> />
</EventField> </EventField>

View File

@ -1,39 +1,37 @@
import Component from "@ember/component"; import Component from "@glimmer/component";
import { action } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import willDestroy from "@ember/render-modifiers/modifiers/will-destroy";
import { LinkTo } from "@ember/routing";
import { schedule } from "@ember/runloop"; import { schedule } from "@ember/runloop";
import { tagName } from "@ember-decorators/component"; import { service } from "@ember/service";
import { Promise } from "rsvp"; import { Promise } from "rsvp";
import getURL from "discourse/lib/get-url"; import getURL from "discourse/lib/get-url";
import loadScript from "discourse/lib/load-script"; import loadScript from "discourse/lib/load-script";
import Category from "discourse/models/category"; import Category from "discourse/models/category";
import { i18n } from "discourse-i18n";
import { formatEventName } from "../helpers/format-event-name"; import { formatEventName } from "../helpers/format-event-name";
import addRecurrentEvents from "../lib/add-recurrent-events"; import addRecurrentEvents from "../lib/add-recurrent-events";
import fullCalendarDefaultOptions from "../lib/full-calendar-default-options"; import fullCalendarDefaultOptions from "../lib/full-calendar-default-options";
import { isNotFullDayEvent } from "../lib/guess-best-date-format"; import { isNotFullDayEvent } from "../lib/guess-best-date-format";
@tagName("")
export default class UpcomingEventsCalendar extends Component { export default class UpcomingEventsCalendar extends Component {
events = null; @service currentUser;
@service site;
@service router;
init() { _calendar = null;
super.init(...arguments);
@action
teardown() {
this._calendar?.destroy?.();
this._calendar = null; this._calendar = null;
} }
willDestroyElement() { @action
super.willDestroyElement(...arguments); async renderCalendar() {
this._calendar && this._calendar.destroy();
this._calendar = null;
}
didInsertElement() {
super.didInsertElement(...arguments);
this._renderCalendar();
}
async _renderCalendar() {
const siteSettings = this.site.siteSettings; const siteSettings = this.site.siteSettings;
const isMobileView = this.site.mobileView;
const calendarNode = document.getElementById("upcoming-events-calendar"); const calendarNode = document.getElementById("upcoming-events-calendar");
if (!calendarNode) { if (!calendarNode) {
@ -44,16 +42,50 @@ export default class UpcomingEventsCalendar extends Component {
await this._loadCalendar(); await this._loadCalendar();
const view =
this.args.controller.view || (isMobileView ? "listNextYear" : "month");
const fullCalendar = new window.FullCalendar.Calendar(calendarNode, { const fullCalendar = new window.FullCalendar.Calendar(calendarNode, {
...fullCalendarDefaultOptions(), ...fullCalendarDefaultOptions(),
firstDay: 1, firstDay: 1,
height: "auto", height: "auto",
defaultView: view,
views: {
listNextYear: {
type: "list",
duration: { days: 365 },
buttonText: "list",
listDayFormat: {
month: "long",
year: "numeric",
day: "numeric",
weekday: "long",
},
},
},
header: {
left: "prev,next today",
center: "title",
right: "month,basicWeek,listNextYear",
},
datesRender: (info) => {
// this is renamed in FullCalendar v5 / v6 to datesSet
// in unit tests we skip
if (this.router?.transitionTo) {
this.router.transitionTo({ queryParams: { view: info.view.type } });
}
},
eventPositioned: (info) => { eventPositioned: (info) => {
if (siteSettings.events_max_rows === 0) { if (siteSettings.events_max_rows === 0) {
return; return;
} }
let fcContent = info.el.querySelector(".fc-content"); let fcContent = info.el.querySelector(".fc-content");
if (!fcContent) {
return;
}
let computedStyle = window.getComputedStyle(fcContent); let computedStyle = window.getComputedStyle(fcContent);
let lineHeight = parseInt(computedStyle.lineHeight, 10); let lineHeight = parseInt(computedStyle.lineHeight, 10);
@ -78,7 +110,7 @@ export default class UpcomingEventsCalendar extends Component {
const tagsColorsMap = JSON.parse(siteSettings.map_events_to_color); const tagsColorsMap = JSON.parse(siteSettings.map_events_to_color);
const resolvedEvents = await this.events; const resolvedEvents = await this.args.controller.model;
const originalEventAndRecurrents = addRecurrentEvents(resolvedEvents); const originalEventAndRecurrents = addRecurrentEvents(resolvedEvents);
(originalEventAndRecurrents || []).forEach((event) => { (originalEventAndRecurrents || []).forEach((event) => {
@ -143,6 +175,31 @@ export default class UpcomingEventsCalendar extends Component {
} }
<template> <template>
<div id="upcoming-events-calendar"></div> {{#if this.currentUser}}
<ul class="events-filter nav nav-pills">
<li>
<LinkTo
@route="discourse-post-event-upcoming-events.index"
class="btn-small"
>
{{i18n "discourse_post_event.upcoming_events.all_events"}}
</LinkTo>
</li>
<li>
<LinkTo
@route="discourse-post-event-upcoming-events.mine"
class="btn-small"
>
{{i18n "discourse_post_event.upcoming_events.my_events"}}
</LinkTo>
</li>
</ul>
{{/if}}
<div
id="upcoming-events-calendar"
{{didInsert this.renderCalendar}}
{{willDestroy this.teardown}}
></div>
</template> </template>
} }

View File

@ -1,3 +1,5 @@
import Controller from "@ember/controller"; import Controller from "@ember/controller";
export default class DiscoursePostEventUpcomingEventsIndexController extends Controller {} export default class DiscoursePostEventUpcomingEventsIndexController extends Controller {
queryParams = ["view"];
}

View File

@ -0,0 +1,5 @@
import Controller from "@ember/controller";
export default class DiscoursePostEventUpcomingEventsMineController extends Controller {
queryParams = ["view"];
}

View File

@ -4,6 +4,7 @@ export default function () {
{ path: "/upcoming-events" }, { path: "/upcoming-events" },
function () { function () {
this.route("index", { path: "/" }); this.route("index", { path: "/" });
this.route("mine");
} }
); );
} }

View File

@ -17,6 +17,14 @@ export function buildParams(startsAt, endsAt, event, siteSettings) {
params.name = event.name; params.name = event.name;
} }
if (event.location) {
params.location = event.location;
}
if (event.description) {
params.description = event.description;
}
if (event.url) { if (event.url) {
params.url = event.url; params.url = event.url;
} }
@ -92,12 +100,16 @@ export function buildParams(startsAt, endsAt, event, siteSettings) {
} }
export function replaceRaw(params, raw) { export function replaceRaw(params, raw) {
const eventRegex = new RegExp(`\\[event\\s(.*?)\\]`, "m"); const eventRegex = /\[event (.*?)\](.*?)\[\/event\]/s;
const eventMatches = raw.match(eventRegex); const eventMatches = raw.match(eventRegex);
if (eventMatches && eventMatches[1]) { if (eventMatches && eventMatches[1]) {
const markdownParams = []; const markdownParams = [];
let description = params.description;
description = description ? `${description}\n` : "";
delete params.description;
Object.keys(params).forEach((param) => { Object.keys(params).forEach((param) => {
const value = params[param]; const value = params[param];
if (value && value.length) { if (value && value.length) {
@ -105,7 +117,10 @@ export function replaceRaw(params, raw) {
} }
}); });
return raw.replace(eventRegex, `[event ${markdownParams.join(" ")}]`); return raw.replace(
eventRegex,
`[event ${markdownParams.join(" ")}]\n${description}[/event]`
);
} }
return false; return false;

View File

@ -29,7 +29,9 @@ export default class DiscoursePostEventEvent {
@tracked startsAt; @tracked startsAt;
@tracked endsAt; @tracked endsAt;
@tracked rawInvitees; @tracked rawInvitees;
@tracked location;
@tracked url; @tracked url;
@tracked description;
@tracked timezone; @tracked timezone;
@tracked showLocalTime; @tracked showLocalTime;
@tracked status; @tracked status;
@ -63,7 +65,9 @@ export default class DiscoursePostEventEvent {
this.endsAt = args.ends_at; this.endsAt = args.ends_at;
this.rawInvitees = args.raw_invitees; this.rawInvitees = args.raw_invitees;
this.sampleInvitees = args.sample_invitees || []; this.sampleInvitees = args.sample_invitees || [];
this.location = args.location;
this.url = args.url; this.url = args.url;
this.description = args.description;
this.timezone = args.timezone; this.timezone = args.timezone;
this.showLocalTime = args.show_local_time; this.showLocalTime = args.show_local_time;
this.status = args.status; this.status = args.status;
@ -145,9 +149,11 @@ export default class DiscoursePostEventEvent {
this.name = event.name; this.name = event.name;
this.startsAt = event.startsAt; this.startsAt = event.startsAt;
this.endsAt = event.endsAt; this.endsAt = event.endsAt;
this.location = event.location;
this.url = event.url; this.url = event.url;
this.timezone = event.timezone; this.timezone = event.timezone;
this.showLocalTime = event.showLocalTime; this.showLocalTime = event.showLocalTime;
this.description = event.description;
this.status = event.status; this.status = event.status;
this.creator = event.creator; this.creator = event.creator;
this.isClosed = event.isClosed; this.isClosed = event.isClosed;

View File

@ -4,7 +4,6 @@ import DiscourseURL from "discourse/lib/url";
import DiscourseRoute from "discourse/routes/discourse"; import DiscourseRoute from "discourse/routes/discourse";
export default class PostEventUpcomingEventsIndexRoute extends DiscourseRoute { export default class PostEventUpcomingEventsIndexRoute extends DiscourseRoute {
@service discoursePostEventApi;
@service discoursePostEventService; @service discoursePostEventService;
@action @action

View File

@ -0,0 +1,22 @@
import { action } from "@ember/object";
import { service } from "@ember/service";
import DiscourseURL from "discourse/lib/url";
import DiscourseRoute from "discourse/routes/discourse";
export default class PostEventUpcomingEventsIndexRoute extends DiscourseRoute {
@service discoursePostEventApi;
@service discoursePostEventService;
@service currentUser;
@action
activate() {
if (!this.siteSettings.discourse_post_event_enabled) {
DiscourseURL.redirectTo("/404");
}
}
async model(params) {
params.attending_user = this.currentUser?.username;
return await this.discoursePostEventService.fetchEvents(params);
}
}

View File

@ -4,7 +4,7 @@ import UpcomingEventsCalendar from "../components/upcoming-events-calendar";
export default RouteTemplate( export default RouteTemplate(
<template> <template>
<div class="discourse-post-event-upcoming-events"> <div class="discourse-post-event-upcoming-events">
<UpcomingEventsCalendar @events={{@controller.model}} /> <UpcomingEventsCalendar @controller={{@controller}} />
</div> </div>
</template> </template>
); );

View File

@ -0,0 +1,10 @@
import RouteTemplate from "ember-route-template";
import UpcomingEventsCalendar from "../components/upcoming-events-calendar";
export default RouteTemplate(
<template>
<div class="discourse-post-event-upcoming-events">
<UpcomingEventsCalendar @controller={{@controller}} />
</div>
</template>
);

View File

@ -1,12 +1,16 @@
$interested: #fb985d; $interested: #fb985d;
$show-interested: inherit; $show-interested: inherit;
.cooked > .discourse-post-event {
display: none;
}
.discourse-post-event { .discourse-post-event {
display: flex; display: flex;
justify-content: center; justify-content: center;
.event__section { .event__section {
padding: 0.5em 1em; padding: 0.5em 0.75rem;
&:first-child { &:first-child {
border-top: 1px solid var(--primary-low); border-top: 1px solid var(--primary-low);
@ -176,8 +180,6 @@ $show-interested: inherit;
align-items: center; align-items: center;
.event-creator { .event-creator {
margin-left: 0.25em;
.topic-invitee-avatar { .topic-invitee-avatar {
display: flex; display: flex;
align-items: center; align-items: center;
@ -348,6 +350,11 @@ $show-interested: inherit;
margin: 0; margin: 0;
} }
.event-description {
display: flex;
}
.event-location,
.event-url, .event-url,
.event-dates, .event-dates,
.event-chat-channel, .event-chat-channel,
@ -400,6 +407,7 @@ $show-interested: inherit;
position: relative; position: relative;
display: flex; display: flex;
margin: 0 auto; margin: 0 auto;
color: var(--primary-high);
} }
.event-invitees-icon .going { .event-invitees-icon .going {

View File

@ -35,6 +35,10 @@
width: 550px; width: 550px;
} }
.conditional-loading-section {
background: transparent;
}
.modal-body { .modal-body {
min-height: 200px; min-height: 200px;
@ -107,6 +111,13 @@
margin-bottom: 2em; margin-bottom: 2em;
flex-direction: column; flex-direction: column;
&.description {
textarea {
border-radius: var(--d-input-border-radius);
margin: 0;
}
}
&.name, &.name,
&.url { &.url {
input { input {

View File

@ -372,6 +372,8 @@ en:
creator: "Creator" creator: "Creator"
status: "Status" status: "Status"
starts_at: "Starts at" starts_at: "Starts at"
all_events: "All events"
my_events: "My events"
upcoming_events_list: upcoming_events_list:
title: "Upcoming events" title: "Upcoming events"
empty: "No upcoming events" empty: "No upcoming events"
@ -472,6 +474,12 @@ en:
url: url:
label: "URL" label: "URL"
placeholder: "Optional" placeholder: "Optional"
location:
label: "Location"
placeholder: "Add a location, link or something."
description:
label: "Description"
placeholder: "Tell people a little bit more about your event. New lines and links are supported."
name: name:
label: "Event name" label: "Event name"
placeholder: "Optional, defaults to topic title" placeholder: "Optional, defaults to topic title"

View File

@ -13,6 +13,7 @@ DiscoursePostEvent::Engine.routes.draw do
get "/discourse-post-event/events/:post_id/invitees" => "invitees#index" get "/discourse-post-event/events/:post_id/invitees" => "invitees#index"
delete "/discourse-post-event/events/:post_id/invitees/:id" => "invitees#destroy" delete "/discourse-post-event/events/:post_id/invitees/:id" => "invitees#destroy"
get "/upcoming-events" => "upcoming_events#index" get "/upcoming-events" => "upcoming_events#index"
get "/upcoming-events/mine" => "upcoming_events#index"
end end
Discourse::Application.routes.draw do Discourse::Application.routes.draw do

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddLocationToEvent < ActiveRecord::Migration[7.2]
def change
add_column :discourse_post_event_events, :location, :string, limit: 1000
end
end

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddDescriptionToEvent < ActiveRecord::Migration[7.2]
def change
add_column :discourse_post_event_events, :description, :string, limit: 1000
end
end

View File

@ -33,6 +33,33 @@ module DiscoursePostEvent
events = events.where(id: Array(params[:post_id])) if params[:post_id] events = events.where(id: Array(params[:post_id])) if params[:post_id]
if params[:attending_user].present?
attending_user = User.find_by(username_lower: params[:attending_user].downcase)
if attending_user
events =
events.joins(:invitees).where(
discourse_post_event_invitees: {
user_id: attending_user.id,
status: DiscoursePostEvent::Invitee.statuses[:going],
},
)
if !guardian.is_admin?
events =
events.where(
"discourse_post_event_events.status != ? OR discourse_post_event_events.status = ? AND EXISTS (
SELECT 1 FROM discourse_post_event_invitees dpoei
WHERE dpoei.post_id = discourse_post_event_events.id
AND dpoei.user_id = ?
)",
DiscoursePostEvent::Event.statuses[:private],
DiscoursePostEvent::Event.statuses[:private],
user&.id,
)
end
end
end
if params[:before].present? if params[:before].present?
events = events.where("dcped.starts_at < ?", params[:before].to_datetime) events = events.where("dcped.starts_at < ?", params[:before].to_datetime)
end end

View File

@ -8,6 +8,7 @@ module DiscoursePostEvent
:status, :status,
:"allowed-groups", :"allowed-groups",
:url, :url,
:location,
:name, :name,
:reminders, :reminders,
:recurrence, :recurrence,
@ -59,6 +60,7 @@ module DiscoursePostEvent
end end
end end
end end
event[:description] = doc.text.strip if event
event event
end end
.compact .compact

View File

@ -37,6 +37,7 @@ register_svg_icon "clock"
register_svg_icon "file-csv" register_svg_icon "file-csv"
register_svg_icon "star" register_svg_icon "star"
register_svg_icon "file-arrow-up" register_svg_icon "file-arrow-up"
register_svg_icon "location-pin"
module ::DiscourseCalendar module ::DiscourseCalendar
PLUGIN_NAME = "discourse-calendar" PLUGIN_NAME = "discourse-calendar"

View File

@ -13,6 +13,62 @@ describe DiscoursePostEvent::EventFinder do
Group.refresh_automatic_groups! Group.refresh_automatic_groups!
end end
describe "by attending user" do
fab!(:attending_user) { Fabricate(:user) }
fab!(:public_event) { Fabricate(:event, status: DiscoursePostEvent::Event.statuses[:public]) }
fab!(:private_event) { Fabricate(:event, status: DiscoursePostEvent::Event.statuses[:private]) }
fab!(:another_event) { Fabricate(:event, status: DiscoursePostEvent::Event.statuses[:public]) }
fab!(:attending_public_event) do
DiscoursePostEvent::Invitee.create!(
user: attending_user,
event: public_event,
status: DiscoursePostEvent::Invitee.statuses[:going],
)
end
fab!(:attending_private_event) do
DiscoursePostEvent::Invitee.create!(
user: attending_user,
event: private_event,
status: DiscoursePostEvent::Invitee.statuses[:going],
)
end
fab!(:not_attending_event) do
DiscoursePostEvent::Invitee.create!(
user: attending_user,
event: another_event,
status: DiscoursePostEvent::Invitee.statuses[:not_going],
)
end
it "returns only events the user is attending" do
expect(
finder.search(current_user, { attending_user: attending_user.username }),
).to match_array([public_event])
end
it "includes private events for admin users" do
current_user.update!(admin: true)
expect(
finder.search(current_user, { attending_user: attending_user.username }),
).to match_array([public_event, private_event])
end
it "includes private events if the searching user is also invited" do
DiscoursePostEvent::Invitee.create!(
user: current_user,
event: private_event,
status: DiscoursePostEvent::Invitee.statuses[:going],
)
expect(
finder.search(current_user, { attending_user: attending_user.username }),
).to match_array([public_event, private_event])
end
end
context "when the event is associated to a visible post" do context "when the event is associated to a visible post" do
let(:post1) do let(:post1) do
PostCreator.create!( PostCreator.create!(

View File

@ -4,8 +4,14 @@ module PageObjects
module Pages module Pages
module DiscourseCalendar module DiscourseCalendar
class PostEvent < PageObjects::Pages::Base class PostEvent < PageObjects::Pages::Base
TRIGGER_MENU_SELECTOR = ".discourse-post-event-more-menu-trigger"
def open_more_menu def open_more_menu
find(".discourse-post-event-more-menu-trigger").click find(TRIGGER_MENU_SELECTOR).click
self
end
def going
find(".going-button").click
self self
end end
@ -14,6 +20,39 @@ module PageObjects
find(".dropdown-menu__item.bulk-invite").click find(".dropdown-menu__item.bulk-invite").click
self self
end end
def has_location?(text)
has_css?(".event-location", text:)
end
def has_description?(text)
has_css?(".event-description", text:)
end
def close
has_css?(".discourse-post-event .status-and-creators .status:not(.closed)")
open_more_menu
find(".close-event").click
find("#dialog-holder .btn-primary").click
has_css?(".discourse-post-event .status-and-creators .status.closed")
has_no_css?("#{TRIGGER_MENU_SELECTOR}.--saving")
self
end
def open
has_css?(".discourse-post-event .status-and-creators .status.closed")
open_more_menu
find(".open-event").click
find("#dialog-holder .btn-primary").click
has_css?(".discourse-post-event .status-and-creators .status:not(.closed)")
has_no_css?("#{TRIGGER_MENU_SELECTOR}.--saving")
self
end
def edit
open_more_menu
find(".edit-event").click
end
end end
end end
end end

View File

@ -0,0 +1,35 @@
# frozen_string_literal: true
module PageObjects
module Pages
module DiscourseCalendar
class PostEventForm < PageObjects::Pages::Base
MODAL_SELECTOR = ".post-event-builder-modal"
def fill_location(with)
form.find(".event-field.location input").fill_in(with:)
self
end
def fill_description(with)
form.find(".event-field.description textarea").fill_in(with:)
self
end
def form
modal.find("form")
end
def modal
find(MODAL_SELECTOR)
end
def submit
modal.find(".d-modal__footer .btn-primary").click
has_no_selector?(MODAL_SELECTOR)
self
end
end
end
end
end

View File

@ -2,10 +2,12 @@
describe "Post event", type: :system do describe "Post event", type: :system do
fab!(:admin) fab!(:admin)
fab!(:user) { Fabricate(:admin, username: "jane") } fab!(:user) { Fabricate(:admin) }
fab!(:group) { Fabricate(:group, name: "test_group") } fab!(:group)
let(:composer) { PageObjects::Components::Composer.new } let(:composer) { PageObjects::Components::Composer.new }
let(:post_event_page) { PageObjects::Pages::DiscourseCalendar::PostEvent.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 } let(:bulk_invite_modal_page) { PageObjects::Pages::DiscourseCalendar::BulkInviteModal.new }
before do before do
@ -15,6 +17,50 @@ describe "Post event", type: :system do
sign_in(admin) sign_in(admin)
end end
context "with location" do
it "can save a location" do
post =
PostCreator.create(
admin,
title: "My test meetup event",
raw: "[event start='2222-02-22 14:22']\n[/event]",
)
visit(post.topic.url)
post_event_page.edit
post_event_form_page.fill_location("123 Main St, Brisbane, Australia http://example.com")
post_event_form_page.submit
expect(post_event_page).to have_location(
"123 Main St, Brisbane, Australia http://example.com",
)
expect(page).to have_css(".event-location a[href='http://example.com']")
end
end
context "with description" do
it "can save a description" do
post =
PostCreator.create(
admin,
title: "My test meetup event",
raw: "[event start='2222-02-22 14:22']\n[/event]",
)
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},
)
expect(page).to have_css(".event-description a[href='http://example.com']")
end
end
context "when showing local time", timezone: "Australia/Brisbane" do context "when showing local time", timezone: "Australia/Brisbane" do
it "correctly shows month/day" do it "correctly shows month/day" do
page.driver.with_playwright_page do |pw_page| page.driver.with_playwright_page do |pw_page|
@ -67,43 +113,25 @@ describe "Post event", type: :system do
visit "/new-topic" visit "/new-topic"
title = "My upcoming l33t event" title = "My upcoming l33t event"
tomorrow = (Time.zone.now + 1.day).strftime("%Y-%m-%d") tomorrow = (Time.zone.now + 1.day).strftime("%Y-%m-%d")
composer.fill_title(title) composer.fill_title(title)
composer.fill_content <<~MD composer.fill_content <<~MD
[event start="#{tomorrow} 13:37" status="public"] [event start="#{tomorrow} 13:37" status="public"]
[/event] [/event]
MD MD
composer.submit composer.submit
expect(page).to have_content(title) expect(page).to have_content(title)
find(".more-dropdown").click post_event_page.close
find(".close-event").click post_event_page.open
find("#dialog-holder .btn-primary").click post_event_page.going.open_more_menu
expect(page).to have_css(".discourse-post-event .status-and-creators .status.closed")
# click on a different button to ensure more dropdown is collapsed before reopening
find(".btn-primary.create").click
find(".more-dropdown").click
find(".open-event").click
find("#dialog-holder .btn-primary").click
expect(page).to have_css(".going-button")
find(".going-button").click
find(".discourse-post-event-more-menu-trigger").click
find(".show-all-participants").click find(".show-all-participants").click
find(".d-modal input.filter").fill_in(with: "jan") find(".d-modal input.filter").fill_in(with: user.username)
find(".d-modal .add-invitee").click find(".d-modal .add-invitee").click
topic_page = PageObjects::Pages::Topic.new topic_page = PageObjects::Pages::Topic.new
try_until_success do try_until_success do
topic = Topic.find(topic_page.current_topic_id) topic = Topic.find(topic_page.current_topic_id)
event = topic.posts.first.event event = topic.posts.first.event
expect(event.invitees.count).to eq(2) expect(event.invitees.count).to eq(2)
@ -117,9 +145,9 @@ describe "Post event", type: :system do
title: "My test meetup event", title: "My test meetup event",
raw: "[event name='cool-event' status='standalone' start='2222-02-22 00:00' ]\n[/event]", raw: "[event name='cool-event' status='standalone' start='2222-02-22 00:00' ]\n[/event]",
) )
visit(post.topic.url) visit(post.topic.url)
page.find(".discourse-post-event-more-menu-trigger").click post_event_page.open_more_menu
expect(page).to have_no_css(".show-all-participants") expect(page).to have_no_css(".show-all-participants")
end end
@ -132,7 +160,8 @@ describe "Post event", type: :system do
) )
visit(post.topic.url) visit(post.topic.url)
page.find(".discourse-post-event-more-menu-trigger").click post_event_page.going.open_more_menu
expect(page).to have_no_css(".send-pm-to-creator") expect(page).to have_no_css(".send-pm-to-creator")
end end
@ -143,7 +172,7 @@ describe "Post event", type: :system do
dropdown.expand dropdown.expand
dropdown.select_row_by_name(I18n.t("js.discourse_post_event.builder_modal.attach")) dropdown.select_row_by_name(I18n.t("js.discourse_post_event.builder_modal.attach"))
find(".d-modal input[name=status][value=private]").click find(".d-modal input[name=status][value=private]").click
find(".d-modal input.group-selector").send_keys("test_") find(".d-modal input.group-selector").send_keys(group.name)
find(".autocomplete.ac-group").click find(".autocomplete.ac-group").click
find(".d-modal .custom-field-input").fill_in(with: "custom value") find(".d-modal .custom-field-input").fill_in(with: "custom value")
dropdown = PageObjects::Components::SelectKit.new(".available-recurrences") dropdown = PageObjects::Components::SelectKit.new(".available-recurrences")
@ -155,11 +184,10 @@ describe "Post event", type: :system do
expect(page).to have_css(".discourse-post-event.is-loaded") expect(page).to have_css(".discourse-post-event.is-loaded")
find(".discourse-post-event-more-menu-trigger").click post_event_page.edit
find(".edit-event").click
expect(find(".d-modal input[name=status][value=private]").checked?).to eq(true) expect(find(".d-modal input[name=status][value=private]").checked?).to eq(true)
expect(find(".d-modal")).to have_text("test_group") expect(find(".d-modal")).to have_text(group.name)
expect(find(".d-modal .custom-field-input").value).to eq("custom value") expect(find(".d-modal .custom-field-input").value).to eq("custom value")
expect(page).to have_selector(".d-modal .recurrence-until .date-picker") do |input| expect(page).to have_selector(".d-modal .recurrence-until .date-picker") do |input|
input.value == "#{1.year.from_now.year}-12-30" input.value == "#{1.year.from_now.year}-12-30"

View File

@ -3,7 +3,7 @@ import { replaceRaw } from "discourse/plugins/discourse-calendar/discourse/lib/r
module("Unit | Lib | raw-event-helper", function () { module("Unit | Lib | raw-event-helper", function () {
test("replaceRaw", function (assert) { test("replaceRaw", function (assert) {
const raw = 'Some text [event param1="value1"] more text'; const raw = 'Some text \n[event param1="va]lue1"]\n[/event]\n more text';
const params = { const params = {
param1: "newValue1", param1: "newValue1",
param2: "value2", param2: "value2",
@ -11,7 +11,7 @@ module("Unit | Lib | raw-event-helper", function () {
assert.strictEqual( assert.strictEqual(
replaceRaw(params, raw), replaceRaw(params, raw),
'Some text [event param1="newValue1" param2="value2"] more text', 'Some text \n[event param1="newValue1" param2="value2"]\n[/event]\n more text',
"updates existing parameters and adds new ones" "updates existing parameters and adds new ones"
); );
@ -21,14 +21,14 @@ module("Unit | Lib | raw-event-helper", function () {
); );
assert.strictEqual( assert.strictEqual(
replaceRaw({ foo: 'bar"quoted' }, '[event original="value"]'), replaceRaw({ foo: 'bar"quoted' }, '[event original="value"]\n[/event]'),
'[event foo="barquoted"]', '[event foo="barquoted"]\n[/event]',
"escapes double quotes in parameter values" "escapes double quotes in parameter values"
); );
assert.strictEqual( assert.strictEqual(
replaceRaw({}, '[event param1="value1"]'), replaceRaw({}, '[event param1="value1"]\n[/event]'),
"[event ]", "[event ]\n[/event]",
"handles empty params object" "handles empty params object"
); );
}); });