FEATURE: customizable event colors by tags and adjustable max rows in calendar (#482)

* FEATURE: customizable event colors by tags and adjustable max rows
This commit is contained in:
Jean 2023-12-01 10:10:34 -04:00 committed by GitHub
parent 07ff8f4f1b
commit d27e52e340
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 339 additions and 59 deletions

View File

@ -1,3 +1,3 @@
--print-width=100
--plugins=plugin/trailing_comma,plugin/disable_auto_ternary
--ignore-files=vendor/*
--ignore-files=vendor/*

View File

@ -51,4 +51,4 @@ DEPENDENCIES
syntax_tree
BUNDLED WITH
2.1.4
2.4.22

View File

@ -14,7 +14,7 @@ module DiscoursePostEvent
# lightweight post object containing
# only needed info for client
def post
{
post_hash = {
id: object.post.id,
post_number: object.post.post_number,
url: object.post.url,
@ -23,6 +23,13 @@ module DiscoursePostEvent
title: object.post.topic.title,
},
}
if JSON.parse(SiteSetting.map_events_to_color).size > 0
post_hash[:topic][:category_slug] = object.post.topic&.category&.slug
post_hash[:topic][:tags] = object.post.topic.tags&.map(&:name)
end
post_hash
end
def category_id

View File

@ -5,6 +5,7 @@ import loadScript from "discourse/lib/load-script";
import getURL from "discourse-common/lib/get-url";
import { formatEventName } from "../helpers/format-event-name";
import { isNotFullDayEvent } from "../lib/guess-best-date-format";
import { buildPopover, destroyPopover } from "../lib/popover";
export default Component.extend({
tagName: "",
@ -46,6 +47,8 @@ export default Component.extend({
},
_renderCalendar() {
const siteSettings = this.site.siteSettings;
const calendarNode = document.getElementById("upcoming-events-calendar");
if (!calendarNode) {
return;
@ -54,7 +57,47 @@ export default Component.extend({
calendarNode.innerHTML = "";
this._loadCalendar().then(() => {
this._calendar = new window.FullCalendar.Calendar(calendarNode, {});
const fullCalendar = new window.FullCalendar.Calendar(calendarNode, {
eventClick: function () {
destroyPopover();
},
eventPositioned: (info) => {
if (siteSettings.events_max_rows === 0) {
return;
}
let fcContent = info.el.querySelector(".fc-content");
let computedStyle = window.getComputedStyle(fcContent);
let lineHeight = parseInt(computedStyle.lineHeight, 10);
if (lineHeight === 0) {
lineHeight = 20;
}
let maxHeight = lineHeight * siteSettings.events_max_rows;
if (fcContent) {
fcContent.style.maxHeight = `${maxHeight}px`;
}
let fcTitle = info.el.querySelector(".fc-title");
if (fcTitle) {
fcTitle.style.overflow = "hidden";
fcTitle.style.whiteSpace = "pre-wrap";
}
fullCalendar.updateSize();
},
eventMouseEnter: function ({ event, jsEvent }) {
destroyPopover();
const htmlContent = event.title;
buildPopover(jsEvent, htmlContent);
},
eventMouseLeave: function () {
destroyPopover();
},
});
this._calendar = fullCalendar;
const tagsColorsMap = JSON.parse(siteSettings.map_events_to_color);
const originalEventAndRecurrents = this.addRecurrentEvents(
this.events.content
@ -62,8 +105,28 @@ export default Component.extend({
(originalEventAndRecurrents || []).forEach((event) => {
const { starts_at, ends_at, post, category_id } = event;
const categoryColor = this.site.categoriesById[category_id]?.color;
const backgroundColor = categoryColor ? `#${categoryColor}` : undefined;
let backgroundColor;
if (post.topic.tags) {
const tagColorEntry = tagsColorsMap.find(
(entry) =>
entry.type === "tag" && post.topic.tags.includes(entry.slug)
);
backgroundColor = tagColorEntry ? tagColorEntry.color : null;
}
if (!backgroundColor) {
const categoryColorFromMap = tagsColorsMap.find(
(entry) =>
entry.type === "category" &&
entry.slug === post.topic.category_slug
)?.color;
backgroundColor =
categoryColorFromMap ||
`#${this.site.categoriesById[category_id]?.color}`;
}
this._calendar.addEvent({
title: formatEventName(event),
start: starts_at,

View File

@ -1,5 +1,4 @@
import { isPresent } from "@ember/utils";
import { createPopper } from "@popperjs/core";
import $ from "jquery";
import { Promise } from "rsvp";
import { ajax } from "discourse/lib/ajax";
@ -15,6 +14,7 @@ import I18n from "I18n";
import { formatEventName } from "../helpers/format-event-name";
import { colorToHex, contrastColor, stringToColor } from "../lib/colors";
import { isNotFullDayEvent } from "../lib/guess-best-date-format";
import { buildPopover, destroyPopover } from "../lib/popover";
function loadFullCalendar() {
return loadScript(
@ -36,9 +36,6 @@ function getCalendarButtonsText() {
};
}
let eventPopper;
const EVENT_POPOVER_ID = "event-popover";
function initializeDiscourseCalendar(api) {
const siteSettings = api.container.lookup("service:site-settings");
@ -140,8 +137,44 @@ function initializeDiscourseCalendar(api) {
let fullCalendar = new window.FullCalendar.Calendar(
categoryEventNode,
{
eventClick: function () {
destroyPopover();
},
locale: getCurrentBcp47Locale(),
buttonText: getCalendarButtonsText(),
eventPositioned: (info) => {
if (siteSettings.events_max_rows === 0) {
return;
}
let fcContent = info.el.querySelector(".fc-content");
let computedStyle = window.getComputedStyle(fcContent);
let lineHeight = parseInt(computedStyle.lineHeight, 10);
if (lineHeight === 0) {
lineHeight = 20;
}
let maxHeight = lineHeight * siteSettings.events_max_rows;
if (fcContent) {
fcContent.style.maxHeight = `${maxHeight}px`;
}
let fcTitle = info.el.querySelector(".fc-title");
if (fcTitle) {
fcTitle.style.overflow = "hidden";
fcTitle.style.whiteSpace = "pre-wrap";
}
fullCalendar.updateSize();
},
eventMouseEnter: function ({ event, jsEvent }) {
destroyPopover();
const htmlContent = event.title;
buildPopover(jsEvent, htmlContent);
},
eventMouseLeave: function () {
destroyPopover();
},
}
);
const loadEvents = ajax(
@ -151,9 +184,32 @@ function initializeDiscourseCalendar(api) {
Promise.all([loadEvents]).then((results) => {
const events = results[0];
const tagsColorsMap = JSON.parse(siteSettings.map_events_to_color);
events[Object.keys(events)[0]].forEach((event) => {
const { starts_at, ends_at, post, category_id } = event;
const backgroundColor = `#${site.categoriesById[category_id]?.color}`;
let backgroundColor;
if (post.topic.tags) {
const tagColorEntry = tagsColorsMap.find(
(entry) =>
entry.type === "tag" && post.topic.tags.includes(entry.slug)
);
backgroundColor = tagColorEntry ? tagColorEntry.color : null;
}
if (!backgroundColor) {
const categoryColorFromMap = tagsColorsMap.find(
(entry) =>
entry.type === "category" &&
entry.slug === post.topic.category_slug
)?.color;
backgroundColor =
categoryColorFromMap ||
`#${site.categoriesById[category_id]?.color}`;
}
fullCalendar.addEvent({
title: formatEventName(event),
start: starts_at,
@ -438,41 +494,6 @@ function initializeDiscourseCalendar(api) {
});
}
function _buildPopover(jsEvent, htmlContent) {
const node = document.createElement("div");
node.setAttribute("id", EVENT_POPOVER_ID);
node.innerHTML = htmlContent;
const arrow = document.createElement("span");
arrow.dataset.popperArrow = true;
node.appendChild(arrow);
document.body.appendChild(node);
eventPopper = createPopper(
jsEvent.target,
document.getElementById(EVENT_POPOVER_ID),
{
placement: "bottom",
modifiers: [
{
name: "arrow",
},
{
name: "offset",
options: {
offset: [20, 10],
},
},
],
}
);
}
function _destroyPopover() {
eventPopper?.destroy();
document.getElementById(EVENT_POPOVER_ID)?.remove();
}
function _setDynamicCalendarOptions(calendar, $calendar) {
const skipWeekends = $calendar.attr("data-weekends") === "false";
const hiddenDays = $calendar.attr("data-hidden-days");
@ -489,7 +510,7 @@ function initializeDiscourseCalendar(api) {
}
calendar.setOption("eventClick", ({ event, jsEvent }) => {
_destroyPopover();
destroyPopover();
const { htmlContent, postNumber, postUrl } = event.extendedProps;
if (postUrl) {
@ -499,18 +520,18 @@ function initializeDiscourseCalendar(api) {
_topicController || api.container.lookup("controller:topic");
_topicController.send("jumpToPost", postNumber);
} else if (isMobileView && htmlContent) {
_buildPopover(jsEvent, htmlContent);
buildPopover(jsEvent, htmlContent);
}
});
calendar.setOption("eventMouseEnter", ({ event, jsEvent }) => {
_destroyPopover();
destroyPopover();
const { htmlContent } = event.extendedProps;
_buildPopover(jsEvent, htmlContent);
buildPopover(jsEvent, htmlContent);
});
calendar.setOption("eventMouseLeave", () => {
_destroyPopover();
destroyPopover();
});
}

View File

@ -0,0 +1,39 @@
import { createPopper } from "@popperjs/core";
let eventPopper;
const EVENT_POPOVER_ID = "event-popover";
export function buildPopover(jsEvent, htmlContent) {
const node = document.createElement("div");
node.setAttribute("id", EVENT_POPOVER_ID);
node.innerHTML = htmlContent;
const arrow = document.createElement("span");
arrow.dataset.popperArrow = true;
node.appendChild(arrow);
document.body.appendChild(node);
eventPopper = createPopper(
jsEvent.target,
document.getElementById(EVENT_POPOVER_ID),
{
placement: "bottom",
modifiers: [
{
name: "arrow",
},
{
name: "offset",
options: {
offset: [20, 10],
},
},
],
}
);
}
export function destroyPopover() {
eventPopper?.destroy();
document.getElementById(EVENT_POPOVER_ID)?.remove();
}

View File

@ -119,10 +119,19 @@ export default createWidget("discourse-post-event", {
this.state.eventModel.watching_invitee.id,
{ status: newStatus, post_id: this.state.eventModel.id }
);
this.appEvents.trigger("calendar:update-invitee-status", {
status: newStatus,
postId: this.state.eventModel.id,
});
} else {
this.store
.createRecord("discourse-post-event-invitee")
.save({ post_id: this.state.eventModel.id, status });
this.appEvents.trigger("calendar:create-invitee-status", {
status,
postId: this.state.eventModel.id,
});
}
},
@ -180,12 +189,14 @@ export default createWidget("discourse-post-event", {
post_id: postId,
})
.then((invitees) => {
invitees
.find(
(invitee) =>
invitee.id === this.state.eventModel.watching_invitee.id
)
.destroyRecord();
let invitee = invitees.find(
(inv) => inv.id === this.state.eventModel.watching_invitee.id
);
this.appEvents.trigger("calendar:invitee-left-event", {
invitee,
postId,
});
invitee.destroyRecord();
});
},

View File

@ -29,6 +29,8 @@ en:
```
site_settings:
events_max_rows: "Maximum text rows per event in the Calendar."
map_events_to_color: "Assign a color to each tag or category."
calendar_enabled: "Enable the discourse-calendar plugin. This will add support for a [calendar][/calendar] tag in the first post of a topic."
discourse_post_event_enabled: "Enables the Event features. Note: also needs `calendar enabled` to be enabled."
displayed_invitees_limit: "Limits the numbers of invitees displayed on an event."

View File

@ -105,3 +105,10 @@ discourse_post_event:
sidebar_show_upcoming_events:
default: true
client: true
events_max_rows:
default: 2
client: true
map_events_to_color:
client: true
default: "[]"
json_schema: DiscourseCalendar::SiteSettings::MapEventTagColorsJsonSchema

View File

@ -0,0 +1,35 @@
# frozen_string_literal: true
module DiscourseCalendar
module SiteSettings
class MapEventTagColorsJsonSchema
def self.schema
@schema ||= {
type: "array",
uniqueItems: true,
items: {
type: "object",
title: "Color Mapping",
properties: {
type: {
type: "string",
description: "Type of mapping (tag or category)",
enum: %w[tag category],
},
slug: {
type: "string",
description: "Slug of the tag or category",
},
color: {
type: "string",
description: "Color associated with the tag or category",
pattern: "^#(?:[0-9a-fA-F]{3}){1,2}$",
},
},
required: %w[slug type color],
},
}
end
end
end
end

View File

@ -77,6 +77,10 @@ end
require_relative "lib/discourse_calendar/engine"
Dir
.glob(File.expand_path("../lib/discourse_calendar/site_settings/*.rb", __FILE__))
.each { |f| require(f) }
after_initialize do
reloadable_patch do
Category.register_custom_field_type("sort_topics_by_event_start_date", :boolean)

View File

@ -148,4 +148,36 @@ describe DiscoursePostEvent::EventSummarySerializer do
)
end
end
describe "map_events_to_color" do
context "when map_events_to_color is empty" do
let(:json) do
DiscoursePostEvent::EventSummarySerializer.new(event, scope: Guardian.new).as_json
end
it "returns the event summary with category_slug and tags" do
summary = json[:event_summary]
expect(summary[:post][:topic][:category_slug]).to be_nil
expect(summary[:post][:topic][:tags]).to be_nil
end
end
context "when map_events_to_color is set" do
let(:json) do
DiscoursePostEvent::EventSummarySerializer.new(event, scope: Guardian.new).as_json
end
before do
SiteSetting.map_events_to_color = [
{ type: "tag", color: "#21d939", slug: "nice-tag" },
].to_json
end
it "returns the event summary with category_slug and tags" do
summary = json[:event_summary]
expect(summary[:post][:topic][:category_slug]).to eq(category.slug)
expect(summary[:post][:topic][:tags]).to eq(topic.tags.map(&:name))
end
end
end
end

View File

@ -18,6 +18,7 @@ const eventsPretender = (server, helper) => {
topic: {
id: 18449,
title: "This is an event",
tags: ["awesome-event"],
},
},
name: "Awesome Event",

View File

@ -10,6 +10,18 @@ acceptance("Discourse Calendar - Category Events Calendar", function (needs) {
discourse_post_event_enabled: true,
events_calendar_categories: "1",
calendar_categories: "",
map_events_to_color: JSON.stringify([
{
type: "tag",
color: "rgb(231, 76, 60)",
slug: "awesome-tag",
},
{
type: "category",
color: "rgb(140,24,193)",
slug: "awesome-category",
},
]),
});
needs.pretender((server, helper) => {
@ -18,8 +30,14 @@ acceptance("Discourse Calendar - Category Events Calendar", function (needs) {
events: [
{
id: 67501,
starts_at: "2022-04-25T15:14:00.000Z",
ends_at: "2022-04-30T16:14:00.000Z",
starts_at: moment()
.tz("Asia/Calcutta")
.add(1, "days")
.format("YYYY-MM-DDT15:14:00.000Z"),
ends_at: moment()
.tz("Asia/Calcutta")
.add(1, "days")
.format("YYYY-MM-DDT16:14:00.000Z"),
timezone: "Asia/Calcutta",
post: {
id: 67501,
@ -28,15 +46,55 @@ acceptance("Discourse Calendar - Category Events Calendar", function (needs) {
topic: {
id: 18449,
title: "This is an event",
tags: ["awesome-tag"],
},
},
name: "Awesome Event",
},
{
id: 67502,
starts_at: moment()
.tz("Asia/Calcutta")
.add(2, "days")
.format("YYYY-MM-DDT15:14:00.000Z"),
ends_at: moment()
.tz("Asia/Calcutta")
.add(2, "days")
.format("YYYY-MM-DDT16:14:00.000Z"),
timezone: "Asia/Calcutta",
post: {
id: 67502,
post_number: 1,
url: "/t/this-is-an-event/18450/1",
topic: {
id: 18450,
title: "This is an event",
category_slug: "awesome-category",
},
},
name: "Awesome Event 2",
},
],
});
});
});
test("events display the color configured in the map_events_to_color site setting", async (assert) => {
await visit("/c/bug/1");
assert
.dom(".fc-event")
.exists({ count: 2 }, "One event is displayed on the calendar");
assert.dom(".fc-event[href='/t/-/18449/1']").hasStyle({
"background-color": "rgb(231, 76, 60)",
});
assert.dom(".fc-event[href='/t/-/18450/1']").hasStyle({
"background-color": "rgb(140, 24, 193)",
});
});
test("shows event calendar on category page", async (assert) => {
await visit("/c/bug/1");