FEATURE: upcoming events list component (#463)

* FEATURE: upcoming events list component
This commit is contained in:
Renato Atilio 2023-10-30 15:34:12 -03:00 committed by GitHub
parent b8a8a241c6
commit f57b09ce8b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 532 additions and 0 deletions

View File

@ -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>
}

View File

@ -63,6 +63,7 @@
background: var(--tertiary); background: var(--tertiary);
color: var(--secondary); color: var(--secondary);
} }
margin: 0.3em 0 0.3em 0.5em; margin: 0.3em 0 0.3em 0.5em;
} }
@ -122,6 +123,7 @@
margin-left: 0; margin-left: 0;
} }
} }
.fc-list-item-add-to-calendar { .fc-list-item-add-to-calendar {
color: var(--tertiary); color: var(--tertiary);
font-size: var(--font-down-1); font-size: var(--font-down-1);

View File

@ -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;
}
}

View File

@ -441,6 +441,12 @@ en:
creator: "Creator" creator: "Creator"
status: "Status" status: "Status"
starts_at: "Starts at" 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: category:
sort_topics_by_event_start_date: "Sort topics by event start date." sort_topics_by_event_start_date: "Sort topics by event start date."
disable_topic_resorting: "Disable topic resorting." disable_topic_resorting: "Disable topic resorting."

View File

@ -32,6 +32,7 @@ register_asset "stylesheets/mobile/discourse-post-event.scss", :mobile
register_asset "stylesheets/desktop/discourse-calendar.scss", :desktop register_asset "stylesheets/desktop/discourse-calendar.scss", :desktop
register_asset "stylesheets/colors.scss", :color_definitions register_asset "stylesheets/colors.scss", :color_definitions
register_asset "stylesheets/common/user-preferences.scss" 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-calendar-day"
register_svg_icon "fas fa-clock" register_svg_icon "fas fa-clock"
register_svg_icon "fas fa-file-csv" register_svg_icon "fas fa-file-csv"

View File

@ -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,
},
],
});
}