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:
parent
781ecefc62
commit
bdf8869a01
|
|
@ -123,6 +123,7 @@ module DiscoursePostEvent
|
|||
:include_expired,
|
||||
:limit,
|
||||
:before,
|
||||
:attending_user,
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -309,6 +309,8 @@ module DiscoursePostEvent
|
|||
original_starts_at: parsed_starts_at,
|
||||
original_ends_at: parsed_ends_at,
|
||||
url: event_params[:url],
|
||||
description: event_params[:description],
|
||||
location: event_params[:location],
|
||||
recurrence: event_params[:recurrence],
|
||||
recurrence_until: parsed_recurrence_until,
|
||||
timezone: event_params[:timezone],
|
||||
|
|
@ -420,6 +422,8 @@ end
|
|||
# raw_invitees :string is an Array
|
||||
# name :string
|
||||
# url :string(1000)
|
||||
# description :string(1000)
|
||||
# location :string(1000)
|
||||
# custom_fields :jsonb not null
|
||||
# reminders :string
|
||||
# recurrence :string
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ module DiscoursePostEvent
|
|||
attributes :timezone
|
||||
attributes :show_local_time
|
||||
attributes :url
|
||||
attributes :description
|
||||
attributes :location
|
||||
attributes :watching_invitee
|
||||
attributes :chat_enabled
|
||||
attributes :channel
|
||||
|
|
@ -135,6 +137,10 @@ module DiscoursePostEvent
|
|||
object.post.topic.category_id
|
||||
end
|
||||
|
||||
def include_url?
|
||||
object.url.present?
|
||||
end
|
||||
|
||||
def include_recurrence_rule?
|
||||
object.recurring?
|
||||
end
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -10,8 +10,10 @@ import routeAction from "discourse/helpers/route-action";
|
|||
import ChatChannel from "./chat-channel";
|
||||
import Creator from "./creator";
|
||||
import Dates from "./dates";
|
||||
import Description from "./description";
|
||||
import EventStatus from "./event-status";
|
||||
import Invitees from "./invitees";
|
||||
import Location from "./location";
|
||||
import MoreMenu from "./more-menu";
|
||||
import Status from "./status";
|
||||
import Url from "./url";
|
||||
|
|
@ -130,16 +132,20 @@ export default class DiscoursePostEvent extends Component {
|
|||
event=@event
|
||||
Section=(component InfoSection event=@event)
|
||||
Url=(component Url url=@event.url)
|
||||
Description=(component Description description=@event.description)
|
||||
Location=(component Location location=@event.location)
|
||||
Dates=(component Dates event=@event)
|
||||
Invitees=(component Invitees event=@event)
|
||||
Status=(component Status event=@event)
|
||||
ChatChannel=(component ChatChannel event=@event)
|
||||
}}
|
||||
>
|
||||
<Url @url={{@event.url}} />
|
||||
<Dates @event={{@event}} />
|
||||
<Location @location={{@event.location}} />
|
||||
<Url @url={{@event.url}} />
|
||||
<ChatChannel @event={{@event}} />
|
||||
<Invitees @event={{@event}} />
|
||||
<Description @description={{@event.description}} />
|
||||
{{#if @event.canUpdateAttendance}}
|
||||
<Status @event={{@event}} />
|
||||
{{/if}}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -1,9 +1,11 @@
|
|||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { hash } from "@ember/helper";
|
||||
import EmberObject, { action } from "@ember/object";
|
||||
import { service } from "@ember/service";
|
||||
import DButton from "discourse/components/d-button";
|
||||
import DropdownMenu from "discourse/components/dropdown-menu";
|
||||
import concatClass from "discourse/helpers/concat-class";
|
||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
import { downloadCalendar } from "discourse/lib/download-calendar";
|
||||
import { exportEntity } from "discourse/lib/export-csv";
|
||||
|
|
@ -27,6 +29,8 @@ export default class DiscoursePostEventMoreMenu extends Component {
|
|||
@service siteSettings;
|
||||
@service store;
|
||||
|
||||
@tracked isSavingEvent = false;
|
||||
|
||||
get expiredOrClosed() {
|
||||
return this.args.event.isExpired || this.args.event.isClosed;
|
||||
}
|
||||
|
|
@ -147,6 +151,8 @@ export default class DiscoursePostEventMoreMenu extends Component {
|
|||
this.dialog.yesNoConfirm({
|
||||
message: i18n("discourse_post_event.builder_modal.confirm_open"),
|
||||
didConfirm: async () => {
|
||||
this.isSavingEvent = true;
|
||||
|
||||
try {
|
||||
const post = await this.store.find("post", this.args.event.id);
|
||||
this.args.event.isClosed = false;
|
||||
|
|
@ -172,6 +178,8 @@ export default class DiscoursePostEventMoreMenu extends Component {
|
|||
}
|
||||
} catch (e) {
|
||||
popupAjaxError(e);
|
||||
} finally {
|
||||
this.isSavingEvent = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
@ -208,6 +216,7 @@ export default class DiscoursePostEventMoreMenu extends Component {
|
|||
this.dialog.yesNoConfirm({
|
||||
message: i18n("discourse_post_event.builder_modal.confirm_close"),
|
||||
didConfirm: () => {
|
||||
this.isSavingEvent = true;
|
||||
return this.store.find("post", this.args.event.id).then((post) => {
|
||||
this.args.event.isClosed = true;
|
||||
|
||||
|
|
@ -226,10 +235,14 @@ export default class DiscoursePostEventMoreMenu extends Component {
|
|||
edit_reason: i18n("discourse_post_event.edit_reason_closed"),
|
||||
};
|
||||
|
||||
return cook(newRaw).then((cooked) => {
|
||||
props.cooked = cooked.string;
|
||||
return post.save(props);
|
||||
});
|
||||
return cook(newRaw)
|
||||
.then((cooked) => {
|
||||
props.cooked = cooked.string;
|
||||
return post.save(props);
|
||||
})
|
||||
.finally(() => {
|
||||
this.isSavingEvent = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
|
@ -239,7 +252,10 @@ export default class DiscoursePostEventMoreMenu extends Component {
|
|||
<template>
|
||||
<DMenu
|
||||
@identifier="discourse-post-event-more-menu"
|
||||
@triggerClass="more-dropdown"
|
||||
@triggerClass={{concatClass
|
||||
"more-dropdown"
|
||||
(if this.isSavingEvent "--saving")
|
||||
}}
|
||||
@icon="ellipsis"
|
||||
@onRegisterApi={{this.registerMenuApi}}
|
||||
>
|
||||
|
|
@ -333,6 +349,7 @@ export default class DiscoursePostEventMoreMenu extends Component {
|
|||
class="btn-transparent"
|
||||
@label="discourse_post_event.open_event"
|
||||
@action={{this.openEvent}}
|
||||
@disabled={{this.isSavingEvent}}
|
||||
/>
|
||||
</dropdown.item>
|
||||
{{else}}
|
||||
|
|
@ -351,6 +368,7 @@ export default class DiscoursePostEventMoreMenu extends Component {
|
|||
@icon="xmark"
|
||||
@label="discourse_post_event.close_event"
|
||||
@action={{this.closeEvent}}
|
||||
@disabled={{this.isSavingEvent}}
|
||||
class="btn-transparent btn-danger"
|
||||
/>
|
||||
</dropdown.item>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { Input } from "@ember/component";
|
||||
import { Input, Textarea } from "@ember/component";
|
||||
import { concat, fn, get } from "@ember/helper";
|
||||
import { on } from "@ember/modifier";
|
||||
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() {
|
||||
return [
|
||||
{
|
||||
|
|
@ -364,13 +368,39 @@ export default class PostEventBuilder extends Component {
|
|||
</EventField>
|
||||
|
||||
<EventField
|
||||
@label="discourse_post_event.builder_modal.url.label"
|
||||
class="url"
|
||||
@label="discourse_post_event.builder_modal.location.label"
|
||||
class="location"
|
||||
>
|
||||
<Input
|
||||
@value={{@model.event.url}}
|
||||
@value={{@model.event.location}}
|
||||
placeholder={{i18n
|
||||
"discourse_post_event.builder_modal.url.placeholder"
|
||||
"discourse_post_event.builder_modal.location.placeholder"
|
||||
}}
|
||||
/>
|
||||
</EventField>
|
||||
|
||||
{{#if this.shouldRenderUrl}}
|
||||
<EventField
|
||||
@label="discourse_post_event.builder_modal.url.label"
|
||||
class="url"
|
||||
>
|
||||
<Input
|
||||
@value={{@model.event.url}}
|
||||
placeholder={{i18n
|
||||
"discourse_post_event.builder_modal.url.placeholder"
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
|
|
@ -383,7 +413,6 @@ export default class PostEventBuilder extends Component {
|
|||
@value={{@model.event.timezone}}
|
||||
@onChange={{this.setNewTimezone}}
|
||||
@none="discourse_post_event.builder_modal.timezone.remove_timezone"
|
||||
class="input-xxlarge"
|
||||
/>
|
||||
</EventField>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 { tagName } from "@ember-decorators/component";
|
||||
import { service } from "@ember/service";
|
||||
import { Promise } from "rsvp";
|
||||
import getURL from "discourse/lib/get-url";
|
||||
import loadScript from "discourse/lib/load-script";
|
||||
import Category from "discourse/models/category";
|
||||
import { i18n } from "discourse-i18n";
|
||||
import { formatEventName } from "../helpers/format-event-name";
|
||||
import addRecurrentEvents from "../lib/add-recurrent-events";
|
||||
import fullCalendarDefaultOptions from "../lib/full-calendar-default-options";
|
||||
import { isNotFullDayEvent } from "../lib/guess-best-date-format";
|
||||
|
||||
@tagName("")
|
||||
export default class UpcomingEventsCalendar extends Component {
|
||||
events = null;
|
||||
@service currentUser;
|
||||
@service site;
|
||||
@service router;
|
||||
|
||||
init() {
|
||||
super.init(...arguments);
|
||||
_calendar = null;
|
||||
|
||||
@action
|
||||
teardown() {
|
||||
this._calendar?.destroy?.();
|
||||
this._calendar = null;
|
||||
}
|
||||
|
||||
willDestroyElement() {
|
||||
super.willDestroyElement(...arguments);
|
||||
|
||||
this._calendar && this._calendar.destroy();
|
||||
this._calendar = null;
|
||||
}
|
||||
|
||||
didInsertElement() {
|
||||
super.didInsertElement(...arguments);
|
||||
|
||||
this._renderCalendar();
|
||||
}
|
||||
|
||||
async _renderCalendar() {
|
||||
@action
|
||||
async renderCalendar() {
|
||||
const siteSettings = this.site.siteSettings;
|
||||
const isMobileView = this.site.mobileView;
|
||||
|
||||
const calendarNode = document.getElementById("upcoming-events-calendar");
|
||||
if (!calendarNode) {
|
||||
|
|
@ -44,16 +42,50 @@ export default class UpcomingEventsCalendar extends Component {
|
|||
|
||||
await this._loadCalendar();
|
||||
|
||||
const view =
|
||||
this.args.controller.view || (isMobileView ? "listNextYear" : "month");
|
||||
|
||||
const fullCalendar = new window.FullCalendar.Calendar(calendarNode, {
|
||||
...fullCalendarDefaultOptions(),
|
||||
firstDay: 1,
|
||||
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) => {
|
||||
if (siteSettings.events_max_rows === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let fcContent = info.el.querySelector(".fc-content");
|
||||
|
||||
if (!fcContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
let computedStyle = window.getComputedStyle(fcContent);
|
||||
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 resolvedEvents = await this.events;
|
||||
const resolvedEvents = await this.args.controller.model;
|
||||
const originalEventAndRecurrents = addRecurrentEvents(resolvedEvents);
|
||||
|
||||
(originalEventAndRecurrents || []).forEach((event) => {
|
||||
|
|
@ -143,6 +175,31 @@ export default class UpcomingEventsCalendar extends Component {
|
|||
}
|
||||
|
||||
<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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import Controller from "@ember/controller";
|
||||
|
||||
export default class DiscoursePostEventUpcomingEventsIndexController extends Controller {}
|
||||
export default class DiscoursePostEventUpcomingEventsIndexController extends Controller {
|
||||
queryParams = ["view"];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
import Controller from "@ember/controller";
|
||||
|
||||
export default class DiscoursePostEventUpcomingEventsMineController extends Controller {
|
||||
queryParams = ["view"];
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ export default function () {
|
|||
{ path: "/upcoming-events" },
|
||||
function () {
|
||||
this.route("index", { path: "/" });
|
||||
this.route("mine");
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,14 @@ export function buildParams(startsAt, endsAt, event, siteSettings) {
|
|||
params.name = event.name;
|
||||
}
|
||||
|
||||
if (event.location) {
|
||||
params.location = event.location;
|
||||
}
|
||||
|
||||
if (event.description) {
|
||||
params.description = event.description;
|
||||
}
|
||||
|
||||
if (event.url) {
|
||||
params.url = event.url;
|
||||
}
|
||||
|
|
@ -92,12 +100,16 @@ export function buildParams(startsAt, endsAt, event, siteSettings) {
|
|||
}
|
||||
|
||||
export function replaceRaw(params, raw) {
|
||||
const eventRegex = new RegExp(`\\[event\\s(.*?)\\]`, "m");
|
||||
const eventRegex = /\[event (.*?)\](.*?)\[\/event\]/s;
|
||||
const eventMatches = raw.match(eventRegex);
|
||||
|
||||
if (eventMatches && eventMatches[1]) {
|
||||
const markdownParams = [];
|
||||
|
||||
let description = params.description;
|
||||
description = description ? `${description}\n` : "";
|
||||
delete params.description;
|
||||
|
||||
Object.keys(params).forEach((param) => {
|
||||
const value = params[param];
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -29,7 +29,9 @@ export default class DiscoursePostEventEvent {
|
|||
@tracked startsAt;
|
||||
@tracked endsAt;
|
||||
@tracked rawInvitees;
|
||||
@tracked location;
|
||||
@tracked url;
|
||||
@tracked description;
|
||||
@tracked timezone;
|
||||
@tracked showLocalTime;
|
||||
@tracked status;
|
||||
|
|
@ -63,7 +65,9 @@ export default class DiscoursePostEventEvent {
|
|||
this.endsAt = args.ends_at;
|
||||
this.rawInvitees = args.raw_invitees;
|
||||
this.sampleInvitees = args.sample_invitees || [];
|
||||
this.location = args.location;
|
||||
this.url = args.url;
|
||||
this.description = args.description;
|
||||
this.timezone = args.timezone;
|
||||
this.showLocalTime = args.show_local_time;
|
||||
this.status = args.status;
|
||||
|
|
@ -145,9 +149,11 @@ export default class DiscoursePostEventEvent {
|
|||
this.name = event.name;
|
||||
this.startsAt = event.startsAt;
|
||||
this.endsAt = event.endsAt;
|
||||
this.location = event.location;
|
||||
this.url = event.url;
|
||||
this.timezone = event.timezone;
|
||||
this.showLocalTime = event.showLocalTime;
|
||||
this.description = event.description;
|
||||
this.status = event.status;
|
||||
this.creator = event.creator;
|
||||
this.isClosed = event.isClosed;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import DiscourseURL from "discourse/lib/url";
|
|||
import DiscourseRoute from "discourse/routes/discourse";
|
||||
|
||||
export default class PostEventUpcomingEventsIndexRoute extends DiscourseRoute {
|
||||
@service discoursePostEventApi;
|
||||
@service discoursePostEventService;
|
||||
|
||||
@action
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@ import UpcomingEventsCalendar from "../components/upcoming-events-calendar";
|
|||
export default RouteTemplate(
|
||||
<template>
|
||||
<div class="discourse-post-event-upcoming-events">
|
||||
<UpcomingEventsCalendar @events={{@controller.model}} />
|
||||
<UpcomingEventsCalendar @controller={{@controller}} />
|
||||
</div>
|
||||
</template>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
@ -1,12 +1,16 @@
|
|||
$interested: #fb985d;
|
||||
$show-interested: inherit;
|
||||
|
||||
.cooked > .discourse-post-event {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.discourse-post-event {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
.event__section {
|
||||
padding: 0.5em 1em;
|
||||
padding: 0.5em 0.75rem;
|
||||
|
||||
&:first-child {
|
||||
border-top: 1px solid var(--primary-low);
|
||||
|
|
@ -176,8 +180,6 @@ $show-interested: inherit;
|
|||
align-items: center;
|
||||
|
||||
.event-creator {
|
||||
margin-left: 0.25em;
|
||||
|
||||
.topic-invitee-avatar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -348,6 +350,11 @@ $show-interested: inherit;
|
|||
margin: 0;
|
||||
}
|
||||
|
||||
.event-description {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.event-location,
|
||||
.event-url,
|
||||
.event-dates,
|
||||
.event-chat-channel,
|
||||
|
|
@ -400,6 +407,7 @@ $show-interested: inherit;
|
|||
position: relative;
|
||||
display: flex;
|
||||
margin: 0 auto;
|
||||
color: var(--primary-high);
|
||||
}
|
||||
|
||||
.event-invitees-icon .going {
|
||||
|
|
|
|||
|
|
@ -35,6 +35,10 @@
|
|||
width: 550px;
|
||||
}
|
||||
|
||||
.conditional-loading-section {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
min-height: 200px;
|
||||
|
||||
|
|
@ -107,6 +111,13 @@
|
|||
margin-bottom: 2em;
|
||||
flex-direction: column;
|
||||
|
||||
&.description {
|
||||
textarea {
|
||||
border-radius: var(--d-input-border-radius);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.name,
|
||||
&.url {
|
||||
input {
|
||||
|
|
|
|||
|
|
@ -372,6 +372,8 @@ en:
|
|||
creator: "Creator"
|
||||
status: "Status"
|
||||
starts_at: "Starts at"
|
||||
all_events: "All events"
|
||||
my_events: "My events"
|
||||
upcoming_events_list:
|
||||
title: "Upcoming events"
|
||||
empty: "No upcoming events"
|
||||
|
|
@ -472,6 +474,12 @@ en:
|
|||
url:
|
||||
label: "URL"
|
||||
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:
|
||||
label: "Event name"
|
||||
placeholder: "Optional, defaults to topic title"
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ DiscoursePostEvent::Engine.routes.draw do
|
|||
get "/discourse-post-event/events/:post_id/invitees" => "invitees#index"
|
||||
delete "/discourse-post-event/events/:post_id/invitees/:id" => "invitees#destroy"
|
||||
get "/upcoming-events" => "upcoming_events#index"
|
||||
get "/upcoming-events/mine" => "upcoming_events#index"
|
||||
end
|
||||
|
||||
Discourse::Application.routes.draw do
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -33,6 +33,33 @@ module DiscoursePostEvent
|
|||
|
||||
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?
|
||||
events = events.where("dcped.starts_at < ?", params[:before].to_datetime)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ module DiscoursePostEvent
|
|||
:status,
|
||||
:"allowed-groups",
|
||||
:url,
|
||||
:location,
|
||||
:name,
|
||||
:reminders,
|
||||
:recurrence,
|
||||
|
|
@ -59,6 +60,7 @@ module DiscoursePostEvent
|
|||
end
|
||||
end
|
||||
end
|
||||
event[:description] = doc.text.strip if event
|
||||
event
|
||||
end
|
||||
.compact
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ register_svg_icon "clock"
|
|||
register_svg_icon "file-csv"
|
||||
register_svg_icon "star"
|
||||
register_svg_icon "file-arrow-up"
|
||||
register_svg_icon "location-pin"
|
||||
|
||||
module ::DiscourseCalendar
|
||||
PLUGIN_NAME = "discourse-calendar"
|
||||
|
|
|
|||
|
|
@ -13,6 +13,62 @@ describe DiscoursePostEvent::EventFinder do
|
|||
Group.refresh_automatic_groups!
|
||||
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
|
||||
let(:post1) do
|
||||
PostCreator.create!(
|
||||
|
|
|
|||
|
|
@ -4,8 +4,14 @@ module PageObjects
|
|||
module Pages
|
||||
module DiscourseCalendar
|
||||
class PostEvent < PageObjects::Pages::Base
|
||||
TRIGGER_MENU_SELECTOR = ".discourse-post-event-more-menu-trigger"
|
||||
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
|
||||
end
|
||||
|
||||
|
|
@ -14,6 +20,39 @@ module PageObjects
|
|||
find(".dropdown-menu__item.bulk-invite").click
|
||||
self
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -2,10 +2,12 @@
|
|||
|
||||
describe "Post event", type: :system do
|
||||
fab!(:admin)
|
||||
fab!(:user) { Fabricate(:admin, username: "jane") }
|
||||
fab!(:group) { Fabricate(:group, name: "test_group") }
|
||||
fab!(:user) { Fabricate(: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
|
||||
|
|
@ -15,6 +17,50 @@ describe "Post event", type: :system do
|
|||
sign_in(admin)
|
||||
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
|
||||
it "correctly shows month/day" do
|
||||
page.driver.with_playwright_page do |pw_page|
|
||||
|
|
@ -67,43 +113,25 @@ describe "Post event", type: :system do
|
|||
visit "/new-topic"
|
||||
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)
|
||||
|
||||
find(".more-dropdown").click
|
||||
find(".close-event").click
|
||||
find("#dialog-holder .btn-primary").click
|
||||
|
||||
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
|
||||
post_event_page.close
|
||||
post_event_page.open
|
||||
post_event_page.going.open_more_menu
|
||||
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
|
||||
|
||||
topic_page = PageObjects::Pages::Topic.new
|
||||
|
||||
try_until_success do
|
||||
topic = Topic.find(topic_page.current_topic_id)
|
||||
|
||||
event = topic.posts.first.event
|
||||
|
||||
expect(event.invitees.count).to eq(2)
|
||||
|
|
@ -117,9 +145,9 @@ describe "Post event", type: :system do
|
|||
title: "My test meetup event",
|
||||
raw: "[event name='cool-event' status='standalone' start='2222-02-22 00:00' ]\n[/event]",
|
||||
)
|
||||
|
||||
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")
|
||||
end
|
||||
|
||||
|
|
@ -132,7 +160,8 @@ describe "Post event", type: :system do
|
|||
)
|
||||
|
||||
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")
|
||||
end
|
||||
|
||||
|
|
@ -143,7 +172,7 @@ describe "Post event", type: :system do
|
|||
dropdown.expand
|
||||
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.group-selector").send_keys("test_")
|
||||
find(".d-modal input.group-selector").send_keys(group.name)
|
||||
find(".autocomplete.ac-group").click
|
||||
find(".d-modal .custom-field-input").fill_in(with: "custom value")
|
||||
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")
|
||||
|
||||
find(".discourse-post-event-more-menu-trigger").click
|
||||
find(".edit-event").click
|
||||
post_event_page.edit
|
||||
|
||||
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(page).to have_selector(".d-modal .recurrence-until .date-picker") do |input|
|
||||
input.value == "#{1.year.from_now.year}-12-30"
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { replaceRaw } from "discourse/plugins/discourse-calendar/discourse/lib/r
|
|||
|
||||
module("Unit | Lib | raw-event-helper", function () {
|
||||
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 = {
|
||||
param1: "newValue1",
|
||||
param2: "value2",
|
||||
|
|
@ -11,7 +11,7 @@ module("Unit | Lib | raw-event-helper", function () {
|
|||
|
||||
assert.strictEqual(
|
||||
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"
|
||||
);
|
||||
|
||||
|
|
@ -21,14 +21,14 @@ module("Unit | Lib | raw-event-helper", function () {
|
|||
);
|
||||
|
||||
assert.strictEqual(
|
||||
replaceRaw({ foo: 'bar"quoted' }, '[event original="value"]'),
|
||||
'[event foo="barquoted"]',
|
||||
replaceRaw({ foo: 'bar"quoted' }, '[event original="value"]\n[/event]'),
|
||||
'[event foo="barquoted"]\n[/event]',
|
||||
"escapes double quotes in parameter values"
|
||||
);
|
||||
|
||||
assert.strictEqual(
|
||||
replaceRaw({}, '[event param1="value1"]'),
|
||||
"[event ]",
|
||||
replaceRaw({}, '[event param1="value1"]\n[/event]'),
|
||||
"[event ]\n[/event]",
|
||||
"handles empty params object"
|
||||
);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue