FEATURE: upcoming events list component (#463)
* FEATURE: upcoming events list component
This commit is contained in:
parent
b8a8a241c6
commit
f57b09ce8b
|
@ -0,0 +1,178 @@
|
|||
import DButton from "discourse/components/d-button";
|
||||
import Component from "@glimmer/component";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import I18n from "discourse-i18n";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { inject as service } from "@ember/service";
|
||||
import { action } from "@ember/object";
|
||||
import { isNotFullDayEvent } from "../lib/guess-best-date-format";
|
||||
import ConditionalLoadingSpinner from "discourse/components/conditional-loading-spinner";
|
||||
import or from "truth-helpers/helpers/or";
|
||||
|
||||
export const DEFAULT_MONTH_FORMAT = "MMMM YYYY";
|
||||
export const DEFAULT_DATE_FORMAT = "dddd, MMM D";
|
||||
export const DEFAULT_TIME_FORMAT = "LT";
|
||||
|
||||
export default class UpcomingEventsList extends Component {
|
||||
@service appEvents;
|
||||
@service siteSettings;
|
||||
@service router;
|
||||
|
||||
@tracked isLoading = true;
|
||||
@tracked hasError = false;
|
||||
@tracked eventsByMonth = {};
|
||||
|
||||
monthFormat = this.args.params?.monthFormat ?? DEFAULT_MONTH_FORMAT;
|
||||
dateFormat = this.args.params?.dateFormat ?? DEFAULT_DATE_FORMAT;
|
||||
timeFormat = this.args.params?.timeFormat ?? DEFAULT_TIME_FORMAT;
|
||||
|
||||
title = I18n.t("discourse_post_event.upcoming_events_list.title");
|
||||
emptyMessage = I18n.t("discourse_post_event.upcoming_events_list.empty");
|
||||
allDayLabel = I18n.t("discourse_post_event.upcoming_events_list.all_day");
|
||||
errorMessage = I18n.t("discourse_post_event.upcoming_events_list.error");
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
||||
this.appEvents.on("page:changed", this, this.updateEventsByMonth);
|
||||
}
|
||||
|
||||
get shouldRender() {
|
||||
if (!this.categoryId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const eventSettings =
|
||||
this.siteSettings.events_calendar_categories.split("|");
|
||||
|
||||
return eventSettings.includes(this.categoryId.toString());
|
||||
}
|
||||
|
||||
get categoryId() {
|
||||
return this.router.currentRoute.attributes?.category?.id;
|
||||
}
|
||||
|
||||
get hasEmptyResponse() {
|
||||
return (
|
||||
!this.isLoading &&
|
||||
!this.hasError &&
|
||||
Object.keys(this.eventsByMonth).length === 0
|
||||
);
|
||||
}
|
||||
|
||||
@action
|
||||
async updateEventsByMonth() {
|
||||
this.isLoading = true;
|
||||
this.hasError = false;
|
||||
|
||||
try {
|
||||
const { events } = await ajax("/discourse-post-event/events", {
|
||||
data: { category_id: this.categoryId },
|
||||
});
|
||||
|
||||
this.eventsByMonth = this.groupByMonthAndDay(events);
|
||||
} catch {
|
||||
this.hasError = true;
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
formatMonth(month) {
|
||||
return moment(month, "YYYY-MM").format(this.monthFormat);
|
||||
}
|
||||
|
||||
@action
|
||||
formatDate(month, day) {
|
||||
return moment(`${month}-${day}`, "YYYY-MM-DD").format(this.dateFormat);
|
||||
}
|
||||
|
||||
@action
|
||||
formatTime({ starts_at, ends_at }) {
|
||||
return isNotFullDayEvent(moment(starts_at), moment(ends_at))
|
||||
? moment(starts_at).format(this.timeFormat)
|
||||
: this.allDayLabel;
|
||||
}
|
||||
|
||||
groupByMonthAndDay(data) {
|
||||
return data.reduce((result, item) => {
|
||||
const date = new Date(item.starts_at);
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth() + 1;
|
||||
const day = date.getDate();
|
||||
|
||||
const monthKey = `${year}-${month}`;
|
||||
|
||||
result[monthKey] = result[monthKey] ?? {};
|
||||
result[monthKey][day] = result[monthKey][day] ?? [];
|
||||
|
||||
result[monthKey][day].push(item);
|
||||
|
||||
return result;
|
||||
}, {});
|
||||
}
|
||||
|
||||
<template>
|
||||
{{#if this.shouldRender}}
|
||||
<div class="upcoming-events-list">
|
||||
<h3 class="upcoming-events-list__heading">
|
||||
{{this.title}}
|
||||
</h3>
|
||||
|
||||
<div class="upcoming-events-list__container">
|
||||
<ConditionalLoadingSpinner @condition={{this.isLoading}} />
|
||||
|
||||
{{#if this.hasEmptyResponse}}
|
||||
<div class="upcoming-events-list__empty-message">
|
||||
{{this.emptyMessage}}
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.hasError}}
|
||||
<div class="upcoming-events-list__error-message">
|
||||
{{this.errorMessage}}
|
||||
</div>
|
||||
<DButton
|
||||
@action={{this.updateEventsByMonth}}
|
||||
@label="discourse_post_event.upcoming_events_list.try_again"
|
||||
class="btn-link upcoming-events-list__try-again"
|
||||
/>
|
||||
{{/if}}
|
||||
|
||||
{{#unless this.isLoading}}
|
||||
{{#each-in this.eventsByMonth as |month monthData|}}
|
||||
{{#if this.monthFormat}}
|
||||
<h4 class="upcoming-events-list__formatted-month">
|
||||
{{this.formatMonth month}}
|
||||
</h4>
|
||||
{{/if}}
|
||||
|
||||
{{#each-in monthData as |day events|}}
|
||||
<div class="upcoming-events-list__day-section">
|
||||
<div class="upcoming-events-list__formatted-day">
|
||||
{{this.formatDate month day}}
|
||||
</div>
|
||||
|
||||
{{#each events as |event|}}
|
||||
<a
|
||||
class="upcoming-events-list__event"
|
||||
href={{event.post.url}}
|
||||
>
|
||||
<div class="upcoming-events-list__event-time">
|
||||
{{this.formatTime event}}
|
||||
</div>
|
||||
<div class="upcoming-events-list__event-name">
|
||||
{{or event.name event.post.topic.title}}
|
||||
</div>
|
||||
</a>
|
||||
{{/each}}
|
||||
</div>
|
||||
{{/each-in}}
|
||||
{{/each-in}}
|
||||
{{/unless}}
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
|
@ -63,6 +63,7 @@
|
|||
background: var(--tertiary);
|
||||
color: var(--secondary);
|
||||
}
|
||||
|
||||
margin: 0.3em 0 0.3em 0.5em;
|
||||
}
|
||||
|
||||
|
@ -122,6 +123,7 @@
|
|||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.fc-list-item-add-to-calendar {
|
||||
color: var(--tertiary);
|
||||
font-size: var(--font-down-1);
|
||||
|
|
|
@ -0,0 +1,36 @@
|
|||
.upcoming-events-list {
|
||||
&__formatted-month {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__day-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: 0.5rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
&__formatted-day {
|
||||
margin-left: 0.5rem;
|
||||
font-weight: 600;
|
||||
font-size: var(--base-font-size);
|
||||
}
|
||||
|
||||
&__event {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: 1rem;
|
||||
gap: 0.25rem;
|
||||
font-size: var(--font-down-1);
|
||||
line-height: var(--line-height-medium);
|
||||
}
|
||||
|
||||
&__event-name {
|
||||
width: 70%;
|
||||
}
|
||||
|
||||
&__event-time {
|
||||
width: 30%;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
|
@ -441,6 +441,12 @@ en:
|
|||
creator: "Creator"
|
||||
status: "Status"
|
||||
starts_at: "Starts at"
|
||||
upcoming_events_list:
|
||||
title: "Upcoming events"
|
||||
empty: "No upcoming events"
|
||||
all_day: "All-day"
|
||||
error: "Failed to retrieve events"
|
||||
try_again: "Try again"
|
||||
category:
|
||||
sort_topics_by_event_start_date: "Sort topics by event start date."
|
||||
disable_topic_resorting: "Disable topic resorting."
|
||||
|
|
|
@ -32,6 +32,7 @@ register_asset "stylesheets/mobile/discourse-post-event.scss", :mobile
|
|||
register_asset "stylesheets/desktop/discourse-calendar.scss", :desktop
|
||||
register_asset "stylesheets/colors.scss", :color_definitions
|
||||
register_asset "stylesheets/common/user-preferences.scss"
|
||||
register_asset "stylesheets/common/upcoming-events-list.scss"
|
||||
register_svg_icon "fas fa-calendar-day"
|
||||
register_svg_icon "fas fa-clock"
|
||||
register_svg_icon "fas fa-file-csv"
|
||||
|
|
|
@ -0,0 +1,309 @@
|
|||
import { module, test } from "qunit";
|
||||
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
|
||||
import { render, waitFor } from "@ember/test-helpers";
|
||||
import UpcomingEventsList, {
|
||||
DEFAULT_MONTH_FORMAT,
|
||||
DEFAULT_DATE_FORMAT,
|
||||
DEFAULT_TIME_FORMAT,
|
||||
} from "../../discourse/components/upcoming-events-list";
|
||||
import Service from "@ember/service";
|
||||
import pretender, { response } from "discourse/tests/helpers/create-pretender";
|
||||
import {
|
||||
query,
|
||||
queryAll,
|
||||
fakeTime,
|
||||
} from "discourse/tests/helpers/qunit-helpers";
|
||||
import { hash } from "@ember/helper";
|
||||
|
||||
class RouterStub extends Service {
|
||||
currentRoute = { attributes: { category: { id: 1 } } };
|
||||
}
|
||||
|
||||
const today = "2100-02-01T08:00:00";
|
||||
const tomorrowAllDay = "2100-02-02T00:00:00";
|
||||
const nextMonth = "2100-03-02T08:00:00";
|
||||
|
||||
module("Integration | Component | upcoming-events-list", function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
let clock;
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
this.owner.unregister("service:router");
|
||||
this.owner.register("service:router", RouterStub);
|
||||
|
||||
this.siteSettings.events_calendar_categories = "1";
|
||||
|
||||
this.appEvents = this.container.lookup("service:appEvents");
|
||||
|
||||
clock = fakeTime(today, null, true);
|
||||
});
|
||||
|
||||
hooks.afterEach(() => {
|
||||
clock.restore();
|
||||
});
|
||||
|
||||
test("empty state message", async function (assert) {
|
||||
pretender.get("/discourse-post-event/events", () => {
|
||||
return response({ events: [] });
|
||||
});
|
||||
|
||||
await render(<template><UpcomingEventsList /></template>);
|
||||
|
||||
this.appEvents.trigger("page:changed", {});
|
||||
|
||||
assert.strictEqual(
|
||||
query(".upcoming-events-list__heading").innerText,
|
||||
I18n.t("discourse_post_event.upcoming_events_list.title"),
|
||||
"it displays the title"
|
||||
);
|
||||
|
||||
await waitFor(".loading-container .spinner", { count: 0 });
|
||||
|
||||
assert.strictEqual(
|
||||
query(".upcoming-events-list__empty-message").innerText,
|
||||
I18n.t("discourse_post_event.upcoming_events_list.empty"),
|
||||
"it displays the empty list message"
|
||||
);
|
||||
});
|
||||
|
||||
test("with events, standard formats", async function (assert) {
|
||||
pretender.get("/discourse-post-event/events", twoEventsResponseHandler);
|
||||
|
||||
await render(<template><UpcomingEventsList /></template>);
|
||||
|
||||
this.appEvents.trigger("page:changed", {});
|
||||
|
||||
assert.strictEqual(
|
||||
query(".upcoming-events-list__heading").innerText,
|
||||
I18n.t("discourse_post_event.upcoming_events_list.title"),
|
||||
"it displays the title"
|
||||
);
|
||||
|
||||
await waitFor(".loading-container .spinner", { count: 0 });
|
||||
|
||||
assert.deepEqual(
|
||||
[...queryAll(".upcoming-events-list__formatted-month")].map(
|
||||
(el) => el.innerText
|
||||
),
|
||||
[
|
||||
moment(tomorrowAllDay).format(DEFAULT_MONTH_FORMAT),
|
||||
moment(nextMonth).format(DEFAULT_MONTH_FORMAT),
|
||||
],
|
||||
"it displays the formatted month"
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
[...queryAll(".upcoming-events-list__formatted-day")].map(
|
||||
(el) => el.innerText
|
||||
),
|
||||
[
|
||||
moment(tomorrowAllDay).format(DEFAULT_DATE_FORMAT),
|
||||
moment(nextMonth).format(DEFAULT_DATE_FORMAT),
|
||||
],
|
||||
"it displays the formatted day"
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
[...queryAll(".upcoming-events-list__event-time")].map(
|
||||
(el) => el.innerText
|
||||
),
|
||||
[
|
||||
I18n.t("discourse_post_event.upcoming_events_list.all_day"),
|
||||
moment(nextMonth).format(DEFAULT_TIME_FORMAT),
|
||||
],
|
||||
"it displays the formatted time"
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
[...queryAll(".upcoming-events-list__event-name")].map(
|
||||
(el) => el.innerText
|
||||
),
|
||||
["Awesome Event", "Another Awesome Event"],
|
||||
"it displays the event name"
|
||||
);
|
||||
});
|
||||
|
||||
test("with events, overriden formats", async function (assert) {
|
||||
pretender.get("/discourse-post-event/events", twoEventsResponseHandler);
|
||||
|
||||
await render(<template>
|
||||
<UpcomingEventsList
|
||||
@params={{hash monthFormat="" dateFormat="L" timeFormat="LLL"}}
|
||||
/>
|
||||
</template>);
|
||||
|
||||
this.appEvents.trigger("page:changed", {});
|
||||
|
||||
assert.strictEqual(
|
||||
query(".upcoming-events-list__heading").innerText,
|
||||
I18n.t("discourse_post_event.upcoming_events_list.title"),
|
||||
"it displays the title"
|
||||
);
|
||||
|
||||
await waitFor(".loading-container .spinner", { count: 0 });
|
||||
|
||||
assert.ok(
|
||||
!exists(".upcoming-events-list__formatted-month"),
|
||||
"it omits the formatted month when empty"
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
[...queryAll(".upcoming-events-list__formatted-day")].map(
|
||||
(el) => el.innerText
|
||||
),
|
||||
[moment(tomorrowAllDay).format("L"), moment(nextMonth).format("L")],
|
||||
"it displays the formatted day"
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
[...queryAll(".upcoming-events-list__event-time")].map(
|
||||
(el) => el.innerText
|
||||
),
|
||||
[
|
||||
I18n.t("discourse_post_event.upcoming_events_list.all_day"),
|
||||
moment(nextMonth).format("LLL"),
|
||||
],
|
||||
"it displays the formatted time"
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
[...queryAll(".upcoming-events-list__event-name")].map(
|
||||
(el) => el.innerText
|
||||
),
|
||||
["Awesome Event", "Another Awesome Event"],
|
||||
"it displays the event name"
|
||||
);
|
||||
});
|
||||
|
||||
test("with an error response", async function (assert) {
|
||||
pretender.get("/discourse-post-event/events", () => {
|
||||
return response(500, {});
|
||||
});
|
||||
|
||||
await render(<template><UpcomingEventsList /></template>);
|
||||
|
||||
this.appEvents.trigger("page:changed", {});
|
||||
|
||||
assert.strictEqual(
|
||||
query(".upcoming-events-list__heading").innerText,
|
||||
I18n.t("discourse_post_event.upcoming_events_list.title"),
|
||||
"it displays the title"
|
||||
);
|
||||
|
||||
await waitFor(".loading-container .spinner", { count: 0 });
|
||||
|
||||
assert.strictEqual(
|
||||
query(".upcoming-events-list__error-message").innerText,
|
||||
I18n.t("discourse_post_event.upcoming_events_list.error"),
|
||||
"it displays the error message"
|
||||
);
|
||||
|
||||
assert.strictEqual(
|
||||
query(".upcoming-events-list__try-again").innerText,
|
||||
I18n.t("discourse_post_event.upcoming_events_list.try_again"),
|
||||
"it displays the try again button"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function twoEventsResponseHandler() {
|
||||
return response({
|
||||
events: [
|
||||
{
|
||||
id: 67501,
|
||||
creator: {
|
||||
id: 1500588,
|
||||
username: "foobar",
|
||||
name: null,
|
||||
avatar_template: "/user_avatar/localhost/foobar/{size}/1913_2.png",
|
||||
assign_icon: "user-plus",
|
||||
assign_path: "/u/foobar/activity/assigned",
|
||||
},
|
||||
sample_invitees: [],
|
||||
watching_invitee: null,
|
||||
starts_at: tomorrowAllDay,
|
||||
ends_at: null,
|
||||
timezone: "Asia/Calcutta",
|
||||
stats: {
|
||||
going: 0,
|
||||
interested: 0,
|
||||
not_going: 0,
|
||||
invited: 0,
|
||||
},
|
||||
status: "public",
|
||||
raw_invitees: ["trust_level_0"],
|
||||
post: {
|
||||
id: 67501,
|
||||
post_number: 1,
|
||||
url: "/t/this-is-an-event/18449/1",
|
||||
topic: {
|
||||
id: 18449,
|
||||
title: "This is an event",
|
||||
},
|
||||
},
|
||||
name: "Awesome Event",
|
||||
can_act_on_discourse_post_event: true,
|
||||
can_update_attendance: true,
|
||||
is_expired: false,
|
||||
is_ongoing: true,
|
||||
should_display_invitees: false,
|
||||
url: null,
|
||||
custom_fields: {},
|
||||
is_public: true,
|
||||
is_private: false,
|
||||
is_standalone: false,
|
||||
reminders: [],
|
||||
recurrence: null,
|
||||
category_id: 1,
|
||||
},
|
||||
{
|
||||
id: 67502,
|
||||
creator: {
|
||||
id: 1500588,
|
||||
username: "foobar",
|
||||
name: null,
|
||||
avatar_template: "/user_avatar/localhost/foobar/{size}/1913_2.png",
|
||||
assign_icon: "user-plus",
|
||||
assign_path: "/u/foobar/activity/assigned",
|
||||
},
|
||||
sample_invitees: [],
|
||||
watching_invitee: null,
|
||||
starts_at: nextMonth,
|
||||
ends_at: null,
|
||||
timezone: "Asia/Calcutta",
|
||||
stats: {
|
||||
going: 0,
|
||||
interested: 0,
|
||||
not_going: 0,
|
||||
invited: 0,
|
||||
},
|
||||
status: "public",
|
||||
raw_invitees: ["trust_level_0"],
|
||||
post: {
|
||||
id: 67501,
|
||||
post_number: 1,
|
||||
url: "/t/this-is-an-event-2/18450/1",
|
||||
topic: {
|
||||
id: 18449,
|
||||
title: "This is an event 2",
|
||||
},
|
||||
},
|
||||
name: "Another Awesome Event",
|
||||
can_act_on_discourse_post_event: true,
|
||||
can_update_attendance: true,
|
||||
is_expired: false,
|
||||
is_ongoing: true,
|
||||
should_display_invitees: false,
|
||||
url: null,
|
||||
custom_fields: {},
|
||||
is_public: true,
|
||||
is_private: false,
|
||||
is_standalone: false,
|
||||
reminders: [],
|
||||
recurrence: null,
|
||||
category_id: 2,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
Loading…
Reference in New Issue