diff --git a/.streerc b/.streerc index a4c32a2d..c6a32f4b 100644 --- a/.streerc +++ b/.streerc @@ -1,3 +1,3 @@ --print-width=100 --plugins=plugin/trailing_comma,plugin/disable_auto_ternary ---ignore-files=vendor/* \ No newline at end of file +--ignore-files=vendor/* diff --git a/Gemfile.lock b/Gemfile.lock index 3428416a..ac9d85c0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -51,4 +51,4 @@ DEPENDENCIES syntax_tree BUNDLED WITH - 2.1.4 + 2.4.22 diff --git a/app/serializers/discourse_post_event/event_summary_serializer.rb b/app/serializers/discourse_post_event/event_summary_serializer.rb index 4758cd1d..dbf12b5c 100644 --- a/app/serializers/discourse_post_event/event_summary_serializer.rb +++ b/app/serializers/discourse_post_event/event_summary_serializer.rb @@ -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 diff --git a/assets/javascripts/discourse/components/upcoming-events-calendar.js b/assets/javascripts/discourse/components/upcoming-events-calendar.js index 61baec4a..930a83e5 100644 --- a/assets/javascripts/discourse/components/upcoming-events-calendar.js +++ b/assets/javascripts/discourse/components/upcoming-events-calendar.js @@ -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, diff --git a/assets/javascripts/discourse/initializers/discourse-calendar.js b/assets/javascripts/discourse/initializers/discourse-calendar.js index c7555674..5ab0e24b 100644 --- a/assets/javascripts/discourse/initializers/discourse-calendar.js +++ b/assets/javascripts/discourse/initializers/discourse-calendar.js @@ -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(); }); } diff --git a/assets/javascripts/discourse/lib/popover.js b/assets/javascripts/discourse/lib/popover.js new file mode 100644 index 00000000..65a262bd --- /dev/null +++ b/assets/javascripts/discourse/lib/popover.js @@ -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(); +} diff --git a/assets/javascripts/discourse/widgets/discourse-post-event.js b/assets/javascripts/discourse/widgets/discourse-post-event.js index 6f3ac2d1..12fd49bc 100644 --- a/assets/javascripts/discourse/widgets/discourse-post-event.js +++ b/assets/javascripts/discourse/widgets/discourse-post-event.js @@ -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(); }); }, diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 1fa84997..3ebc5d19 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -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." diff --git a/config/settings.yml b/config/settings.yml index 65c2821d..1e483d06 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -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 diff --git a/lib/discourse_calendar/site_settings/map_event_tag_colors_json_schema.rb b/lib/discourse_calendar/site_settings/map_event_tag_colors_json_schema.rb new file mode 100644 index 00000000..2aa75a93 --- /dev/null +++ b/lib/discourse_calendar/site_settings/map_event_tag_colors_json_schema.rb @@ -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 diff --git a/plugin.rb b/plugin.rb index 65327c08..3da8302e 100644 --- a/plugin.rb +++ b/plugin.rb @@ -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) diff --git a/spec/serializers/discourse_post_event/event_summary_serializer_spec.rb b/spec/serializers/discourse_post_event/event_summary_serializer_spec.rb index 1d509ed3..b581c2cf 100644 --- a/spec/serializers/discourse_post_event/event_summary_serializer_spec.rb +++ b/spec/serializers/discourse_post_event/event_summary_serializer_spec.rb @@ -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 diff --git a/test/javascripts/acceptance/category-events-calendar-outlet-test.js b/test/javascripts/acceptance/category-events-calendar-outlet-test.js index d6d9e89e..eae3fd78 100644 --- a/test/javascripts/acceptance/category-events-calendar-outlet-test.js +++ b/test/javascripts/acceptance/category-events-calendar-outlet-test.js @@ -18,6 +18,7 @@ const eventsPretender = (server, helper) => { topic: { id: 18449, title: "This is an event", + tags: ["awesome-event"], }, }, name: "Awesome Event", diff --git a/test/javascripts/acceptance/category-events-calendar-test.js b/test/javascripts/acceptance/category-events-calendar-test.js index 0d8d591f..80ca9b38 100644 --- a/test/javascripts/acceptance/category-events-calendar-test.js +++ b/test/javascripts/acceptance/category-events-calendar-test.js @@ -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");