mirror of
https://github.com/discourse/discourse.git
synced 2025-10-03 17:21:20 +08:00
DEV: full calendar v6 (#33737)
This commit makes the following changes: - uses fullcalendar 6 - replaces the server generated upcoming dates for recurring events with a frontend implementation of the RRULE standard (using the fullcalendar RRULE plugin) - displays a preview of the event on click on the upcoming events calendar --------- Co-authored-by: chapoi <101828855+chapoi@users.noreply.github.com> Co-authored-by: Martin Brennan <martin@discourse.org>
This commit is contained in:
parent
59317b2530
commit
361029ad4d
81 changed files with 2514 additions and 2571 deletions
|
@ -30,7 +30,7 @@ export default class DownloadCalendar extends Component {
|
|||
this.args.model.calendar.title,
|
||||
this.args.model.calendar.dates,
|
||||
{
|
||||
recurrenceRule: this.args.model.calendar.recurrenceRule,
|
||||
rrule: this.args.model.calendar.rrule,
|
||||
location: this.args.model.calendar.location,
|
||||
details: this.args.model.calendar.details,
|
||||
}
|
||||
|
@ -40,7 +40,7 @@ export default class DownloadCalendar extends Component {
|
|||
this.args.model.calendar.title,
|
||||
this.args.model.calendar.dates,
|
||||
{
|
||||
recurrenceRule: this.args.model.calendar.recurrenceRule,
|
||||
rrule: this.args.model.calendar.rrule,
|
||||
location: this.args.model.calendar.location,
|
||||
details: this.args.model.calendar.details,
|
||||
}
|
||||
|
|
|
@ -49,8 +49,8 @@ export function downloadGoogle(title, dates, options = {}) {
|
|||
)}`
|
||||
);
|
||||
|
||||
if (options.recurrenceRule) {
|
||||
link.searchParams.append("recur", `RRULE:${options.recurrenceRule}`);
|
||||
if (options.rrule) {
|
||||
link.searchParams.append("recur", `RRULE:${options.rrule}`);
|
||||
}
|
||||
|
||||
if (options.location) {
|
||||
|
@ -88,7 +88,7 @@ export function generateIcsData(title, dates, options = {}) {
|
|||
`DTSTAMP:${moment().utc().format("YMMDDTHHmmss")}Z\n` +
|
||||
`DTSTART:${startDate.utc().format("YMMDDTHHmmss")}Z\n` +
|
||||
`DTEND:${endDate.utc().format("YMMDDTHHmmss")}Z\n` +
|
||||
(options.recurrenceRule ? `RRULE:${options.recurrenceRule}\n` : ``) +
|
||||
(options.rrule ? `RRULE:${options.rrule}\n` : ``) +
|
||||
(options.location ? `LOCATION:${options.location}\n` : ``) +
|
||||
(options.details ? `DESCRIPTION:${options.details}\n` : ``) +
|
||||
`SUMMARY:${title}\n` +
|
||||
|
@ -106,7 +106,7 @@ function _displayModal(title, dates, options = {}) {
|
|||
calendar: {
|
||||
title,
|
||||
dates,
|
||||
recurrenceRule: options.recurrenceRule,
|
||||
rrule: options.rrule,
|
||||
location: options.location,
|
||||
details: options.details,
|
||||
},
|
||||
|
|
|
@ -2457,7 +2457,7 @@ class PluginApi {
|
|||
* endsAt: "2021-10-12T16:00:00.000Z",
|
||||
* },
|
||||
* ],
|
||||
* { recurrenceRule: "FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR", location: "Paris", details: "Foo" }
|
||||
* { rrule: "FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR", location: "Paris", details: "Foo" }
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
|
|
|
@ -2,3 +2,5 @@ export { Calendar } from "@fullcalendar/core";
|
|||
export { default as DayGrid } from "@fullcalendar/daygrid";
|
||||
export { default as TimeGrid } from "@fullcalendar/timegrid";
|
||||
export { default as List } from "@fullcalendar/list";
|
||||
export { default as RRULE } from "@fullcalendar/rrule";
|
||||
export { default as MomentTimezone } from "@fullcalendar/moment-timezone";
|
||||
|
|
|
@ -23,6 +23,8 @@
|
|||
"@fullcalendar/core": "^6.1.18",
|
||||
"@fullcalendar/daygrid": "^6.1.18",
|
||||
"@fullcalendar/list": "^6.1.18",
|
||||
"@fullcalendar/moment-timezone": "^6.1.19",
|
||||
"@fullcalendar/rrule": "^6.1.18",
|
||||
"@fullcalendar/timegrid": "^6.1.18",
|
||||
"@glimmer/syntax": "0.93.1",
|
||||
"@highlightjs/cdn-assets": "11.11.1",
|
||||
|
@ -58,7 +60,8 @@
|
|||
"prosemirror-schema-list": "^1.5.1",
|
||||
"prosemirror-state": "^1.4.3",
|
||||
"prosemirror-transform": "^1.10.4",
|
||||
"prosemirror-view": "^1.40.0"
|
||||
"prosemirror-view": "^1.40.0",
|
||||
"rrule": "^2.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.28.3",
|
||||
|
|
|
@ -33,7 +33,7 @@ module("Unit | Utility | download-calendar", function (hooks) {
|
|||
},
|
||||
],
|
||||
{
|
||||
recurrenceRule: "FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR",
|
||||
rrule: "FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR",
|
||||
location: "Paris",
|
||||
details: "Good soup",
|
||||
}
|
||||
|
@ -74,7 +74,7 @@ END:VCALENDAR`
|
|||
endsAt: "2021-10-12T16:00:00.000Z",
|
||||
},
|
||||
],
|
||||
{ recurrenceRule: "FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR" }
|
||||
{ rrule: "FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR" }
|
||||
);
|
||||
assert.strictEqual(
|
||||
data,
|
||||
|
@ -121,7 +121,7 @@ END:VCALENDAR`
|
|||
endsAt: "2021-10-12T16:00:00.000Z",
|
||||
},
|
||||
],
|
||||
{ recurrenceRule: "FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR" }
|
||||
{ rrule: "FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR" }
|
||||
);
|
||||
assert.true(
|
||||
window.open.calledWith(
|
||||
|
|
|
@ -69,7 +69,7 @@
|
|||
|
||||
&[data-placement^="top"] {
|
||||
.arrow {
|
||||
bottom: -10px;
|
||||
bottom: -11px;
|
||||
rotate: 180deg;
|
||||
}
|
||||
}
|
||||
|
@ -82,21 +82,21 @@
|
|||
|
||||
&[data-placement^="bottom"] {
|
||||
.arrow {
|
||||
top: -10px;
|
||||
top: -11px;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-placement^="right"] {
|
||||
.arrow {
|
||||
rotate: -90deg;
|
||||
left: -10px;
|
||||
left: -11px;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-placement^="left"] {
|
||||
.arrow {
|
||||
rotate: 90deg;
|
||||
right: -10px;
|
||||
right: -11px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -109,7 +109,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
## [1.24.0] - 2024-01-08
|
||||
|
||||
- Added `addAdminSidebarSectionLink` which is used to add a link to a specific admin sidebar section, as a replacement for the `admin-menu` plugin outlet.
|
||||
- Added `addAdminSidebarSectionLink` which is used to add a link to a specific admin sidebar section, as a replacement for the `admin-menu` plugin outlet.
|
||||
|
||||
## [1.23.0] - 2024-01-03
|
||||
|
||||
|
@ -154,7 +154,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
### Added
|
||||
|
||||
- Added `recurrenceRule` option to `downloadCalendar`, this can be used to set recurring events in the calendar. Rule syntax can be found at https://datatracker.ietf.org/doc/html/rfc5545#section-3.3.10.
|
||||
- Added `rrule` option to `downloadCalendar`, this can be used to set recurring events in the calendar. Rule syntax can be found at https://datatracker.ietf.org/doc/html/rfc5545#section-3.3.10.
|
||||
|
||||
## [1.15.0] - 2023-10-18
|
||||
|
||||
|
|
|
@ -9,12 +9,12 @@ module DiscoursePostEvent
|
|||
)
|
||||
|
||||
# The detailed serializer is currently not used anywhere in the frontend, but available via API
|
||||
serializer = params[:include_details] == "true" ? EventSerializer : EventSummarySerializer
|
||||
serializer = params[:include_details] == "true" ? EventSerializer : BasicEventSerializer
|
||||
|
||||
render json:
|
||||
ActiveModel::ArraySerializer.new(
|
||||
@events,
|
||||
each_serializer: serializer,
|
||||
each_serializer: BasicEventSerializer,
|
||||
scope: guardian,
|
||||
).as_json
|
||||
end
|
||||
|
@ -124,6 +124,8 @@ module DiscoursePostEvent
|
|||
:limit,
|
||||
:before,
|
||||
:attending_user,
|
||||
:before,
|
||||
:after,
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -59,40 +59,19 @@ module DiscoursePostEvent
|
|||
|
||||
return if !starts_at_changed && !ends_at_changed
|
||||
|
||||
event_dates.update_all(finished_at: Time.current)
|
||||
set_next_date
|
||||
end
|
||||
|
||||
def set_next_date
|
||||
next_dates = calculate_next_date
|
||||
return if !next_dates
|
||||
next_date_result = calculate_next_date
|
||||
|
||||
date_args = { starts_at: next_dates[:starts_at], ends_at: next_dates[:ends_at] }
|
||||
if next_dates[:ends_at] && next_dates[:ends_at] < Time.current
|
||||
date_args[:finished_at] = next_dates[:ends_at]
|
||||
end
|
||||
|
||||
existing_date = event_dates.find_by(starts_at: date_args[:starts_at])
|
||||
|
||||
if existing_date && existing_date.ends_at == date_args[:ends_at] &&
|
||||
existing_date.finished_at == date_args[:finished_at]
|
||||
# exact same state in DB, this is a dupe call, return early
|
||||
return
|
||||
end
|
||||
|
||||
if existing_date
|
||||
existing_date.update!(date_args)
|
||||
else
|
||||
event_dates.create!(date_args)
|
||||
end
|
||||
|
||||
invitees.where.not(status: Invitee.statuses[:going]).update_all(status: nil, notified: false)
|
||||
|
||||
if !next_dates[:rescheduled]
|
||||
notify_invitees!
|
||||
notify_missing_invitees!
|
||||
end
|
||||
return event_dates.update_all(finished_at: Time.current) if next_date_result.nil?
|
||||
|
||||
starts_at, ends_at = next_date_result
|
||||
finish_previous_event_dates(starts_at) if dates_changed?
|
||||
upsert_event_date(starts_at, ends_at)
|
||||
reset_invitee_notifications
|
||||
notify_if_new_event
|
||||
publish_update!
|
||||
end
|
||||
|
||||
|
@ -400,27 +379,71 @@ module DiscoursePostEvent
|
|||
end
|
||||
|
||||
def calculate_next_date
|
||||
if self.recurrence.blank? || original_starts_at > Time.current
|
||||
return { starts_at: original_starts_at, ends_at: original_ends_at, rescheduled: false }
|
||||
if recurrence.blank? || original_starts_at > Time.current
|
||||
return original_starts_at, original_ends_at
|
||||
end
|
||||
|
||||
next_starts_at =
|
||||
RRuleGenerator.generate(
|
||||
starts_at: original_starts_at.in_time_zone(timezone),
|
||||
timezone:,
|
||||
recurrence:,
|
||||
recurrence_until:,
|
||||
).first
|
||||
return unless next_starts_at
|
||||
next_starts_at = calculate_next_recurring_date
|
||||
return nil unless next_starts_at
|
||||
|
||||
if original_ends_at
|
||||
difference = original_ends_at - original_starts_at
|
||||
next_ends_at = next_starts_at + difference.seconds
|
||||
next_ends_at = original_ends_at ? next_starts_at + event_duration : nil
|
||||
[next_starts_at, next_ends_at]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def dates_changed?
|
||||
saved_change_to_original_starts_at || saved_change_to_original_ends_at
|
||||
end
|
||||
|
||||
def finish_previous_event_dates(current_starts_at)
|
||||
existing_date = event_dates.find_by(starts_at: current_starts_at)
|
||||
event_dates
|
||||
.where.not(id: existing_date&.id)
|
||||
.where(finished_at: nil)
|
||||
.update_all(finished_at: Time.current)
|
||||
end
|
||||
|
||||
def upsert_event_date(starts_at, ends_at)
|
||||
finished_at = ends_at && ends_at < Time.current ? ends_at : nil
|
||||
|
||||
existing_date = event_dates.find_by(starts_at:)
|
||||
|
||||
if existing_date
|
||||
# Only update if something actually changed
|
||||
unless existing_date.ends_at == ends_at && existing_date.finished_at == finished_at
|
||||
existing_date.update!(ends_at:, finished_at:)
|
||||
end
|
||||
else
|
||||
next_ends_at = nil
|
||||
event_dates.create!(starts_at:, ends_at:, finished_at:)
|
||||
end
|
||||
end
|
||||
|
||||
{ starts_at: next_starts_at, ends_at: next_ends_at, rescheduled: true }
|
||||
def reset_invitee_notifications
|
||||
invitees.where.not(status: Invitee.statuses[:going]).update_all(status: nil, notified: false)
|
||||
end
|
||||
|
||||
def notify_if_new_event
|
||||
is_generating_future_recurrence = recurrence.present? && original_starts_at <= Time.current
|
||||
|
||||
unless is_generating_future_recurrence
|
||||
notify_invitees!
|
||||
notify_missing_invitees!
|
||||
end
|
||||
end
|
||||
|
||||
def calculate_next_recurring_date
|
||||
RRuleGenerator.generate(
|
||||
starts_at: original_starts_at.in_time_zone(timezone),
|
||||
timezone: timezone,
|
||||
recurrence: recurrence,
|
||||
recurrence_until: recurrence_until,
|
||||
dtstart: original_starts_at.in_time_zone(timezone),
|
||||
).first
|
||||
end
|
||||
|
||||
def event_duration
|
||||
original_ends_at - original_starts_at
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,81 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module DiscoursePostEvent
|
||||
class BasicEventSerializer < ApplicationSerializer
|
||||
attributes :id,
|
||||
:category_id,
|
||||
:name,
|
||||
:recurrence,
|
||||
:recurrence_until,
|
||||
:starts_at,
|
||||
:ends_at,
|
||||
:rrule,
|
||||
:show_local_time,
|
||||
:timezone,
|
||||
:post
|
||||
|
||||
def category_id
|
||||
object.post.topic.category_id
|
||||
end
|
||||
|
||||
def post
|
||||
{
|
||||
id: object.post.id,
|
||||
post_number: object.post.post_number,
|
||||
url: object.post.url,
|
||||
topic: {
|
||||
id: object.post.topic.id,
|
||||
title: object.post.topic.title,
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
def include_rrule?
|
||||
object.recurring?
|
||||
end
|
||||
|
||||
def rrule
|
||||
return nil unless include_rrule?
|
||||
|
||||
# Use UTC for RRULE to avoid timezone compatibility issues with FullCalendar
|
||||
RRuleGenerator.generate_string(
|
||||
starts_at: object.original_starts_at,
|
||||
timezone: "UTC",
|
||||
recurrence: object.recurrence,
|
||||
recurrence_until: object.recurrence_until,
|
||||
dtstart: object.original_starts_at,
|
||||
)
|
||||
end
|
||||
|
||||
def starts_at
|
||||
# For recurring events, use UTC to match RRULE
|
||||
# For non-recurring events, use the event's timezone
|
||||
if object.recurring?
|
||||
object.original_starts_at
|
||||
else
|
||||
object.starts_at.in_time_zone(object.timezone)
|
||||
end
|
||||
end
|
||||
|
||||
def ends_at
|
||||
if object.ends_at
|
||||
if object.recurring?
|
||||
object.ends_at
|
||||
else
|
||||
object.ends_at.in_time_zone(object.timezone)
|
||||
end
|
||||
else
|
||||
# Use consistent timezone as starts_at for calculation
|
||||
base_starts_at =
|
||||
(
|
||||
if object.recurring?
|
||||
object.original_starts_at
|
||||
else
|
||||
object.starts_at.in_time_zone(object.timezone)
|
||||
end
|
||||
)
|
||||
(base_starts_at + 1.hour)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -20,7 +20,6 @@ module DiscoursePostEvent
|
|||
attributes :post
|
||||
attributes :raw_invitees
|
||||
attributes :recurrence
|
||||
attributes :recurrence_rule
|
||||
attributes :recurrence_until
|
||||
attributes :reminders
|
||||
attributes :sample_invitees
|
||||
|
@ -36,6 +35,7 @@ module DiscoursePostEvent
|
|||
attributes :watching_invitee
|
||||
attributes :chat_enabled
|
||||
attributes :channel
|
||||
attributes :rrule
|
||||
|
||||
def channel
|
||||
::Chat::ChannelSerializer.new(object.chat_channel, root: false, scope:)
|
||||
|
@ -141,16 +141,22 @@ module DiscoursePostEvent
|
|||
object.url.present?
|
||||
end
|
||||
|
||||
def include_recurrence_rule?
|
||||
def include_rrule?
|
||||
object.recurring?
|
||||
end
|
||||
|
||||
def recurrence_rule
|
||||
RRuleConfigurator.rule(
|
||||
def rrule
|
||||
RRuleGenerator.generate_string(
|
||||
starts_at: object.original_starts_at.in_time_zone(object.timezone),
|
||||
timezone: object.timezone,
|
||||
recurrence: object.recurrence,
|
||||
starts_at: object.starts_at.in_time_zone(object.timezone),
|
||||
recurrence_until: object.recurrence_until&.in_time_zone(object.timezone),
|
||||
dtstart: object.original_starts_at.in_time_zone(object.timezone),
|
||||
)
|
||||
end
|
||||
|
||||
def ends_at
|
||||
object.ends_at || object.starts_at + 1.hour
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,63 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module DiscoursePostEvent
|
||||
class EventSummarySerializer < ApplicationSerializer
|
||||
attributes :id
|
||||
attributes :starts_at
|
||||
attributes :ends_at
|
||||
attributes :show_local_time
|
||||
attributes :timezone
|
||||
attributes :post
|
||||
attributes :name
|
||||
attributes :category_id
|
||||
attributes :upcoming_dates
|
||||
|
||||
# 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,
|
||||
topic: {
|
||||
id: object.post.topic.id,
|
||||
title: object.post.topic.title,
|
||||
},
|
||||
}
|
||||
|
||||
if post_hash[:topic][:title].match?(/:[\w\-+]+:/)
|
||||
post_hash[:topic][:title] = Emoji.gsub_emoji_to_unicode(post_hash[:topic][:title])
|
||||
end
|
||||
|
||||
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
|
||||
object.post.topic.category_id
|
||||
end
|
||||
|
||||
def include_upcoming_dates?
|
||||
object.recurring?
|
||||
end
|
||||
|
||||
def upcoming_dates
|
||||
difference = object.original_ends_at ? object.original_ends_at - object.original_starts_at : 0
|
||||
|
||||
RRuleGenerator
|
||||
.generate(
|
||||
starts_at: object.original_starts_at.in_time_zone(object.timezone),
|
||||
timezone: object.timezone,
|
||||
max_years: 1,
|
||||
recurrence: object.recurrence,
|
||||
recurrence_until: object.recurrence_until,
|
||||
)
|
||||
.map { |date| { starts_at: date, ends_at: date + difference.seconds } }
|
||||
.take(31)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,166 @@
|
|||
import Component from "@glimmer/component";
|
||||
import { action } from "@ember/object";
|
||||
import { service } from "@ember/service";
|
||||
import AsyncContent from "discourse/components/async-content";
|
||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
import { bind } from "discourse/lib/decorators";
|
||||
import getURL from "discourse/lib/get-url";
|
||||
import Category from "discourse/models/category";
|
||||
import { formatEventName } from "../helpers/format-event-name";
|
||||
import { isNotFullDayEvent } from "../lib/guess-best-date-format";
|
||||
import FullCalendar from "./full-calendar";
|
||||
|
||||
export default class CategoryCalendar extends Component {
|
||||
@service currentUser;
|
||||
@service router;
|
||||
@service siteSettings;
|
||||
@service store;
|
||||
@service discoursePostEventApi;
|
||||
|
||||
@bind
|
||||
async loadEvents() {
|
||||
try {
|
||||
const params = {
|
||||
post_id: this.categorySetting?.postId,
|
||||
category_id: this.category.id,
|
||||
include_subcategories: true,
|
||||
};
|
||||
|
||||
if (this.siteSettings.include_expired_events_on_calendar) {
|
||||
params.include_expired = true;
|
||||
}
|
||||
|
||||
return await this.discoursePostEventApi.events(params);
|
||||
} catch (error) {
|
||||
popupAjaxError(error);
|
||||
}
|
||||
}
|
||||
|
||||
get tagsColorsMap() {
|
||||
return JSON.parse(this.siteSettings.map_events_to_color);
|
||||
}
|
||||
|
||||
get shouldRender() {
|
||||
if (this.siteSettings.login_required && !this.currentUser) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.router.currentRoute?.params?.category_slug_path_with_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.category) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.validCategory) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
get validCategory() {
|
||||
if (
|
||||
!this.categorySetting &&
|
||||
!this.siteSettings.events_calendar_categories
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
this.categorySetting?.categoryId === this.category.id.toString() ||
|
||||
this.siteSettings.events_calendar_categories
|
||||
.split("|")
|
||||
.filter(Boolean)
|
||||
.includes(this.category.id.toString())
|
||||
);
|
||||
}
|
||||
|
||||
get category() {
|
||||
return Category.findBySlugPathWithID(
|
||||
this.router.currentRoute.params.category_slug_path_with_id
|
||||
);
|
||||
}
|
||||
|
||||
get renderWeekends() {
|
||||
return this.categorySetting?.weekends !== "false";
|
||||
}
|
||||
|
||||
get categorySetting() {
|
||||
const settings = this.siteSettings.calendar_categories
|
||||
.split("|")
|
||||
.filter(Boolean)
|
||||
.map((stringSetting) => {
|
||||
const data = {};
|
||||
stringSetting
|
||||
.split(";")
|
||||
.filter(Boolean)
|
||||
.forEach((s) => {
|
||||
const parts = s.split("=");
|
||||
data[parts[0]] = parts[1];
|
||||
});
|
||||
return data;
|
||||
});
|
||||
|
||||
return settings.findBy("categoryId", this.category.id.toString());
|
||||
}
|
||||
|
||||
@action
|
||||
formatedEvents(events = []) {
|
||||
return events.map((event) => {
|
||||
const { startsAt, endsAt, post, categoryId } = event;
|
||||
|
||||
let backgroundColor;
|
||||
|
||||
if (post.topic.tags) {
|
||||
const tagColorEntry = this.tagsColorsMap.find(
|
||||
(entry) =>
|
||||
entry.type === "tag" && post.topic.tags.includes(entry.slug)
|
||||
);
|
||||
backgroundColor = tagColorEntry ? tagColorEntry.color : null;
|
||||
}
|
||||
|
||||
if (!backgroundColor) {
|
||||
const categoryColorFromMap = this.tagsColorsMap.find(
|
||||
(entry) =>
|
||||
entry.type === "category" && entry.slug === post.topic.category_slug
|
||||
)?.color;
|
||||
backgroundColor =
|
||||
categoryColorFromMap || `#${Category.findById(categoryId)?.color}`;
|
||||
}
|
||||
|
||||
let classNames;
|
||||
if (moment(endsAt || startsAt).isBefore(moment())) {
|
||||
classNames = "fc-past-event";
|
||||
}
|
||||
|
||||
return {
|
||||
title: formatEventName(event, this.currentUser?.user_option?.timezone),
|
||||
start: startsAt,
|
||||
display: "list-item",
|
||||
rrule: event.rrule,
|
||||
end: endsAt || startsAt,
|
||||
allDay: !isNotFullDayEvent(moment(startsAt), moment(endsAt)),
|
||||
url: getURL(`/t/-/${post.topic.id}/${post.post_number}`),
|
||||
backgroundColor,
|
||||
classNames,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
<template>
|
||||
{{#if this.shouldRender}}
|
||||
<AsyncContent @asyncData={{this.loadEvents}}>
|
||||
<:content as |events|>
|
||||
<FullCalendar
|
||||
@events={{this.formatedEvents events}}
|
||||
@height="650px"
|
||||
@initialView={{this.categorySetting?.defaultView}}
|
||||
@weekends={{this.renderWeekends}}
|
||||
/>
|
||||
</:content>
|
||||
</AsyncContent>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
|
@ -10,7 +10,11 @@ const DiscoursePostEventChatChannel = <template>
|
|||
{{#if (and @event.channel ChannelTitle)}}
|
||||
<section class="event__section event-chat-channel">
|
||||
<span></span>
|
||||
<LinkTo @route="chat.channel" @models={{@event.channel.routeModels}}>
|
||||
<LinkTo
|
||||
@route="chat.channel"
|
||||
@models={{@event.channel.routeModels}}
|
||||
class="chat-channel-link"
|
||||
>
|
||||
<ChannelTitle @channel={{@event.channel}} />
|
||||
</LinkTo>
|
||||
</section>
|
||||
|
|
|
@ -2,7 +2,7 @@ import Component from "@glimmer/component";
|
|||
import { tracked } from "@glimmer/tracking";
|
||||
import { action } from "@ember/object";
|
||||
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
|
||||
import { next } from "@ember/runloop";
|
||||
import { next, schedule } from "@ember/runloop";
|
||||
import { service } from "@ember/service";
|
||||
import { htmlSafe } from "@ember/template";
|
||||
import icon from "discourse/helpers/d-icon";
|
||||
|
@ -102,7 +102,7 @@ export default class DiscoursePostEventDates extends Component {
|
|||
time: date.format("HH:mm"),
|
||||
format,
|
||||
timezone: this.timezone,
|
||||
hideTimezone: this.args.event.showLocalTime,
|
||||
postId: this.args.event.id,
|
||||
};
|
||||
|
||||
if (this.args.event.showLocalTime) {
|
||||
|
@ -127,16 +127,18 @@ export default class DiscoursePostEventDates extends Component {
|
|||
this.htmlDates = htmlSafe(result.toString());
|
||||
|
||||
next(() => {
|
||||
if (this.isDestroying || this.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
schedule("afterRender", () => {
|
||||
if (this.isDestroying || this.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
|
||||
applyLocalDates(
|
||||
element.querySelectorAll(
|
||||
`[data-post-id="${this.args.event.id}"] .discourse-local-date`
|
||||
),
|
||||
this.siteSettings
|
||||
);
|
||||
applyLocalDates(
|
||||
element.querySelectorAll(
|
||||
`[data-post-id="${this.args.event.id}"].discourse-local-date`
|
||||
),
|
||||
this.siteSettings
|
||||
);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
let dates = `${this.startsAt.format(this.startsAtFormat)}`;
|
||||
|
@ -148,7 +150,11 @@ export default class DiscoursePostEventDates extends Component {
|
|||
}
|
||||
|
||||
<template>
|
||||
<section class="event__section event-dates" {{didInsert this.computeDates}}>
|
||||
<section
|
||||
data-event-id={{@event.id}}
|
||||
class="event__section event-dates"
|
||||
{{didInsert this.computeDates}}
|
||||
>
|
||||
{{icon "clock"}}{{this.htmlDates}}</section>
|
||||
</template>
|
||||
}
|
||||
|
|
|
@ -1,12 +1,16 @@
|
|||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { service } from "@ember/service";
|
||||
import { modifier } from "ember-modifier";
|
||||
import AsyncContent from "discourse/components/async-content";
|
||||
import DButton from "discourse/components/d-button";
|
||||
import PluginOutlet from "discourse/components/plugin-outlet";
|
||||
import concatClass from "discourse/helpers/concat-class";
|
||||
import icon from "discourse/helpers/d-icon";
|
||||
import lazyHash from "discourse/helpers/lazy-hash";
|
||||
import replaceEmoji from "discourse/helpers/replace-emoji";
|
||||
import routeAction from "discourse/helpers/route-action";
|
||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
import { bind } from "discourse/lib/decorators";
|
||||
import ChatChannel from "./chat-channel";
|
||||
import Creator from "./creator";
|
||||
import Dates from "./dates";
|
||||
|
@ -37,21 +41,26 @@ export default class DiscoursePostEvent extends Component {
|
|||
@service discoursePostEventApi;
|
||||
@service messageBus;
|
||||
|
||||
@tracked event = this.args.event;
|
||||
|
||||
setupMessageBus = modifier(() => {
|
||||
const { event } = this.args;
|
||||
const path = `/discourse-post-event/${event.post.topic.id}`;
|
||||
const path = `/discourse-post-event/${this.event.post.topic.id}`;
|
||||
this.messageBus.subscribe(path, async (msg) => {
|
||||
const eventData = await this.discoursePostEventApi.event(msg.id);
|
||||
event.updateFromEvent(eventData);
|
||||
this.event.updateFromEvent(eventData);
|
||||
});
|
||||
|
||||
return () => this.messageBus.unsubscribe(path);
|
||||
});
|
||||
|
||||
get withDescription() {
|
||||
return this.args.withDescription ?? true;
|
||||
}
|
||||
|
||||
get localStartsAtTime() {
|
||||
let time = moment(this.args.event.startsAt);
|
||||
if (this.args.event.showLocalTime && this.args.event.timezone) {
|
||||
time = time.tz(this.args.event.timezone);
|
||||
let time = moment(this.event.startsAt);
|
||||
if (this.event.showLocalTime && this.event.timezone) {
|
||||
time = time.tz(this.event.timezone);
|
||||
}
|
||||
return time;
|
||||
}
|
||||
|
@ -65,93 +74,135 @@ export default class DiscoursePostEvent extends Component {
|
|||
}
|
||||
|
||||
get eventName() {
|
||||
return this.args.event.name || this.args.event.post.topic.title;
|
||||
return this.event.name || this.event.post.topic.title;
|
||||
}
|
||||
|
||||
get isPublicEvent() {
|
||||
return this.args.event.status === "public";
|
||||
return this.event.status === "public";
|
||||
}
|
||||
|
||||
get isStandaloneEvent() {
|
||||
return this.args.event.status === "standalone";
|
||||
return this.event.status === "standalone";
|
||||
}
|
||||
|
||||
get canActOnEvent() {
|
||||
return this.currentUser && this.args.event.can_act_on_discourse_post_event;
|
||||
return this.currentUser && this.event.can_act_on_discourse_post_event;
|
||||
}
|
||||
|
||||
get watchingInviteeStatus() {
|
||||
return this.args.event.watchingInvitee?.status;
|
||||
return this.event.watchingInvitee?.status;
|
||||
}
|
||||
|
||||
@bind
|
||||
async loadEvent() {
|
||||
if (this.event) {
|
||||
return this.event;
|
||||
}
|
||||
|
||||
if (this.args.eventId) {
|
||||
try {
|
||||
return (this.event = await this.discoursePostEventApi.event(
|
||||
this.args.eventId
|
||||
));
|
||||
} catch (error) {
|
||||
popupAjaxError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
<template>
|
||||
<div
|
||||
class={{concatClass
|
||||
"discourse-post-event"
|
||||
(if @event "is-loaded" "is-loading")
|
||||
}}
|
||||
>
|
||||
<div class="discourse-post-event-widget">
|
||||
{{#if @event}}
|
||||
<header class="event-header" {{this.setupMessageBus}}>
|
||||
<div class="event-date">
|
||||
<div class="month">{{this.startsAtMonth}}</div>
|
||||
<div class="day">{{this.startsAtDay}}</div>
|
||||
</div>
|
||||
<div class="event-info">
|
||||
<span class="name">
|
||||
{{replaceEmoji this.eventName}}
|
||||
</span>
|
||||
<div class="status-and-creators">
|
||||
<PluginOutlet
|
||||
@name="discourse-post-event-status-and-creators"
|
||||
@outletArgs={{lazyHash
|
||||
event=@event
|
||||
Separator=StatusSeparator
|
||||
Status=(component EventStatus event=@event)
|
||||
Creator=(component Creator user=@event.creator)
|
||||
}}
|
||||
>
|
||||
<EventStatus @event={{@event}} />
|
||||
<StatusSeparator />
|
||||
<Creator @user={{@event.creator}} />
|
||||
</PluginOutlet>
|
||||
</div>
|
||||
</div>
|
||||
<AsyncContent @asyncData={{this.loadEvent}}>
|
||||
<:content as |event|>
|
||||
<div class="discourse-post-event">
|
||||
<div class="discourse-post-event-widget">
|
||||
{{#if event}}
|
||||
<header class="event-header" {{this.setupMessageBus}}>
|
||||
<div class="event-date">
|
||||
<div class="month">{{this.startsAtMonth}}</div>
|
||||
<div class="day">{{this.startsAtDay}}</div>
|
||||
</div>
|
||||
<div class="event-info">
|
||||
<span class="name">
|
||||
{{#if @linkToPost}}
|
||||
<a
|
||||
href={{event.post.url}}
|
||||
rel="noopener noreferrer"
|
||||
>{{replaceEmoji this.eventName}}</a>
|
||||
{{else}}
|
||||
{{replaceEmoji this.eventName}}
|
||||
{{/if}}
|
||||
</span>
|
||||
<div class="status-and-creators">
|
||||
<PluginOutlet
|
||||
@name="discourse-post-event-status-and-creators"
|
||||
@outletArgs={{lazyHash
|
||||
event=event
|
||||
Separator=StatusSeparator
|
||||
Status=(component EventStatus event=event)
|
||||
Creator=(component Creator user=event.creator)
|
||||
}}
|
||||
>
|
||||
<EventStatus @event={{event}} />
|
||||
<StatusSeparator />
|
||||
<Creator @user={{event.creator}} />
|
||||
</PluginOutlet>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MoreMenu
|
||||
@event={{@event}}
|
||||
@isStandaloneEvent={{this.isStandaloneEvent}}
|
||||
@composePrivateMessage={{routeAction "composePrivateMessage"}}
|
||||
/>
|
||||
</header>
|
||||
<MoreMenu
|
||||
@event={{event}}
|
||||
@isStandaloneEvent={{this.isStandaloneEvent}}
|
||||
@composePrivateMessage={{routeAction "composePrivateMessage"}}
|
||||
/>
|
||||
|
||||
<PluginOutlet
|
||||
@name="discourse-post-event-info"
|
||||
@outletArgs={{lazyHash
|
||||
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)
|
||||
}}
|
||||
>
|
||||
<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 @onClose}}
|
||||
<DButton
|
||||
class="btn-small"
|
||||
@icon="xmark"
|
||||
@action={{@onClose}}
|
||||
/>
|
||||
{{/if}}
|
||||
</header>
|
||||
|
||||
<PluginOutlet
|
||||
@name="discourse-post-event-info"
|
||||
@outletArgs={{lazyHash
|
||||
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)
|
||||
}}
|
||||
>
|
||||
<Dates @event={{event}} />
|
||||
<Location @location={{event.location}} />
|
||||
<Url @url={{event.url}} />
|
||||
<ChatChannel @event={{event}} />
|
||||
<Invitees @event={{event}} />
|
||||
|
||||
{{#if this.withDescription}}
|
||||
<Description @description={{event.description}} />
|
||||
{{/if}}
|
||||
|
||||
{{#if @event.canUpdateAttendance}}
|
||||
<Status @event={{event}} />
|
||||
{{/if}}
|
||||
</PluginOutlet>
|
||||
{{/if}}
|
||||
</PluginOutlet>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</:content>
|
||||
<:loading>
|
||||
<div class="discourse-post-event-loader">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
</:loading>
|
||||
</AsyncContent>
|
||||
</template>
|
||||
}
|
||||
|
|
|
@ -1,9 +1,26 @@
|
|||
import Component from "@glimmer/component";
|
||||
import { action } from "@ember/object";
|
||||
import { service } from "@ember/service";
|
||||
import DButton from "discourse/components/d-button";
|
||||
import icon from "discourse/helpers/d-icon";
|
||||
import { i18n } from "discourse-i18n";
|
||||
import PostEventInvitees from "../modal/post-event-invitees";
|
||||
import Invitee from "./invitee";
|
||||
|
||||
export default class DiscoursePostEventInvitees extends Component {
|
||||
@service modal;
|
||||
|
||||
@action
|
||||
showInvitees() {
|
||||
this.modal.show(PostEventInvitees, {
|
||||
model: {
|
||||
event: this.args.event,
|
||||
title: this.args.event.title,
|
||||
extraClass: this.args.event.extraClass,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
get hasAttendees() {
|
||||
return this.args.event.stats.going > 0;
|
||||
}
|
||||
|
@ -23,12 +40,16 @@ export default class DiscoursePostEventInvitees extends Component {
|
|||
{{#if @event.shouldDisplayInvitees}}
|
||||
<section class="event__section event-invitees">
|
||||
<div class="event-invitees-avatars-container">
|
||||
<div class="event-invitees-icon" title={{this.inviteesTitle}}>
|
||||
<DButton
|
||||
class="event-invitees-icon btn-transparent"
|
||||
title={{this.inviteesTitle}}
|
||||
@action={{this.showInvitees}}
|
||||
>
|
||||
{{icon "users"}}
|
||||
{{#if this.hasAttendees}}
|
||||
<span class="going">{{this.statsInfo}}</span>
|
||||
{{/if}}
|
||||
</div>
|
||||
</DButton>
|
||||
<ul class="event-invitees-avatars">
|
||||
{{#each @event.sampleInvitees as |invitee|}}
|
||||
<Invitee @invitee={{invitee}} />
|
||||
|
|
|
@ -84,7 +84,7 @@ export default class DiscoursePostEventMoreMenu extends Component {
|
|||
},
|
||||
],
|
||||
{
|
||||
recurrenceRule: event.recurrenceRule,
|
||||
rrule: event.rrule,
|
||||
location: event.url,
|
||||
details: getAbsoluteURL(event.post.url),
|
||||
}
|
||||
|
@ -253,6 +253,7 @@ export default class DiscoursePostEventMoreMenu extends Component {
|
|||
@identifier="discourse-post-event-more-menu"
|
||||
@triggerClass={{concatClass
|
||||
"more-dropdown"
|
||||
"btn-small"
|
||||
(if this.isSavingEvent "--saving")
|
||||
}}
|
||||
@icon="ellipsis"
|
||||
|
|
|
@ -0,0 +1,170 @@
|
|||
import Component from "@glimmer/component";
|
||||
import { inject as controller } from "@ember/controller";
|
||||
import { action } from "@ember/object";
|
||||
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
|
||||
import didUpdate from "@ember/render-modifiers/modifiers/did-update";
|
||||
import { service } from "@ember/service";
|
||||
import { htmlSafe } from "@ember/template";
|
||||
import loadFullCalendar from "discourse/lib/load-full-calendar";
|
||||
import DiscourseURL from "discourse/lib/url";
|
||||
import DiscoursePostEvent from "discourse/plugins/discourse-calendar/discourse/components/discourse-post-event";
|
||||
import {
|
||||
getCalendarButtonsText,
|
||||
getCurrentBcp47Locale,
|
||||
} from "../lib/calendar-locale";
|
||||
import { normalizeViewForCalendar } from "../lib/calendar-view-helper";
|
||||
|
||||
const PostEventMenu = <template>
|
||||
<DiscoursePostEvent
|
||||
@linkToPost={{true}}
|
||||
@event={{@data.event}}
|
||||
@eventId={{@data.eventId}}
|
||||
@onClose={{@data.onClose}}
|
||||
@withDescription={{false}}
|
||||
/>
|
||||
</template>;
|
||||
|
||||
export default class FullCalendar extends Component {
|
||||
@service currentUser;
|
||||
@service capabilities;
|
||||
@service tooltip;
|
||||
@service menu;
|
||||
|
||||
@controller topic;
|
||||
|
||||
calendar = null;
|
||||
|
||||
willDestroy() {
|
||||
this.calendar?.destroy?.();
|
||||
this.menu.getByIdentifier("post-event-menu")?.destroy?.();
|
||||
super.willDestroy(...arguments);
|
||||
}
|
||||
|
||||
@action
|
||||
async setupCalendar(element) {
|
||||
const calendarModule = await loadFullCalendar();
|
||||
|
||||
this.calendar = new calendarModule.Calendar(element, {
|
||||
locale: getCurrentBcp47Locale(),
|
||||
buttonText: getCalendarButtonsText(),
|
||||
timeZone: this.currentUser?.user_option?.timezone || "local",
|
||||
firstDay: 1,
|
||||
displayEventTime: true,
|
||||
weekends: this.args.weekends ?? true,
|
||||
initialDate: this.args.initialDate,
|
||||
height: this.args.height ?? "100%",
|
||||
events: this.args.events || [],
|
||||
plugins: [
|
||||
calendarModule.DayGrid,
|
||||
calendarModule.TimeGrid,
|
||||
calendarModule.List,
|
||||
calendarModule.RRULE,
|
||||
calendarModule.MomentTimezone,
|
||||
],
|
||||
initialView: this.initialView,
|
||||
headerToolbar: this.headerToolbar,
|
||||
customButtons: this.args.customButtons || {},
|
||||
eventWillUnmount: async () => {
|
||||
await this.activeMenu?.close?.();
|
||||
await this.activeTooltip?.close?.();
|
||||
},
|
||||
datesSet: (info) => {
|
||||
this.args.onDatesChange?.(info);
|
||||
},
|
||||
eventMouseLeave: async () => {
|
||||
await this.activeTooltip?.close?.();
|
||||
},
|
||||
eventMouseEnter: async ({ el, event }) => {
|
||||
const { htmlContent } = event.extendedProps;
|
||||
|
||||
if (htmlContent) {
|
||||
this.activeTooltip = await this.tooltip.show(el, {
|
||||
identifier: "post-event-tooltip",
|
||||
triggers: ["hover"],
|
||||
content: htmlSafe(
|
||||
// this is a workaround to allow linebreaks in the tooltip
|
||||
"<div>" + htmlContent + "</div>"
|
||||
),
|
||||
});
|
||||
}
|
||||
},
|
||||
eventClick: async ({ el, event, jsEvent }) => {
|
||||
const { postNumber, postUrl, postEvent } = event.extendedProps;
|
||||
|
||||
if (postEvent?.id) {
|
||||
jsEvent.preventDefault();
|
||||
|
||||
this.activeMenu = await this.menu.show(
|
||||
{
|
||||
getBoundingClientRect() {
|
||||
return el.getBoundingClientRect();
|
||||
},
|
||||
},
|
||||
{
|
||||
identifier: "post-event-menu",
|
||||
component: PostEventMenu,
|
||||
modalForMobile: true,
|
||||
maxWidth: 500,
|
||||
data: {
|
||||
eventId: postEvent.id,
|
||||
onClose: () => {
|
||||
this.menu.getByIdentifier("post-event-menu")?.close?.();
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
} else if (postUrl) {
|
||||
DiscourseURL.routeTo(postUrl);
|
||||
} else if (postNumber) {
|
||||
this.topic.send("jumpToPost", postNumber);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
this.calendar.render();
|
||||
}
|
||||
|
||||
@action
|
||||
updateCalendar() {
|
||||
if (this.calendar) {
|
||||
this.calendar.setOption("headerToolbar", this.headerToolbar);
|
||||
this.calendar.setOption("events", this.args.events || []);
|
||||
if (this.args.initialDate) {
|
||||
this.calendar.gotoDate(this.args.initialDate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get defaultLeftHeaderToolbar() {
|
||||
return !this.capabilities.viewport.md ? "prev,next" : "prev,next today";
|
||||
}
|
||||
|
||||
get headerToolbar() {
|
||||
return {
|
||||
left: this.args.leftHeaderToolbar ?? this.defaultLeftHeaderToolbar,
|
||||
center: this.args.centerHeaderToolbar ?? "title",
|
||||
right:
|
||||
this.args.rightHeaderToolbar ??
|
||||
"timeGridDay,timeGridWeek,dayGridMonth,listYear",
|
||||
};
|
||||
}
|
||||
|
||||
get initialView() {
|
||||
const normalizedView = normalizeViewForCalendar(this.args.initialView);
|
||||
|
||||
return (
|
||||
normalizedView ||
|
||||
(this.capabilities.viewport.sm ? "dayGridMonth" : "timeGridWeek")
|
||||
);
|
||||
}
|
||||
|
||||
<template>
|
||||
<div
|
||||
{{didInsert this.setupCalendar}}
|
||||
{{didUpdate this.updateCalendar @events this.capabilities.viewport.md}}
|
||||
...attributes
|
||||
>
|
||||
{{! The calendar will be rendered inside this div by the library }}
|
||||
</div>
|
||||
</template>
|
||||
}
|
|
@ -24,7 +24,7 @@ export default class PostEventInviteesModal extends Component {
|
|||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this._fetchInvitees();
|
||||
this.fetchInvitees();
|
||||
}
|
||||
|
||||
get hasSuggestedUsers() {
|
||||
|
@ -46,13 +46,13 @@ export default class PostEventInviteesModal extends Component {
|
|||
@action
|
||||
toggleType(type) {
|
||||
this.type = type;
|
||||
this._fetchInvitees(this.filter);
|
||||
this.fetchInvitees(this.filter);
|
||||
}
|
||||
|
||||
@debounce(250)
|
||||
onFilterChanged(event) {
|
||||
this.filter = event.target.value;
|
||||
this._fetchInvitees(this.filter);
|
||||
this.fetchInvitees(this.filter);
|
||||
}
|
||||
|
||||
@action
|
||||
|
@ -75,7 +75,7 @@ export default class PostEventInviteesModal extends Component {
|
|||
this.inviteesList.add(invitee);
|
||||
}
|
||||
|
||||
async _fetchInvitees(filter) {
|
||||
async fetchInvitees(filter) {
|
||||
try {
|
||||
this.isLoading = true;
|
||||
|
||||
|
@ -114,6 +114,7 @@ export default class PostEventInviteesModal extends Component {
|
|||
{{#each this.inviteesList.invitees as |invitee|}}
|
||||
<li class="invitee">
|
||||
<User @user={{invitee.user}} />
|
||||
|
||||
{{#if @model.event.canActOnDiscoursePostEvent}}
|
||||
<DButton
|
||||
class="remove-invitee"
|
||||
|
@ -132,14 +133,17 @@ export default class PostEventInviteesModal extends Component {
|
|||
{{#each this.inviteesList.suggestedUsers as |user|}}
|
||||
<li class="invitee">
|
||||
<User @user={{user}} />
|
||||
<DButton
|
||||
class="add-invitee"
|
||||
@icon="plus"
|
||||
@action={{fn this.addInvitee user}}
|
||||
title={{i18n
|
||||
"discourse_post_event.invitees_modal.add_invitee"
|
||||
}}
|
||||
/>
|
||||
|
||||
{{#if @model.event.canActOnDiscoursePostEvent}}
|
||||
<DButton
|
||||
class="add-invitee"
|
||||
@icon="plus"
|
||||
@action={{fn this.addInvitee user}}
|
||||
title={{i18n
|
||||
"discourse_post_event.invitees_modal.add_invitee"
|
||||
}}
|
||||
/>
|
||||
{{/if}}
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
|
|
|
@ -0,0 +1,310 @@
|
|||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { action } from "@ember/object";
|
||||
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
|
||||
import willDestroy from "@ember/render-modifiers/modifiers/will-destroy";
|
||||
import { service } from "@ember/service";
|
||||
import { htmlSafe } from "@ember/template";
|
||||
import getURL from "discourse/lib/get-url";
|
||||
import { escapeExpression } from "discourse/lib/utilities";
|
||||
import { colorToHex, contrastColor, stringToColor } from "../lib/colors";
|
||||
import FullCalendar from "./full-calendar";
|
||||
|
||||
export default class PostCalendar extends Component {
|
||||
@service currentUser;
|
||||
@service siteSettings;
|
||||
@service capabilities;
|
||||
@service postCalendar;
|
||||
@service store;
|
||||
|
||||
@tracked post = this.args.post;
|
||||
|
||||
@action
|
||||
registerPostCalendar() {
|
||||
this.postCalendar.registerComponent(this);
|
||||
}
|
||||
|
||||
@action
|
||||
teardownPostCalendar() {
|
||||
this.postCalendar.teardownComponent();
|
||||
}
|
||||
|
||||
get isStatic() {
|
||||
return this.args.options.calendarType === "static";
|
||||
}
|
||||
|
||||
get isFullDay() {
|
||||
return this.args.options.calendarFullDay === "true";
|
||||
}
|
||||
|
||||
get events() {
|
||||
const events = [];
|
||||
|
||||
if (this.isStatic) {
|
||||
events.push(...(this.args.staticEvents ?? []));
|
||||
} else {
|
||||
events.push(...(this.dynamicEvents ?? []));
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
get dynamicEvents() {
|
||||
const events = [];
|
||||
const groupedEvents = [];
|
||||
|
||||
(this.post.calendar_details || []).forEach((detail) => {
|
||||
switch (detail.type) {
|
||||
case "grouped":
|
||||
if (this.isFullDay && detail.timezone) {
|
||||
detail.from = moment
|
||||
.tz(detail.from, detail.timezone)
|
||||
.format("YYYY-MM-DD");
|
||||
}
|
||||
groupedEvents.push(detail);
|
||||
break;
|
||||
case "standalone":
|
||||
if (this.isFullDay && detail.timezone) {
|
||||
const eventDetail = { ...detail };
|
||||
const from = moment.tz(detail.from, detail.timezone);
|
||||
const to = moment.tz(detail.to, detail.timezone);
|
||||
eventDetail.from = from.format("YYYY-MM-DD");
|
||||
eventDetail.to = to.format("YYYY-MM-DD");
|
||||
events.push(this.buildStandaloneEvent(eventDetail));
|
||||
} else {
|
||||
events.push(this.buildStandaloneEvent(detail));
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
const formattedGroupedEvents = {};
|
||||
groupedEvents.forEach((groupedEvent) => {
|
||||
const minDate = this.isFullDay
|
||||
? moment(groupedEvent.from).format("YYYY-MM-DD")
|
||||
: moment(groupedEvent.from).utc().startOf("day").toISOString();
|
||||
const maxDate = this.isFullDay
|
||||
? moment(groupedEvent.to || groupedEvent.from).format("YYYY-MM-DD")
|
||||
: moment(groupedEvent.to || groupedEvent.from)
|
||||
.utc()
|
||||
.endOf("day")
|
||||
.toISOString();
|
||||
|
||||
const identifier = `${minDate}-${maxDate}`;
|
||||
formattedGroupedEvents[identifier] = formattedGroupedEvents[
|
||||
identifier
|
||||
] || {
|
||||
from: minDate,
|
||||
to: maxDate || minDate,
|
||||
localEvents: {},
|
||||
};
|
||||
|
||||
formattedGroupedEvents[identifier].localEvents[groupedEvent.name] =
|
||||
formattedGroupedEvents[identifier].localEvents[groupedEvent.name] || {
|
||||
users: [],
|
||||
};
|
||||
|
||||
formattedGroupedEvents[identifier].localEvents[
|
||||
groupedEvent.name
|
||||
].users.push.apply(
|
||||
formattedGroupedEvents[identifier].localEvents[groupedEvent.name].users,
|
||||
groupedEvent.users
|
||||
);
|
||||
});
|
||||
|
||||
Object.keys(formattedGroupedEvents).forEach((key) => {
|
||||
const formattedGroupedEvent = formattedGroupedEvents[key];
|
||||
this.buildGroupedEvents(formattedGroupedEvent).forEach((event) => {
|
||||
events.push(event);
|
||||
});
|
||||
});
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
buildEvent(detail) {
|
||||
const event = this.buildEventObject(
|
||||
detail.from
|
||||
? {
|
||||
dateTime: moment(detail.from),
|
||||
weeklyRecurring: detail.recurring === "1.weeks",
|
||||
}
|
||||
: null,
|
||||
detail.to
|
||||
? {
|
||||
dateTime: moment(detail.to),
|
||||
weeklyRecurring: detail.recurring === "1.weeks",
|
||||
}
|
||||
: null
|
||||
);
|
||||
|
||||
event.extendedProps = {};
|
||||
if (detail.post_url) {
|
||||
event.extendedProps.postUrl = getURL(detail.post_url);
|
||||
} else if (detail.post_number) {
|
||||
event.extendedProps.postNumber = detail.post_number;
|
||||
} else {
|
||||
event.classNames = ["holiday"];
|
||||
}
|
||||
|
||||
if (detail.timezoneOffset) {
|
||||
event.extendedProps.timezoneOffset = detail.timezoneOffset;
|
||||
}
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
buildEventObject(from, to) {
|
||||
const hasTimeSpecified = (d) => {
|
||||
if (!d) {
|
||||
return false;
|
||||
}
|
||||
return d.hours() || d.minutes() || d.seconds();
|
||||
};
|
||||
|
||||
const hasTime =
|
||||
hasTimeSpecified(to?.dateTime) || hasTimeSpecified(from?.dateTime);
|
||||
const dateFormat = hasTime ? "YYYY-MM-DD HH:mm:ssZ" : "YYYY-MM-DD";
|
||||
|
||||
let event = {
|
||||
start: from.dateTime.format(dateFormat),
|
||||
allDay: false,
|
||||
};
|
||||
|
||||
if (to) {
|
||||
if (hasTime) {
|
||||
event.end = to.dateTime.format(dateFormat);
|
||||
} else {
|
||||
event.end = to.dateTime.add(1, "days").format(dateFormat);
|
||||
event.allDay = true;
|
||||
}
|
||||
} else {
|
||||
event.allDay = true;
|
||||
}
|
||||
|
||||
if (from.weeklyRecurring) {
|
||||
event.startTime = {
|
||||
hours: from.dateTime.hours(),
|
||||
minutes: from.dateTime.minutes(),
|
||||
seconds: from.dateTime.seconds(),
|
||||
};
|
||||
event.daysOfWeek = [from.dateTime.day()];
|
||||
}
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
buildGroupedEvents(detail) {
|
||||
const events = [];
|
||||
const groupedEventData = [detail];
|
||||
|
||||
groupedEventData.forEach((eventData) => {
|
||||
let htmlContent = "";
|
||||
let users = [];
|
||||
let localEventNames = [];
|
||||
|
||||
Object.keys(eventData.localEvents)
|
||||
.sort()
|
||||
.forEach((key) => {
|
||||
const localEvent = eventData.localEvents[key];
|
||||
htmlContent += `<b>${key}</b>: ${localEvent.users
|
||||
.map((u) => u.username)
|
||||
.sort()
|
||||
.join(", ")}<br>`;
|
||||
users = users.concat(localEvent.users);
|
||||
localEventNames.push(key);
|
||||
});
|
||||
|
||||
const event = this.buildEvent(eventData);
|
||||
event.classNames = ["grouped-event"];
|
||||
|
||||
if (users.length > 2) {
|
||||
event.title = `(${users.length}) ${localEventNames[0]}`;
|
||||
} else if (users.length === 1) {
|
||||
event.title = users[0].username;
|
||||
} else {
|
||||
event.title = !this.capabilities.viewport.sm
|
||||
? `(${users.length}) ${localEventNames[0]}`
|
||||
: `(${users.length}) ` + users.map((u) => u.username).join(", ");
|
||||
}
|
||||
|
||||
if (localEventNames.length > 1) {
|
||||
event.extendedProps.htmlContent = htmlContent;
|
||||
} else {
|
||||
if (users.length > 1) {
|
||||
event.extendedProps.htmlContent = htmlContent;
|
||||
} else {
|
||||
event.extendedProps.htmlContent = localEventNames[0];
|
||||
}
|
||||
}
|
||||
|
||||
event.participantCount = users.length;
|
||||
events.push(event);
|
||||
});
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
buildStandaloneEvent(detail) {
|
||||
const event = this.buildEvent(detail);
|
||||
const holidayCalendarTopicId = parseInt(
|
||||
this.siteSettings.holiday_calendar_topic_id,
|
||||
10
|
||||
);
|
||||
const text = detail.message.split("\n").filter(Boolean);
|
||||
|
||||
if (
|
||||
text.length &&
|
||||
this.args.post.topic_id &&
|
||||
holidayCalendarTopicId !== this.args.post.topic_id
|
||||
) {
|
||||
event.title = text[0];
|
||||
event.extendedProps.description = text.slice(1).join(" ");
|
||||
} else {
|
||||
const color = stringToColor(detail.username);
|
||||
event.title = detail.username;
|
||||
event.backgroundColor = colorToHex(color);
|
||||
event.textColor = contrastColor(color);
|
||||
}
|
||||
|
||||
let popupText = detail.message.slice(0, 100);
|
||||
if (detail.message.length > 100) {
|
||||
popupText += "…";
|
||||
}
|
||||
event.extendedProps.htmlContent = htmlSafe(escapeExpression(popupText));
|
||||
event.title = event.title.replace(/<img[^>]*>/g, "");
|
||||
event.participantCount = 1;
|
||||
|
||||
if (detail.post_url) {
|
||||
event.extendedProps.postUrl = getURL(detail.post_url);
|
||||
}
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
get leftHeaderToolbar() {
|
||||
return this.capabilities.viewport.sm
|
||||
? "prev,next today"
|
||||
: "prev,next title";
|
||||
}
|
||||
|
||||
get centerHeaderToolbar() {
|
||||
return this.capabilities.viewport.sm ? "title" : "";
|
||||
}
|
||||
|
||||
<template>
|
||||
<div
|
||||
{{didInsert this.registerPostCalendar}}
|
||||
{{willDestroy this.teardownPostCalendar}}
|
||||
class="post-calendar"
|
||||
>
|
||||
<FullCalendar
|
||||
@leftHeaderToolbar={{this.leftHeaderToolbar}}
|
||||
@centerHeaderToolbar={{this.centerHeaderToolbar}}
|
||||
@rightHeaderToolbar="timeGridDay,timeGridWeek,dayGridMonth,listYear"
|
||||
@events={{this.events}}
|
||||
@height={{@height}}
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
}
|
|
@ -1,126 +1,64 @@
|
|||
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 { service } from "@ember/service";
|
||||
import { Promise } from "rsvp";
|
||||
import moment from "moment";
|
||||
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 { normalizeViewForRoute } from "../lib/calendar-view-helper";
|
||||
import { isNotFullDayEvent } from "../lib/guess-best-date-format";
|
||||
import FullCalendar from "./full-calendar";
|
||||
|
||||
export default class UpcomingEventsCalendar extends Component {
|
||||
@service currentUser;
|
||||
@service site;
|
||||
@service router;
|
||||
@service capabilities;
|
||||
@service siteSettings;
|
||||
|
||||
_calendar = null;
|
||||
_isInitializing = true;
|
||||
_isViewChanging = false;
|
||||
|
||||
get displayFilters() {
|
||||
return this.currentUser && this.args.controller;
|
||||
}
|
||||
|
||||
@action
|
||||
teardown() {
|
||||
this._calendar?.destroy?.();
|
||||
this._calendar = null;
|
||||
}
|
||||
|
||||
@action
|
||||
async renderCalendar() {
|
||||
const siteSettings = this.site.siteSettings;
|
||||
const isMobileView = this.site.mobileView;
|
||||
|
||||
const calendarNode = document.getElementById("upcoming-events-calendar");
|
||||
if (!calendarNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
calendarNode.innerHTML = "";
|
||||
|
||||
await this._loadCalendar();
|
||||
|
||||
const view =
|
||||
this.args.controller?.view || (isMobileView ? "listNextYear" : "month");
|
||||
|
||||
const fullCalendar = new window.FullCalendar.Calendar(calendarNode, {
|
||||
...fullCalendarDefaultOptions(),
|
||||
timeZone: this.currentUser?.user_option?.timezone || "local",
|
||||
firstDay: 1,
|
||||
height: "auto",
|
||||
defaultView: view,
|
||||
views: {
|
||||
listNextYear: {
|
||||
type: "list",
|
||||
duration: { days: 365 },
|
||||
buttonText: "list",
|
||||
listDayFormat: {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
day: "numeric",
|
||||
weekday: "long",
|
||||
},
|
||||
get customButtons() {
|
||||
return {
|
||||
mineEvents: {
|
||||
text: i18n("discourse_post_event.upcoming_events.my_events"),
|
||||
click: () => {
|
||||
const params = this.router.currentRoute.params;
|
||||
this.router.replaceWith(
|
||||
"discourse-post-event-upcoming-events.mine",
|
||||
params.view || "month",
|
||||
params.year || moment().year(),
|
||||
params.month || moment().month() + 1,
|
||||
params.day || moment().date()
|
||||
);
|
||||
},
|
||||
},
|
||||
header: {
|
||||
left: "prev,next today",
|
||||
center: "title",
|
||||
right: "month,basicWeek,listNextYear",
|
||||
allEvents: {
|
||||
text: i18n("discourse_post_event.upcoming_events.all_events"),
|
||||
click: () => {
|
||||
const params = this.router.currentRoute.params;
|
||||
this.router.replaceWith(
|
||||
"discourse-post-event-upcoming-events.index",
|
||||
params.view || "month",
|
||||
params.year || moment().year(),
|
||||
params.month || moment().month() + 1,
|
||||
params.day || moment().date()
|
||||
);
|
||||
},
|
||||
},
|
||||
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");
|
||||
get events() {
|
||||
if (!this.args.events) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!fcContent) {
|
||||
return;
|
||||
}
|
||||
const tagsColorsMap = JSON.parse(this.siteSettings.map_events_to_color);
|
||||
|
||||
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();
|
||||
},
|
||||
});
|
||||
this._calendar = fullCalendar;
|
||||
|
||||
const tagsColorsMap = JSON.parse(siteSettings.map_events_to_color);
|
||||
|
||||
const resolvedEvents = this.args.events
|
||||
? await this.args.events
|
||||
: await this.args.controller.model;
|
||||
const originalEventAndRecurrents = addRecurrentEvents(resolvedEvents);
|
||||
|
||||
(originalEventAndRecurrents || []).forEach((event) => {
|
||||
return this.args.events.map((event) => {
|
||||
const { startsAt, endsAt, post, categoryId } = event;
|
||||
|
||||
let backgroundColor;
|
||||
|
@ -151,62 +89,250 @@ export default class UpcomingEventsCalendar extends Component {
|
|||
classNames = "fc-past-event";
|
||||
}
|
||||
|
||||
this._calendar.addEvent({
|
||||
return {
|
||||
extendedProps: { postEvent: event },
|
||||
title: formatEventName(event, this.currentUser?.user_option?.timezone),
|
||||
rrule: event.rrule,
|
||||
start: startsAt,
|
||||
end: endsAt || startsAt,
|
||||
allDay: !isNotFullDayEvent(moment(startsAt), moment(endsAt)),
|
||||
url: getURL(`/t/-/${post.topic.id}/${post.post_number}`),
|
||||
backgroundColor,
|
||||
classNames,
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
this._calendar.render();
|
||||
}
|
||||
|
||||
_loadCalendar() {
|
||||
return new Promise((resolve) => {
|
||||
loadScript(
|
||||
"/plugins/discourse-calendar/javascripts/fullcalendar-with-moment-timezone.min.js"
|
||||
).then(() => {
|
||||
schedule("afterRender", () => {
|
||||
if (this.isDestroying || this.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
get leftHeaderToolbar() {
|
||||
let left = "";
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
if (!this.capabilities.viewport.sm) {
|
||||
left = `title allEvents,mineEvents`;
|
||||
} else {
|
||||
left += "allEvents,mineEvents";
|
||||
}
|
||||
|
||||
if (!this.capabilities.viewport.sm) {
|
||||
return left;
|
||||
} else {
|
||||
return `${left} prev,next,today`;
|
||||
}
|
||||
}
|
||||
|
||||
get rightHeaderToolbar() {
|
||||
if (!this.capabilities.viewport.sm) {
|
||||
return "prev,next timeGridDay,timeGridWeek,dayGridMonth,listYear";
|
||||
} else {
|
||||
return "timeGridDay,timeGridWeek,dayGridMonth,listYear";
|
||||
}
|
||||
}
|
||||
|
||||
get centerHeaderToolbar() {
|
||||
if (!this.capabilities.viewport.sm) {
|
||||
return "";
|
||||
} else {
|
||||
return "title";
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
async onDatesChange(info) {
|
||||
this.applyCustomButtonsState();
|
||||
|
||||
const view = normalizeViewForRoute(info.view.type);
|
||||
const currentParams = this.router.currentRoute.params;
|
||||
const currentYear = parseInt(currentParams.year, 10);
|
||||
const currentMonth = parseInt(currentParams.month, 10);
|
||||
const currentDay = parseInt(currentParams.day, 10);
|
||||
|
||||
const isViewChanged = currentParams.view !== view;
|
||||
|
||||
// For view changes, always preserve the current URL parameters
|
||||
if (isViewChanged) {
|
||||
this._isViewChanging = true;
|
||||
this.router.replaceWith(
|
||||
this.router.currentRouteName,
|
||||
view,
|
||||
currentYear,
|
||||
currentMonth,
|
||||
currentDay
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip navigation logic immediately after a view change
|
||||
if (this._isViewChanging) {
|
||||
this._isViewChanging = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
year: urlYear,
|
||||
month: urlMonth,
|
||||
day: urlDay,
|
||||
} = this.#calculateUrlParams(
|
||||
view,
|
||||
info.view,
|
||||
currentYear,
|
||||
currentMonth,
|
||||
currentDay,
|
||||
isViewChanged
|
||||
);
|
||||
|
||||
const isMonthChanged =
|
||||
view === "month" &&
|
||||
(currentYear !== urlYear || currentMonth !== urlMonth);
|
||||
const isDayChanged =
|
||||
view !== "month" &&
|
||||
(currentYear !== urlYear ||
|
||||
currentMonth !== urlMonth ||
|
||||
currentDay !== urlDay);
|
||||
|
||||
// Prevent URL changes during calendar initialization
|
||||
if (this._isInitializing) {
|
||||
this._isInitializing = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (isViewChanged || isMonthChanged || isDayChanged) {
|
||||
this.router.replaceWith(
|
||||
this.router.currentRouteName,
|
||||
view,
|
||||
urlYear,
|
||||
urlMonth,
|
||||
urlDay
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#calculateUrlParams(
|
||||
view,
|
||||
calendarView,
|
||||
currentYear,
|
||||
currentMonth,
|
||||
currentDay,
|
||||
isViewChanged = false
|
||||
) {
|
||||
const viewStart = moment(calendarView.currentStart);
|
||||
const viewEnd = moment(calendarView.currentEnd);
|
||||
const currentParams = this.router.currentRoute.params;
|
||||
|
||||
// For view changes, preserve the current date from URL
|
||||
if (isViewChanged) {
|
||||
return {
|
||||
year: currentYear,
|
||||
month: currentMonth,
|
||||
day: parseInt(currentParams.day, 10),
|
||||
};
|
||||
}
|
||||
|
||||
if (view === "month") {
|
||||
const startYear = viewStart.year();
|
||||
const startMonth = viewStart.month() + 1;
|
||||
|
||||
if (
|
||||
this.#isSequentialMonthNavigation(
|
||||
currentYear,
|
||||
currentMonth,
|
||||
startYear,
|
||||
startMonth
|
||||
)
|
||||
) {
|
||||
return { year: startYear, month: startMonth, day: 1 };
|
||||
} else if (
|
||||
this.#isTodayNavigation(currentParams, startYear, startMonth)
|
||||
) {
|
||||
return { year: startYear, month: startMonth, day: moment().date() };
|
||||
} else {
|
||||
const viewMiddle = moment(
|
||||
(viewStart.valueOf() + viewEnd.valueOf()) / 2
|
||||
);
|
||||
return {
|
||||
year: viewMiddle.year(),
|
||||
month: viewMiddle.month() + 1,
|
||||
day: viewMiddle.date(),
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// For view changes, preserve the current date from URL
|
||||
if (isViewChanged) {
|
||||
return {
|
||||
year: currentYear,
|
||||
month: currentMonth,
|
||||
day: currentDay,
|
||||
};
|
||||
}
|
||||
|
||||
// For navigation (next/prev/today), calculate based on the calendar view's current date
|
||||
const viewDate = moment(calendarView.currentStart);
|
||||
|
||||
return {
|
||||
year: viewDate.year(),
|
||||
month: viewDate.month() + 1,
|
||||
day: viewDate.date(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#isSequentialMonthNavigation(currentYear, currentMonth, newYear, newMonth) {
|
||||
if (newYear === currentYear && newMonth === currentMonth + 1) {
|
||||
return true;
|
||||
}
|
||||
if (newYear === currentYear && newMonth === currentMonth - 1) {
|
||||
return true;
|
||||
}
|
||||
if (newYear === currentYear + 1 && newMonth === 1 && currentMonth === 12) {
|
||||
return true;
|
||||
}
|
||||
if (newYear === currentYear - 1 && newMonth === 12 && currentMonth === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
#isTodayNavigation(currentParams, newYear, newMonth) {
|
||||
const today = moment();
|
||||
return (
|
||||
newYear === today.year() &&
|
||||
newMonth === today.month() + 1 &&
|
||||
(currentParams.year !== newYear || currentParams.month !== newMonth)
|
||||
);
|
||||
}
|
||||
|
||||
@action
|
||||
applyCustomButtonsState() {
|
||||
schedule("afterRender", () => {
|
||||
if (this.args.mine) {
|
||||
document
|
||||
.querySelector(".fc-mineEvents-button")
|
||||
.classList.add("fc-button-active");
|
||||
document
|
||||
.querySelector(".fc-allEvents-button")
|
||||
.classList.remove("fc-button-active");
|
||||
} else {
|
||||
document
|
||||
.querySelector(".fc-allEvents-button")
|
||||
.classList.add("fc-button-active");
|
||||
document
|
||||
.querySelector(".fc-mineEvents-button")
|
||||
.classList.remove("fc-button-active");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
<template>
|
||||
{{#if this.displayFilters}}
|
||||
<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>
|
||||
<div id="upcoming-events-calendar">
|
||||
<FullCalendar
|
||||
@initialDate={{@initialDate}}
|
||||
@onDatesChange={{this.onDatesChange}}
|
||||
@events={{this.events}}
|
||||
@initialView={{@initialView}}
|
||||
@customButtons={{this.customButtons}}
|
||||
@leftHeaderToolbar={{this.leftHeaderToolbar}}
|
||||
@centerHeaderToolbar={{this.centerHeaderToolbar}}
|
||||
@rightHeaderToolbar={{this.rightHeaderToolbar}}
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import Component from "@glimmer/component";
|
||||
import CategoryCalendar from "../../components/category-calendar";
|
||||
|
||||
export default class CategoryEventsCalendar extends Component {
|
||||
static shouldRender(_, ctx) {
|
||||
|
@ -9,6 +10,6 @@ export default class CategoryEventsCalendar extends Component {
|
|||
}
|
||||
|
||||
<template>
|
||||
<div id="category-events-calendar"></div>
|
||||
<div id="category-events-calendar"><CategoryCalendar /></div>
|
||||
</template>
|
||||
}
|
||||
|
|
|
@ -1,5 +1,18 @@
|
|||
import Controller from "@ember/controller";
|
||||
import { service } from "@ember/service";
|
||||
|
||||
export default class DiscoursePostEventUpcomingEventsIndexController extends Controller {
|
||||
queryParams = ["view"];
|
||||
@service router;
|
||||
|
||||
get initialView() {
|
||||
return this.model.view;
|
||||
}
|
||||
|
||||
get initialDate() {
|
||||
return this.model.initialDate;
|
||||
}
|
||||
|
||||
get events() {
|
||||
return this.model.events;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,18 @@
|
|||
import Controller from "@ember/controller";
|
||||
import { service } from "@ember/service";
|
||||
|
||||
export default class DiscoursePostEventUpcomingEventsMineController extends Controller {
|
||||
queryParams = ["view"];
|
||||
@service router;
|
||||
|
||||
get initialView() {
|
||||
return this.model.view;
|
||||
}
|
||||
|
||||
get initialDate() {
|
||||
return this.model.initialDate;
|
||||
}
|
||||
|
||||
get events() {
|
||||
return this.model.events;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,8 +3,10 @@ export default function () {
|
|||
"discourse-post-event-upcoming-events",
|
||||
{ path: "/upcoming-events" },
|
||||
function () {
|
||||
this.route("index", { path: "/" });
|
||||
this.route("mine");
|
||||
this.route("index", { path: "/:view/:year/:month/:day" });
|
||||
this.route("default_index", { path: "/" });
|
||||
this.route("mine", { path: "/mine/:view/:year/:month/:day" });
|
||||
this.route("default_mine", { path: "/mine" });
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,209 @@
|
|||
import { withPluginApi } from "discourse/lib/plugin-api";
|
||||
import { i18n } from "discourse-i18n";
|
||||
import PostCalendar from "../components/post-calendar";
|
||||
|
||||
function initializeDiscourseCalendar(api) {
|
||||
const postCalendar = api.container.lookup("service:post-calendar");
|
||||
|
||||
api.decorateCookedElement(
|
||||
(element, helper) => {
|
||||
const calendar = element.querySelector(".calendar");
|
||||
|
||||
if (!calendar) {
|
||||
return;
|
||||
}
|
||||
|
||||
// header is now generated in the component
|
||||
// remove the old header generated when cooking if it exists
|
||||
element.querySelector(".discourse-calendar-header")?.remove?.();
|
||||
|
||||
const post = helper.getModel();
|
||||
const options = calendar.dataset;
|
||||
const staticEvents = parseStaticDates(calendar, post);
|
||||
|
||||
calendar.innerHTML = "";
|
||||
|
||||
helper.renderGlimmer(
|
||||
calendar,
|
||||
<template>
|
||||
<PostCalendar
|
||||
@post={{@data.post}}
|
||||
@options={{@data.options}}
|
||||
@staticEvents={{@data.staticEvents}}
|
||||
@height="650px"
|
||||
/>
|
||||
</template>,
|
||||
{
|
||||
options,
|
||||
post,
|
||||
staticEvents,
|
||||
}
|
||||
);
|
||||
},
|
||||
{
|
||||
onlyStream: true,
|
||||
id: "discourse-calendar",
|
||||
}
|
||||
);
|
||||
|
||||
function parseStaticDates(calendar) {
|
||||
const events = [];
|
||||
const paragraph = calendar.querySelector(":scope > p");
|
||||
paragraph?.innerHTML?.split("<br>")?.forEach((line) => {
|
||||
const tempDiv = document.createElement("div");
|
||||
tempDiv.innerHTML = line;
|
||||
const html = Array.from(tempDiv.childNodes);
|
||||
|
||||
const dates = html.filter(
|
||||
(h) => h.nodeType === 1 && h.classList.contains("discourse-local-date")
|
||||
);
|
||||
const title = html[0] ? html[0].textContent.trim() : "";
|
||||
|
||||
const from = convertHtmlToDate(dates[0]);
|
||||
|
||||
let to;
|
||||
if (dates[1]) {
|
||||
to = convertHtmlToDate(dates[1]);
|
||||
}
|
||||
let event = buildEventObject(from, to);
|
||||
event.title = title;
|
||||
events.push(event);
|
||||
});
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
api.registerCustomPostMessageCallback("calendar_change", () => {
|
||||
postCalendar.refresh();
|
||||
});
|
||||
|
||||
if (api.registerNotificationTypeRenderer) {
|
||||
api.registerNotificationTypeRenderer(
|
||||
"event_reminder",
|
||||
(NotificationTypeBase) => {
|
||||
return class extends NotificationTypeBase {
|
||||
get linkTitle() {
|
||||
if (this.notification.data.title) {
|
||||
return i18n(this.notification.data.title);
|
||||
} else {
|
||||
return super.linkTitle;
|
||||
}
|
||||
}
|
||||
|
||||
get icon() {
|
||||
return "calendar-day";
|
||||
}
|
||||
|
||||
get label() {
|
||||
return i18n(this.notification.data.message);
|
||||
}
|
||||
|
||||
get description() {
|
||||
return this.notification.data.topic_title;
|
||||
}
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
api.registerNotificationTypeRenderer(
|
||||
"event_invitation",
|
||||
(NotificationTypeBase) => {
|
||||
return class extends NotificationTypeBase {
|
||||
get icon() {
|
||||
return "calendar-day";
|
||||
}
|
||||
|
||||
get label() {
|
||||
if (
|
||||
this.notification.data.message ===
|
||||
"discourse_post_event.notifications.invite_user_predefined_attendance_notification"
|
||||
) {
|
||||
return i18n(this.notification.data.message, {
|
||||
username: this.username,
|
||||
eventName:
|
||||
this.notification.data.event_name ||
|
||||
i18n("discourse_post_event.notifications.an_event"),
|
||||
});
|
||||
}
|
||||
return super.label;
|
||||
}
|
||||
|
||||
get description() {
|
||||
return this.notification.data.topic_title;
|
||||
}
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function convertHtmlToDate(html) {
|
||||
const date = html.dataset.date;
|
||||
|
||||
if (!date) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const time = html.dataset.time;
|
||||
const timezone = html.dataset.timezone;
|
||||
let dateTime = date;
|
||||
if (time) {
|
||||
dateTime = `${dateTime} ${time}`;
|
||||
}
|
||||
|
||||
return {
|
||||
weeklyRecurring: html.dataset.recurring === "1.weeks",
|
||||
dateTime: moment.tz(dateTime, timezone || "Etc/UTC"),
|
||||
};
|
||||
}
|
||||
|
||||
function buildEventObject(from, to) {
|
||||
const hasTimeSpecified = (d) => {
|
||||
if (!d) {
|
||||
return false;
|
||||
}
|
||||
return d.hours() !== 0 || d.minutes() !== 0 || d.seconds() !== 0;
|
||||
};
|
||||
|
||||
const hasTime =
|
||||
hasTimeSpecified(to?.dateTime) || hasTimeSpecified(from?.dateTime);
|
||||
const dateFormat = hasTime ? "YYYY-MM-DD HH:mm:ssZ" : "YYYY-MM-DD";
|
||||
|
||||
let event = {
|
||||
start: from.dateTime.format(dateFormat),
|
||||
allDay: false,
|
||||
};
|
||||
|
||||
if (to) {
|
||||
if (hasTime) {
|
||||
event.end = to.dateTime.format(dateFormat);
|
||||
} else {
|
||||
event.end = to.dateTime.add(1, "days").format(dateFormat);
|
||||
event.allDay = true;
|
||||
}
|
||||
} else {
|
||||
event.allDay = true;
|
||||
}
|
||||
|
||||
if (from.weeklyRecurring) {
|
||||
event.startTime = {
|
||||
hours: from.dateTime.hours(),
|
||||
minutes: from.dateTime.minutes(),
|
||||
seconds: from.dateTime.seconds(),
|
||||
};
|
||||
event.daysOfWeek = [from.dateTime.day()];
|
||||
}
|
||||
|
||||
return event;
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
name: "discourse-calendar",
|
||||
|
||||
initialize(container) {
|
||||
const siteSettings = container.lookup("service:site-settings");
|
||||
if (siteSettings.calendar_enabled) {
|
||||
withPluginApi(initializeDiscourseCalendar);
|
||||
}
|
||||
},
|
||||
};
|
File diff suppressed because it is too large
Load diff
|
@ -1,28 +0,0 @@
|
|||
/* eslint-disable no-console */
|
||||
import DiscoursePostEventEvent from "../models/discourse-post-event-event";
|
||||
|
||||
export default function addRecurrentEvents(events) {
|
||||
try {
|
||||
return events.flatMap((event) => {
|
||||
if (!event.upcomingDates?.length) {
|
||||
return [event];
|
||||
}
|
||||
|
||||
const upcomingEvents =
|
||||
event.upcomingDates?.map((upcomingDate) =>
|
||||
DiscoursePostEventEvent.create({
|
||||
name: event.name,
|
||||
post: event.post,
|
||||
category_id: event.categoryId,
|
||||
starts_at: upcomingDate.starts_at,
|
||||
ends_at: upcomingDate.ends_at,
|
||||
})
|
||||
) || [];
|
||||
|
||||
return upcomingEvents;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to retrieve events:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
|
@ -10,6 +10,6 @@ export function getCalendarButtonsText() {
|
|||
month: i18n("discourse_calendar.toolbar_button.month"),
|
||||
week: i18n("discourse_calendar.toolbar_button.week"),
|
||||
day: i18n("discourse_calendar.toolbar_button.day"),
|
||||
list: i18n("discourse_calendar.toolbar_button.list"),
|
||||
list: i18n("discourse_calendar.toolbar_button.year"),
|
||||
};
|
||||
}
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
// Helper functions for normalizing calendar view types between route params and FullCalendar
|
||||
|
||||
// Maps route view names to FullCalendar view names
|
||||
export function normalizeViewForCalendar(routeView) {
|
||||
const viewMap = {
|
||||
agendaDay: "timeGridDay",
|
||||
agendaWeek: "timeGridWeek",
|
||||
month: "dayGridMonth",
|
||||
listNextYear: "listYear",
|
||||
year: "listYear",
|
||||
week: "timeGridWeek",
|
||||
day: "timeGridDay",
|
||||
};
|
||||
|
||||
return viewMap[routeView] || routeView;
|
||||
}
|
||||
|
||||
// Maps FullCalendar view names back to route view names
|
||||
export function normalizeViewForRoute(calendarView) {
|
||||
const viewMap = {
|
||||
timeGridDay: "day",
|
||||
timeGridWeek: "week",
|
||||
dayGridMonth: "month",
|
||||
listYear: "year",
|
||||
};
|
||||
|
||||
return viewMap[calendarView] || calendarView;
|
||||
}
|
|
@ -5,22 +5,6 @@ const calendarRule = {
|
|||
let wrapperDivToken = state.push("div_calendar_wrap", "div", 1);
|
||||
wrapperDivToken.attrs = [["class", "discourse-calendar-wrap"]];
|
||||
|
||||
let headerDivToken = state.push("div_calendar_header", "div", 1);
|
||||
headerDivToken.attrs = [["class", "discourse-calendar-header"]];
|
||||
|
||||
let titleH2Token = state.push("h2_open", "h2", 1);
|
||||
titleH2Token.attrs = [["class", "discourse-calendar-title"]];
|
||||
state.push("h2_close", "h2", -1);
|
||||
|
||||
let timezoneWrapToken = state.push("span_open", "span", 1);
|
||||
timezoneWrapToken.attrs = [["class", "discourse-calendar-timezone-wrap"]];
|
||||
if (info.attrs.tzPicker === "true") {
|
||||
_renderTimezonePicker(state);
|
||||
}
|
||||
state.push("span_close", "span", -1);
|
||||
|
||||
state.push("div_calendar_header", "div", -1);
|
||||
|
||||
let mainCalendarDivToken = state.push("div_calendar", "div", 1);
|
||||
mainCalendarDivToken.attrs = [
|
||||
["class", "calendar"],
|
||||
|
@ -84,17 +68,9 @@ const groupTimezoneRule = {
|
|||
},
|
||||
};
|
||||
|
||||
function _renderTimezonePicker(state) {
|
||||
const timezoneSelectToken = state.push("select_open", "select", 1);
|
||||
timezoneSelectToken.attrs = [["class", "discourse-calendar-timezone-picker"]];
|
||||
|
||||
state.push("select_close", "select", -1);
|
||||
}
|
||||
|
||||
export function setup(helper) {
|
||||
helper.allowList([
|
||||
"div.calendar",
|
||||
"div.discourse-calendar-header",
|
||||
"div.discourse-calendar-wrap",
|
||||
"select.discourse-calendar-timezone-picker",
|
||||
"span.discourse-calendar-timezone-wrap",
|
||||
|
|
|
@ -1,25 +1,11 @@
|
|||
import { escape } from "pretty-text/sanitizer";
|
||||
import {
|
||||
getCalendarButtonsText,
|
||||
getCurrentBcp47Locale,
|
||||
} from "./calendar-locale";
|
||||
import { buildPopover, destroyPopover } from "./popover";
|
||||
|
||||
export default function fullCalendarDefaultOptions() {
|
||||
return {
|
||||
eventClick: function () {
|
||||
destroyPopover();
|
||||
},
|
||||
locale: getCurrentBcp47Locale(),
|
||||
buttonText: getCalendarButtonsText(),
|
||||
eventMouseEnter: function ({ event, jsEvent }) {
|
||||
destroyPopover();
|
||||
|
||||
const htmlContent = escape(event.title);
|
||||
buildPopover(jsEvent, htmlContent);
|
||||
},
|
||||
eventMouseLeave: function () {
|
||||
destroyPopover();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -24,6 +24,7 @@ export default class DiscoursePostEventEvent {
|
|||
}
|
||||
|
||||
@tracked title;
|
||||
@tracked rrule;
|
||||
@tracked name;
|
||||
@tracked categoryId;
|
||||
@tracked startsAt;
|
||||
|
@ -46,7 +47,6 @@ export default class DiscoursePostEventEvent {
|
|||
@tracked isStandalone;
|
||||
@tracked recurrenceUntil;
|
||||
@tracked recurrence;
|
||||
@tracked recurrenceRule;
|
||||
@tracked customFields;
|
||||
@tracked channel;
|
||||
|
||||
|
@ -58,9 +58,9 @@ export default class DiscoursePostEventEvent {
|
|||
|
||||
constructor(args = {}) {
|
||||
this.id = args.id;
|
||||
this.rrule = args.rrule;
|
||||
this.name = args.name;
|
||||
this.categoryId = args.category_id;
|
||||
this.upcomingDates = args.upcoming_dates;
|
||||
this.startsAt = args.starts_at;
|
||||
this.endsAt = args.ends_at;
|
||||
this.rawInvitees = args.raw_invitees;
|
||||
|
@ -78,7 +78,6 @@ export default class DiscoursePostEventEvent {
|
|||
this.isStandalone = args.is_standalone;
|
||||
this.minimal = args.minimal;
|
||||
this.chatEnabled = args.chat_enabled;
|
||||
this.recurrenceRule = args.recurrence_rule;
|
||||
this.recurrence = args.recurrence;
|
||||
this.recurrenceUntil = args.recurrence_until;
|
||||
this.canUpdateAttendance = args.can_update_attendance;
|
||||
|
@ -161,7 +160,7 @@ export default class DiscoursePostEventEvent {
|
|||
this.isStandalone = event.isStandalone;
|
||||
this.minimal = event.minimal;
|
||||
this.chatEnabled = event.chatEnabled;
|
||||
this.recurrenceRule = event.recurrenceRule;
|
||||
this.rrule = event.rrule;
|
||||
this.recurrence = event.recurrence;
|
||||
this.recurrenceUntil = event.recurrenceUntil;
|
||||
this.canUpdateAttendance = event.canUpdateAttendance;
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
import { service } from "@ember/service";
|
||||
import moment from "moment";
|
||||
import DiscourseRoute from "discourse/routes/discourse";
|
||||
|
||||
export default class PostEventUpcomingEventsDefaultIndexRoute extends DiscourseRoute {
|
||||
@service router;
|
||||
|
||||
beforeModel() {
|
||||
const today = moment();
|
||||
const year = today.year();
|
||||
const month = today.month() + 1; // moment months are 0-indexed, but URLs use 1-indexed
|
||||
const day = today.date();
|
||||
|
||||
this.router?.replaceWith?.(
|
||||
"discourse-post-event-upcoming-events.index",
|
||||
"month",
|
||||
year,
|
||||
month,
|
||||
day
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
import { service } from "@ember/service";
|
||||
import moment from "moment";
|
||||
import DiscourseRoute from "discourse/routes/discourse";
|
||||
|
||||
export default class PostEventUpcomingEventsDefaultMineRoute extends DiscourseRoute {
|
||||
@service router;
|
||||
|
||||
beforeModel() {
|
||||
const today = moment();
|
||||
const year = today.year();
|
||||
const month = today.month() + 1; // moment months are 0-indexed, but URLs use 1-indexed
|
||||
const day = today.date();
|
||||
|
||||
this.router?.replaceWith?.(
|
||||
"discourse-post-event-upcoming-events.mine",
|
||||
"month",
|
||||
year,
|
||||
month,
|
||||
day
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
import UpcomingEventsBaseRoute from "./upcoming-events-base-route";
|
||||
|
||||
export default class PostEventUpcomingEventsIndexRoute extends UpcomingEventsBaseRoute {}
|
|
@ -1,19 +0,0 @@
|
|||
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 discoursePostEventService;
|
||||
|
||||
@action
|
||||
activate() {
|
||||
if (!this.siteSettings.discourse_post_event_enabled) {
|
||||
DiscourseURL.redirectTo("/404");
|
||||
}
|
||||
}
|
||||
|
||||
async model(params) {
|
||||
return await this.discoursePostEventService.fetchEvents(params);
|
||||
}
|
||||
}
|
|
@ -1,22 +1,7 @@
|
|||
import { action } from "@ember/object";
|
||||
import { service } from "@ember/service";
|
||||
import DiscourseURL from "discourse/lib/url";
|
||||
import DiscourseRoute from "discourse/routes/discourse";
|
||||
import UpcomingEventsBaseRoute from "./upcoming-events-base-route";
|
||||
|
||||
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);
|
||||
export default class PostEventUpcomingEventsMineRoute extends UpcomingEventsBaseRoute {
|
||||
addRouteSpecificParams(fetchParams) {
|
||||
fetchParams.attending_user = this.currentUser?.username;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,71 @@
|
|||
import { service } from "@ember/service";
|
||||
import moment from "moment";
|
||||
import DiscourseURL from "discourse/lib/url";
|
||||
import DiscourseRoute from "discourse/routes/discourse";
|
||||
|
||||
export default class UpcomingEventsBaseRoute extends DiscourseRoute {
|
||||
@service discoursePostEventService;
|
||||
@service currentUser;
|
||||
|
||||
activate() {
|
||||
if (!this.siteSettings.discourse_post_event_enabled) {
|
||||
DiscourseURL.redirectTo("/404");
|
||||
}
|
||||
}
|
||||
|
||||
async model(params) {
|
||||
let after, before, initialDate;
|
||||
|
||||
if (params.view === "year") {
|
||||
const year = parseInt(params.year, 10);
|
||||
after = moment.utc({ year }).startOf("year").toISOString();
|
||||
before = moment.utc({ year }).endOf("year").toISOString();
|
||||
initialDate = moment.utc({ year }).format("YYYY-MM-DD");
|
||||
} else if (params.view === "month") {
|
||||
const year = parseInt(params.year, 10);
|
||||
const month = parseInt(params.month, 10) - 1; // moment months are 0-indexed
|
||||
|
||||
const date = moment.utc({ year, month });
|
||||
after = date.clone().startOf("month").toISOString();
|
||||
before = date.clone().endOf("month").toISOString();
|
||||
initialDate = moment.utc({ year, month }).format("YYYY-MM-DD");
|
||||
} else if (params.view === "week") {
|
||||
const year = parseInt(params.year, 10);
|
||||
const month = parseInt(params.month, 10) - 1; // moment months are 0-indexed
|
||||
const day = parseInt(params.day, 10);
|
||||
|
||||
const date = moment.utc({ year, month, day });
|
||||
after = date.clone().startOf("week").toISOString();
|
||||
before = date.clone().endOf("week").toISOString();
|
||||
initialDate = moment.utc({ year, month, day }).format("YYYY-MM-DD");
|
||||
} else if (params.view === "day") {
|
||||
const year = parseInt(params.year, 10);
|
||||
const month = parseInt(params.month, 10) - 1; // moment months are 0-indexed
|
||||
const day = parseInt(params.day, 10);
|
||||
|
||||
const date = moment.utc({ year, month, day });
|
||||
after = date.clone().startOf("day").toISOString();
|
||||
before = date.clone().endOf("day").toISOString();
|
||||
initialDate = moment.utc({ year, month, day }).format("YYYY-MM-DD");
|
||||
}
|
||||
|
||||
const fetchParams = {
|
||||
after,
|
||||
before,
|
||||
};
|
||||
|
||||
this.addRouteSpecificParams(fetchParams);
|
||||
|
||||
const events =
|
||||
await this.discoursePostEventService.fetchEvents(fetchParams);
|
||||
|
||||
return {
|
||||
events,
|
||||
initialDate,
|
||||
view: params.view,
|
||||
};
|
||||
}
|
||||
|
||||
// Override in subclasses to add route-specific parameters
|
||||
addRouteSpecificParams() {}
|
||||
}
|
|
@ -11,13 +11,20 @@ import DiscoursePostEventInvitees from "discourse/plugins/discourse-calendar/dis
|
|||
* @implements {@ember/service}
|
||||
*/
|
||||
export default class DiscoursePostEventApi extends Service {
|
||||
eventsPromise = null;
|
||||
|
||||
async event(id) {
|
||||
const result = await this.#getRequest(`/events/${id}`);
|
||||
return DiscoursePostEventEvent.create(result.event);
|
||||
}
|
||||
|
||||
async events(data = {}) {
|
||||
const result = await this.#getRequest("/events", data);
|
||||
if (this.eventsPromise) {
|
||||
this.eventsPromise.abort();
|
||||
}
|
||||
this.eventsPromise = this.#getRequest("/events", data);
|
||||
const result = await this.eventsPromise;
|
||||
this.eventsPromise = null;
|
||||
return result.events.map((e) => DiscoursePostEventEvent.create(e));
|
||||
}
|
||||
|
||||
|
|
|
@ -8,6 +8,7 @@ export default class DiscoursePostEventService extends Service {
|
|||
if (this.siteSettings.include_expired_events_on_calendar) {
|
||||
params.include_expired = true;
|
||||
}
|
||||
|
||||
const events = await this.discoursePostEventApi.events(params);
|
||||
return await events;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
import Service from "@ember/service";
|
||||
|
||||
/**
|
||||
* Discoure post event API service. Provides methods to refresh the current post calendar.
|
||||
*
|
||||
* @module PostCalendar
|
||||
* @implements {@ember/service}
|
||||
*/
|
||||
export default class PostCalendar extends Service {
|
||||
registerComponent(component) {
|
||||
this.component = component;
|
||||
}
|
||||
|
||||
teardownComponent() {
|
||||
this.component = null;
|
||||
}
|
||||
|
||||
refresh() {
|
||||
this.component?.refresh?.();
|
||||
}
|
||||
}
|
|
@ -4,7 +4,11 @@ import UpcomingEventsCalendar from "../components/upcoming-events-calendar";
|
|||
export default RouteTemplate(
|
||||
<template>
|
||||
<div class="discourse-post-event-upcoming-events">
|
||||
<UpcomingEventsCalendar @controller={{@controller}} />
|
||||
<UpcomingEventsCalendar
|
||||
@events={{@controller.events}}
|
||||
@initialView={{@controller.initialView}}
|
||||
@initialDate={{@controller.initialDate}}
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
);
|
||||
|
|
|
@ -4,7 +4,12 @@ import UpcomingEventsCalendar from "../components/upcoming-events-calendar";
|
|||
export default RouteTemplate(
|
||||
<template>
|
||||
<div class="discourse-post-event-upcoming-events">
|
||||
<UpcomingEventsCalendar @controller={{@controller}} />
|
||||
<UpcomingEventsCalendar
|
||||
@events={{@controller.events}}
|
||||
@mine={{true}}
|
||||
@initialView={{@controller.initialView}}
|
||||
@initialDate={{@controller.initialDate}}
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
);
|
||||
|
|
|
@ -12,4 +12,17 @@
|
|||
desaturate(lighten($tertiary, 40%), 20%),
|
||||
darken($tertiary, 10%)
|
||||
)};
|
||||
--fc-border-color: var(--primary-low);
|
||||
--fc-button-text-color: var(--d-button-default-text-color);
|
||||
--fc-button-bg-color: var(--d-button-default-bg-color);
|
||||
--fc-button-border-color: transparent;
|
||||
--fc-button-hover-bg-color: var(--d-button-default-bg-color--hover);
|
||||
--fc-button-hover-border-color: transparent;
|
||||
--fc-button-active-bg-color: var(--tertiary);
|
||||
--fc-button-active-border-color: transparent;
|
||||
--fc-event-border-color: var(--primary-low);
|
||||
--fc-today-bg-color: var(--highlight-medium);
|
||||
--fc-page-bg-color: var(--secondary);
|
||||
--fc-list-event-hover-bg-color: var(--secondary);
|
||||
--fc-neutral-bg-color: var(--primary-low);
|
||||
}
|
||||
|
|
|
@ -1,163 +1,9 @@
|
|||
.discourse-calendar-wrap {
|
||||
margin: 0.5em 0;
|
||||
border: 5px solid var(--primary-low);
|
||||
}
|
||||
|
||||
.category-calendar .calendar {
|
||||
overflow-y: scroll;
|
||||
}
|
||||
@use "lib/viewport";
|
||||
|
||||
.before-topic-list-body-outlet.category-calendar {
|
||||
display: table-caption;
|
||||
}
|
||||
|
||||
.calendar.fc {
|
||||
height: 645px; // Must be fixed to prevent height change on load
|
||||
border: 0;
|
||||
|
||||
&.fc-unthemed {
|
||||
tbody,
|
||||
thead,
|
||||
tr {
|
||||
border: none;
|
||||
|
||||
td.fc-widget-content,
|
||||
td.fc-widget-header {
|
||||
border-left: 0;
|
||||
|
||||
&:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
overflow: hidden;
|
||||
|
||||
.fc-scroller {
|
||||
height: 560px !important;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
|
||||
.fc-basic-view .fc-day-top .fc-day-number {
|
||||
float: left;
|
||||
}
|
||||
|
||||
.fc-bg td.fc-today {
|
||||
background-color: var(--highlight-medium);
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.fc-month-view .fc-widget-content,
|
||||
.fc-basicWeek-view .fc-widget-content,
|
||||
.fc-head-container {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.fc-bg tbody {
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
.fc-header-toolbar {
|
||||
padding: 0.5em 0.5em 0 0.5em;
|
||||
}
|
||||
|
||||
.fc-title {
|
||||
@include ellipsis;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.fc-event-container {
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
.fc-widget-header span {
|
||||
padding: 3px 3px 3px 0.5em;
|
||||
}
|
||||
|
||||
.fc-center {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.fc-button {
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
background: var(--primary-low);
|
||||
text-transform: capitalize;
|
||||
color: var(--primary);
|
||||
text-shadow: none;
|
||||
border: none;
|
||||
padding: 6px 12px;
|
||||
|
||||
&:hover {
|
||||
background: var(--primary-medium);
|
||||
color: var(--secondary);
|
||||
}
|
||||
|
||||
&.fc-state-active {
|
||||
background: var(--tertiary);
|
||||
color: var(--secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.fc-button-group {
|
||||
// margin-right: 0;
|
||||
.fc-button {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.fc-divider,
|
||||
.fc-list-empty,
|
||||
.fc-list-heading td,
|
||||
.fc-popover .fc-header {
|
||||
background: var(--primary-low);
|
||||
}
|
||||
|
||||
.fc-content,
|
||||
.fc-divider,
|
||||
.fc-list-heading td,
|
||||
.fc-list-view,
|
||||
.fc-popover,
|
||||
.fc-row,
|
||||
tbody,
|
||||
td,
|
||||
th,
|
||||
thead {
|
||||
border-color: var(--primary-low);
|
||||
}
|
||||
}
|
||||
|
||||
.fc-event,
|
||||
.fc-event-dot {
|
||||
background-color: var(--tertiary);
|
||||
border: 1px solid transparent;
|
||||
|
||||
.fc-time {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.grouped-event {
|
||||
background-color: var(--primary-low);
|
||||
border: 1px solid var(--primary-low-mid);
|
||||
color: var(--primary);
|
||||
|
||||
.emoji {
|
||||
margin-right: 0.25em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fc-left {
|
||||
.fc-button-group:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.fc-list-item-add-to-calendar {
|
||||
color: var(--tertiary);
|
||||
font-size: var(--font-down-1);
|
||||
}
|
||||
}
|
||||
|
||||
a.holiday {
|
||||
cursor: default;
|
||||
}
|
||||
|
@ -166,37 +12,17 @@ a.holiday {
|
|||
min-width: 15em;
|
||||
}
|
||||
|
||||
.discourse-calendar-header {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-sizing: border-box;
|
||||
padding: 0.5em;
|
||||
border-bottom: 1px solid var(--primary-low);
|
||||
background: var(--primary-very-low);
|
||||
min-height: 60px;
|
||||
|
||||
.discourse-calendar-timezone-picker {
|
||||
font-size: 16px;
|
||||
margin-bottom: 0;
|
||||
max-width: 50vw;
|
||||
}
|
||||
|
||||
h2.discourse-calendar-title {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 700;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.grouped-event {
|
||||
background-color: var(--primary-low);
|
||||
border: 1px solid var(--primary-low-mid);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.group-timezones {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
&[data-size="auto"],
|
||||
&.auto {
|
||||
|
@ -404,30 +230,52 @@ a.holiday {
|
|||
height: 15px;
|
||||
}
|
||||
|
||||
#event-popover {
|
||||
background-color: var(--tertiary-very-low);
|
||||
z-index: z("modal", "tooltip");
|
||||
box-shadow: var(--shadow-dropdown);
|
||||
border-radius: 4px;
|
||||
padding: 0.5em;
|
||||
max-width: min(75vw, 400px);
|
||||
.discourse-post-event-upcoming-events {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
[data-popper-arrow],
|
||||
[data-popper-arrow]::before {
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: inherit;
|
||||
top: -2px;
|
||||
.discourse-calendar-wrap {
|
||||
border: 0;
|
||||
margin-inline: 3px;
|
||||
box-shadow:
|
||||
0 0 0 1px var(--primary-300),
|
||||
0 0 0 3px var(--primary-100);
|
||||
|
||||
// Makes the outer drop shadow radius equal to --d-border-radius
|
||||
border-radius: calc(var(--d-border-radius) - (3px / 2));
|
||||
|
||||
.fc th {
|
||||
font-weight: normal;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
[data-popper-arrow] {
|
||||
visibility: hidden;
|
||||
tr.fc-list-event td {
|
||||
padding: 0.25rem !important;
|
||||
}
|
||||
|
||||
[data-popper-arrow]::before {
|
||||
visibility: visible;
|
||||
content: "";
|
||||
transform: rotate(45deg);
|
||||
.fc td,
|
||||
.fc th {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.post-calendar {
|
||||
.fc .fc-toolbar.fc-header-toolbar {
|
||||
margin: 0;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fk-d-menu.discourse-post-event-more-menu-content {
|
||||
.mobile-view & {
|
||||
z-index: z("modal", "dropdown");
|
||||
}
|
||||
}
|
||||
|
||||
.discourse-post-event-loader {
|
||||
width: 200px;
|
||||
height: 100px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
|
|
@ -1,19 +0,0 @@
|
|||
.discourse-post-event-upcoming-events {
|
||||
height: 100%;
|
||||
|
||||
.upcoming-events-table {
|
||||
width: 100%;
|
||||
|
||||
thead {
|
||||
tr th {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
tbody {
|
||||
tr td {
|
||||
padding: 0.5em;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -30,16 +30,19 @@ $show-interested: inherit;
|
|||
}
|
||||
|
||||
.discourse-post-event-widget {
|
||||
box-shadow: 0 0 0 3px var(--primary-100);
|
||||
border: 1px solid var(--primary-300);
|
||||
display: flex;
|
||||
background: var(--secondary);
|
||||
margin: 5px 0;
|
||||
flex-direction: column;
|
||||
flex: 1 0 auto;
|
||||
max-width: calc(100% - 2px - 6px);
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--d-border-radius);
|
||||
|
||||
.cooked & {
|
||||
box-shadow: 0 0 0 3px var(--primary-100);
|
||||
border: 1px solid var(--primary-300);
|
||||
border-radius: var(--d-border-radius);
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
section:last-child {
|
||||
padding-bottom: 0.75em;
|
||||
|
@ -144,8 +147,6 @@ $show-interested: inherit;
|
|||
margin-right: 0.5rem;
|
||||
|
||||
.name {
|
||||
@include ellipsis;
|
||||
max-width: 45vw;
|
||||
font-size: var(--font-up-2);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
@ -173,6 +174,8 @@ $show-interested: inherit;
|
|||
.username {
|
||||
margin-left: 0.25em;
|
||||
color: var(--primary);
|
||||
|
||||
@include ellipsis;
|
||||
}
|
||||
|
||||
.creators {
|
||||
|
@ -180,9 +183,13 @@ $show-interested: inherit;
|
|||
align-items: center;
|
||||
|
||||
.event-creator {
|
||||
@include ellipsis;
|
||||
|
||||
.topic-invitee-avatar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@include ellipsis;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -322,6 +329,7 @@ $show-interested: inherit;
|
|||
|
||||
.avatar {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.avatar-flair {
|
||||
|
@ -370,6 +378,10 @@ $show-interested: inherit;
|
|||
margin: 0 auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.chat-channel-link {
|
||||
@include ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.event-url {
|
||||
|
@ -413,8 +425,8 @@ $show-interested: inherit;
|
|||
.event-invitees-icon .going {
|
||||
font-size: var(--font-down-3);
|
||||
position: absolute;
|
||||
right: -2px;
|
||||
bottom: -8px;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: var(--secondary);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
|
|
|
@ -0,0 +1,79 @@
|
|||
@use "lib/viewport";
|
||||
|
||||
.fc {
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
|
||||
.fc-button,
|
||||
.fc-button-active:focus {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.fc-button:focus {
|
||||
background: var(--d-button-default-bg-color--hover) !important;
|
||||
color: var(--d-button-default-text-color--hover) !important;
|
||||
}
|
||||
|
||||
.fc-button-active {
|
||||
color: var(--d-button-primary-text-color) !important;
|
||||
}
|
||||
|
||||
.fc-button-active:focus {
|
||||
background: var(--d-button-primary-bg-color--hover) !important;
|
||||
color: var(--d-button-primary-text-color--hover) !important;
|
||||
}
|
||||
|
||||
.fc-col-header-cell {
|
||||
&.fc-day {
|
||||
a {
|
||||
color: var(--primary);
|
||||
text-transform: uppercase;
|
||||
font-size: var(--font-down-2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fc-daygrid-day-frame {
|
||||
.fc-daygrid-day-top {
|
||||
justify-content: center;
|
||||
|
||||
.fc-daygrid-day-number {
|
||||
font-size: var(--font-down-2);
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fc-header-toolbar {
|
||||
.fc-button,
|
||||
.fc-toolbar-title {
|
||||
font-size: var(--font-down-1) !important;
|
||||
}
|
||||
}
|
||||
|
||||
#upcoming-events-calendar {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@include viewport.until(md) {
|
||||
.fc-header-toolbar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.fc-toolbar-chunk {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.post-calendar {
|
||||
.fc-toolbar-chunk:nth-child(3) {
|
||||
.fc-button-group {
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,129 +0,0 @@
|
|||
#upcoming-events-calendar,
|
||||
#category-events-calendar {
|
||||
&.fc-unthemed {
|
||||
tbody,
|
||||
thead,
|
||||
tr {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.fc-basic-view .fc-day-top .fc-day-number {
|
||||
float: left;
|
||||
}
|
||||
|
||||
.fc-bg td.fc-today {
|
||||
background-color: var(--highlight-medium);
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.fc-month-view .fc-widget-content,
|
||||
.fc-basicWeek-view .fc-widget-content,
|
||||
.fc-head-container {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.fc-bg tbody {
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
.fc-header-toolbar {
|
||||
margin: 1em 0 0.5em 0;
|
||||
}
|
||||
|
||||
.fc-title {
|
||||
@include ellipsis;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.fc-widget-header span {
|
||||
padding: 3px 3px 3px 0.5em;
|
||||
}
|
||||
|
||||
.fc-button {
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
background: var(--primary-low);
|
||||
text-transform: capitalize;
|
||||
color: var(--primary);
|
||||
text-shadow: none;
|
||||
border: none;
|
||||
padding: 6px 12px;
|
||||
|
||||
&:hover {
|
||||
background: var(--primary-medium);
|
||||
color: var(--secondary);
|
||||
}
|
||||
|
||||
&.fc-state-active {
|
||||
background: var(--tertiary);
|
||||
color: var(--secondary);
|
||||
}
|
||||
margin: 0.3em 0 0.3em 0.5em;
|
||||
}
|
||||
|
||||
.fc-button-group {
|
||||
margin: 0.3em 0 0.3em 0.5em;
|
||||
|
||||
// margin-right: 0;
|
||||
.fc-button {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.fc-divider,
|
||||
.fc-list-empty,
|
||||
.fc-list-heading td,
|
||||
.fc-popover .fc-header {
|
||||
background: var(--primary-low);
|
||||
}
|
||||
|
||||
.fc-content,
|
||||
.fc-divider,
|
||||
.fc-list-heading td,
|
||||
.fc-list-view,
|
||||
.fc-popover,
|
||||
.fc-row,
|
||||
tbody,
|
||||
td,
|
||||
th,
|
||||
thead {
|
||||
border-color: var(--primary-low);
|
||||
}
|
||||
}
|
||||
|
||||
.fc-event,
|
||||
.fc-event-dot {
|
||||
color: var(--secondary);
|
||||
background-color: var(--tertiary);
|
||||
border: 1px solid transparent;
|
||||
|
||||
.fc-time {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.grouped-event {
|
||||
background: var(--secondary);
|
||||
border: 1px solid var(--primary-low-mid);
|
||||
color: var(--primary);
|
||||
|
||||
.emoji {
|
||||
margin-right: 0.25em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fc-past-event {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.fc-left {
|
||||
.fc-button-group:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.fc-list-item-add-to-calendar {
|
||||
color: var(--tertiary);
|
||||
font-size: var(--font-down-1);
|
||||
}
|
||||
}
|
|
@ -1,14 +0,0 @@
|
|||
.calendar.fc {
|
||||
table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.fc-list-item-add-to-calendar {
|
||||
float: right;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.fc-list-item:hover td {
|
||||
background: var(--highlight-medium);
|
||||
}
|
||||
}
|
|
@ -1,37 +1,6 @@
|
|||
.discourse-calendar-wrap {
|
||||
border: 0;
|
||||
|
||||
.discourse-calendar-header {
|
||||
padding: 0;
|
||||
background: none;
|
||||
|
||||
h2.discourse-calendar-title {
|
||||
font-size: var(--font-0);
|
||||
flex-wrap: nowrap;
|
||||
max-width: 75%;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.discourse-calendar-timezone-picker {
|
||||
max-width: 40vw;
|
||||
}
|
||||
}
|
||||
|
||||
.fc-view-container {
|
||||
.fc-day-header.fc-widget-header {
|
||||
text-align: center;
|
||||
padding: 0.25em;
|
||||
|
||||
span {
|
||||
font-size: var(--font-down-1);
|
||||
text-align: center;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.calendar {
|
||||
padding: 0;
|
||||
|
||||
|
@ -44,3 +13,7 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
.discourse-post-event-loader {
|
||||
width: 100%;
|
||||
}
|
||||
|
|
File diff suppressed because one or more lines are too long
|
@ -308,7 +308,7 @@ en:
|
|||
month: "Month"
|
||||
week: "Week"
|
||||
day: "Day"
|
||||
list: "List"
|
||||
year: "Year"
|
||||
group_timezones:
|
||||
search: "Search..."
|
||||
group_availability: "%{group} availability"
|
||||
|
|
|
@ -29,7 +29,6 @@ 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."
|
||||
map_events_title: "Overwrites 'Events' in the 'Upcoming Events' sidebar title per category."
|
||||
calendar_enabled: "Enable the discourse-calendar plugin. This will add support for a [calendar][/calendar] tag in the first post of a topic."
|
||||
|
|
|
@ -12,7 +12,9 @@ DiscoursePostEvent::Engine.routes.draw do
|
|||
post "/discourse-post-event/events/:event_id/invitees" => "invitees#create"
|
||||
get "/discourse-post-event/events/:post_id/invitees" => "invitees#index"
|
||||
delete "/discourse-post-event/events/:post_id/invitees/:id" => "invitees#destroy"
|
||||
get "/upcoming-events/:view/:year/:month/:day" => "upcoming_events#index"
|
||||
get "/upcoming-events" => "upcoming_events#index"
|
||||
get "/upcoming-events/mine/:view/:year/:month/:day" => "upcoming_events#index"
|
||||
get "/upcoming-events/mine" => "upcoming_events#index"
|
||||
end
|
||||
|
||||
|
|
|
@ -47,18 +47,10 @@ discourse_calendar:
|
|||
default: 2
|
||||
client: true
|
||||
calendar_automatic_holidays_enabled: true
|
||||
enable_timezone_offset_for_calendar_events:
|
||||
default: false
|
||||
client: true
|
||||
hidden: true
|
||||
split_grouped_events_by_timezone_threshold:
|
||||
default: 0
|
||||
client: true
|
||||
hidden: true
|
||||
default_timezone_offset_user_option:
|
||||
default: false
|
||||
client: true
|
||||
hidden: true
|
||||
event_participation_buttons:
|
||||
default: "going|interested|not going"
|
||||
client: true
|
||||
|
@ -113,9 +105,6 @@ discourse_calendar:
|
|||
sidebar_show_upcoming_events:
|
||||
default: true
|
||||
client: true
|
||||
events_max_rows:
|
||||
default: 2
|
||||
client: true
|
||||
map_events_to_color:
|
||||
client: true
|
||||
default: "[]"
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class RemoveUnususedCalendarSiteSettings < ActiveRecord::Migration[8.0]
|
||||
def up
|
||||
execute "DELETE FROM site_settings WHERE name = 'events_max_rows'"
|
||||
execute "DELETE FROM site_settings WHERE name = 'enable_timezone_offset_for_calendar_events'"
|
||||
execute "DELETE FROM site_settings WHERE name = 'default_timezone_offset_user_option'"
|
||||
end
|
||||
|
||||
def down
|
||||
raise ActiveRecord::IrreversibleMigration
|
||||
end
|
||||
end
|
|
@ -53,7 +53,7 @@ module Jobs
|
|||
|
||||
def process_invitee(invitee)
|
||||
if @event.public?
|
||||
users = User.where(username: invitee["identifier"]).pluck(:id)
|
||||
users = User.where(username_lower: invitee["identifier"].downcase).pluck(:id)
|
||||
else
|
||||
group = Group.find_by(name: invitee["identifier"])
|
||||
if group
|
||||
|
|
|
@ -4,86 +4,153 @@ module DiscoursePostEvent
|
|||
class EventFinder
|
||||
def self.search(user, params = {})
|
||||
guardian = Guardian.new(user)
|
||||
topics = listable_topics(guardian)
|
||||
pms = private_messages(user)
|
||||
|
||||
dates_join = <<~SQL
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
finished_at,
|
||||
event_id,
|
||||
starts_at,
|
||||
ROW_NUMBER() OVER (PARTITION BY event_id ORDER BY finished_at DESC NULLS FIRST) as row_num
|
||||
FROM discourse_calendar_post_event_dates
|
||||
) dcped ON dcped.event_id = discourse_post_event_events.id AND dcped.row_num = 1
|
||||
|
||||
SQL
|
||||
events =
|
||||
DiscoursePostEvent::Event
|
||||
.select("discourse_post_event_events.*, dcped.starts_at")
|
||||
.joins(post: :topic)
|
||||
.merge(Post.secured(guardian))
|
||||
.merge(topics.or(pms).distinct)
|
||||
.joins(dates_join)
|
||||
.order("dcped.starts_at ASC")
|
||||
|
||||
include_expired = params[:include_expired].to_s == "true"
|
||||
|
||||
events = events.where("dcped.finished_at IS NULL") unless include_expired
|
||||
|
||||
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
|
||||
|
||||
if params[:category_id].present?
|
||||
if params[:include_subcategories].present?
|
||||
events =
|
||||
events.where(
|
||||
topics: {
|
||||
category_id: Category.subcategory_ids(params[:category_id].to_i),
|
||||
},
|
||||
)
|
||||
else
|
||||
events = events.where(topics: { category_id: params[:category_id].to_i })
|
||||
end
|
||||
end
|
||||
|
||||
events = events.limit(params[:limit].to_i) if params[:limit].present?
|
||||
|
||||
events
|
||||
build_base_query(guardian, user)
|
||||
.then { |query| apply_filters(query, params, guardian, user) }
|
||||
.then { |query| apply_date_filters(query, params) }
|
||||
.then { |query| apply_category_filters(query, params) }
|
||||
.then { |query| apply_ordering(query) }
|
||||
.then { |query| apply_limit(query, params) }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def self.build_base_query(guardian, user)
|
||||
topics = listable_topics(guardian)
|
||||
pms = private_messages(user)
|
||||
|
||||
DiscoursePostEvent::Event
|
||||
.includes(:event_dates, :post, post: :topic)
|
||||
.joins(post: :topic)
|
||||
.merge(Post.secured(guardian))
|
||||
.merge(topics.or(pms))
|
||||
.joins(latest_event_date_join)
|
||||
.select("discourse_post_event_events.*, latest_event_dates.starts_at")
|
||||
.where("latest_event_dates.starts_at IS NOT NULL")
|
||||
.distinct
|
||||
end
|
||||
|
||||
def self.latest_event_date_join
|
||||
<<~SQL
|
||||
LEFT JOIN (
|
||||
SELECT DISTINCT ON (event_id)
|
||||
event_id,
|
||||
starts_at,
|
||||
finished_at
|
||||
FROM discourse_calendar_post_event_dates
|
||||
ORDER BY event_id, finished_at DESC NULLS FIRST, starts_at DESC
|
||||
) latest_event_dates ON latest_event_dates.event_id = discourse_post_event_events.id
|
||||
SQL
|
||||
end
|
||||
|
||||
def self.apply_filters(events, params, guardian, user)
|
||||
events
|
||||
.then { |query| filter_by_expiration(query, params) }
|
||||
.then { |query| filter_by_post_id(query, params) }
|
||||
.then { |query| filter_by_attending_user(query, params, guardian, user) }
|
||||
end
|
||||
|
||||
def self.filter_by_expiration(events, params)
|
||||
return events if params[:include_expired].to_s == "true"
|
||||
events.where("latest_event_dates.finished_at IS NULL")
|
||||
end
|
||||
|
||||
def self.filter_by_post_id(events, params)
|
||||
return events if params[:post_id].blank?
|
||||
events.where(id: params[:post_id])
|
||||
end
|
||||
|
||||
def self.filter_by_attending_user(events, params, guardian, user)
|
||||
return events if params[:attending_user].blank?
|
||||
|
||||
attending_user = User.find_by(username_lower: params[:attending_user].downcase)
|
||||
return events.none if !attending_user
|
||||
|
||||
events =
|
||||
events.joins(:invitees).where(
|
||||
discourse_post_event_invitees: {
|
||||
user_id: attending_user.id,
|
||||
status: DiscoursePostEvent::Invitee.statuses[:going],
|
||||
},
|
||||
)
|
||||
|
||||
guardian.is_admin? ? events : apply_privacy_restrictions(events, user)
|
||||
end
|
||||
|
||||
def self.apply_privacy_restrictions(events, user)
|
||||
private_status = DiscoursePostEvent::Event.statuses[:private]
|
||||
|
||||
# If no user, can only see non-private events
|
||||
return events.where.not(status: private_status) if user.nil?
|
||||
|
||||
events.where(<<~SQL, private_status, private_status, user.id)
|
||||
discourse_post_event_events.status != ? OR (
|
||||
discourse_post_event_events.status = ? AND EXISTS (
|
||||
SELECT 1 FROM discourse_post_event_invitees dpei
|
||||
WHERE dpei.post_id = discourse_post_event_events.id
|
||||
AND dpei.user_id = ?
|
||||
)
|
||||
)
|
||||
SQL
|
||||
end
|
||||
|
||||
def self.apply_date_filters(events, params)
|
||||
events
|
||||
.then { |query| apply_before_date_filter(query, params) }
|
||||
.then { |query| apply_after_date_filter(query, params) }
|
||||
.then { |query| apply_end_date_filter(query, params) }
|
||||
end
|
||||
|
||||
def self.apply_before_date_filter(events, params)
|
||||
return events if params[:before].blank?
|
||||
|
||||
before_date = params[:before].to_datetime
|
||||
events.where("discourse_post_event_events.original_starts_at < ?", before_date)
|
||||
end
|
||||
|
||||
def self.apply_after_date_filter(events, params)
|
||||
return events if params[:after].blank?
|
||||
|
||||
after_date = params[:after].to_datetime
|
||||
events.where(
|
||||
"(discourse_post_event_events.recurrence_until IS NULL OR " \
|
||||
"discourse_post_event_events.recurrence_until >= ?)",
|
||||
after_date,
|
||||
)
|
||||
end
|
||||
|
||||
def self.apply_end_date_filter(events, params)
|
||||
return events if params[:end_date].blank?
|
||||
|
||||
end_date = params[:end_date].to_datetime
|
||||
events.where("discourse_post_event_events.original_starts_at <= ?", end_date)
|
||||
end
|
||||
|
||||
def self.apply_category_filters(events, params)
|
||||
return events if params[:category_id].blank?
|
||||
|
||||
category_id = params[:category_id].to_i
|
||||
category_ids =
|
||||
(
|
||||
if params[:include_subcategories].present?
|
||||
Category.subcategory_ids(category_id)
|
||||
else
|
||||
[category_id]
|
||||
end
|
||||
)
|
||||
|
||||
events.where(topics: { category_id: category_ids })
|
||||
end
|
||||
|
||||
def self.apply_ordering(events)
|
||||
events.order("latest_event_dates.starts_at ASC, discourse_post_event_events.id ASC")
|
||||
end
|
||||
|
||||
def self.apply_limit(events, params)
|
||||
limit = params[:limit]&.to_i || 200
|
||||
events.limit(limit.clamp(1, 200))
|
||||
end
|
||||
|
||||
def self.listable_topics(guardian)
|
||||
Topic.listable_topics.secured(guardian)
|
||||
end
|
||||
|
|
|
@ -21,9 +21,9 @@ class RRuleConfigurator
|
|||
when "every_weekday"
|
||||
"FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR"
|
||||
when "every_two_weeks"
|
||||
"FREQ=WEEKLY;INTERVAL=2;"
|
||||
"FREQ=WEEKLY;INTERVAL=2"
|
||||
when "every_four_weeks"
|
||||
"FREQ=WEEKLY;INTERVAL=4;"
|
||||
"FREQ=WEEKLY;INTERVAL=4"
|
||||
else
|
||||
byday = starts_at.strftime("%A").upcase[0, 2]
|
||||
"FREQ=WEEKLY;BYDAY=#{byday}"
|
||||
|
|
|
@ -8,17 +8,47 @@ class RRuleGenerator
|
|||
timezone: "UTC",
|
||||
max_years: nil,
|
||||
recurrence: "every_week",
|
||||
recurrence_until: nil
|
||||
recurrence_until: nil,
|
||||
dtstart: nil
|
||||
)
|
||||
rrule = generate_hash(RRuleConfigurator.rule(recurrence_until:, recurrence:, starts_at:))
|
||||
rrule = set_mandatory_options(rrule, starts_at)
|
||||
|
||||
::RRule::Rule
|
||||
.new(stringify(rrule), dtstart: starts_at, tzid: timezone)
|
||||
.new(stringify(rrule), dtstart: starts_at, tzid: timezone, dtstart:)
|
||||
.between(Time.current, Time.current + 14.months)
|
||||
.first(RRuleConfigurator.how_many_recurring_events(recurrence:, max_years:))
|
||||
end
|
||||
|
||||
def self.generate_string(
|
||||
starts_at:,
|
||||
timezone: "UTC",
|
||||
max_years: nil,
|
||||
recurrence: "every_week",
|
||||
recurrence_until: nil,
|
||||
dtstart: nil
|
||||
)
|
||||
rrule = generate_hash(RRuleConfigurator.rule(recurrence_until:, recurrence:, starts_at:))
|
||||
rrule = set_mandatory_options(rrule, starts_at)
|
||||
|
||||
# Format as multi-line RFC 5545 format for FullCalendar
|
||||
# Include timezone information in DTSTART to ensure proper interpretation
|
||||
if dtstart
|
||||
if timezone == "UTC"
|
||||
dtstart_line = "DTSTART:#{dtstart.utc.strftime("%Y%m%dT%H%M%SZ")}"
|
||||
else
|
||||
dtstart_line = "DTSTART;TZID=#{timezone}:#{dtstart.strftime("%Y%m%dT%H%M%S")}"
|
||||
end
|
||||
end
|
||||
rrule_line = "RRULE:#{stringify(rrule)}"
|
||||
|
||||
if dtstart_line
|
||||
"#{dtstart_line}\n#{rrule_line}"
|
||||
else
|
||||
rrule_line
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def self.stringify(rrule)
|
||||
|
|
|
@ -14,21 +14,18 @@ require_relative "lib/calendar_settings_validator.rb"
|
|||
|
||||
enabled_site_setting :calendar_enabled
|
||||
|
||||
register_asset "stylesheets/vendor/fullcalendar.min.css"
|
||||
register_asset "stylesheets/common/full-calendar-ext.scss"
|
||||
register_asset "stylesheets/common/discourse-calendar.scss"
|
||||
register_asset "stylesheets/common/discourse-calendar-holidays.scss"
|
||||
register_asset "stylesheets/common/upcoming-events-calendar.scss"
|
||||
register_asset "stylesheets/common/discourse-post-event.scss"
|
||||
register_asset "stylesheets/common/discourse-post-event-preview.scss"
|
||||
register_asset "stylesheets/common/post-event-builder.scss"
|
||||
register_asset "stylesheets/common/discourse-post-event-invitees.scss"
|
||||
register_asset "stylesheets/common/discourse-post-event-upcoming-events.scss"
|
||||
register_asset "stylesheets/common/discourse-post-event-core-ext.scss"
|
||||
register_asset "stylesheets/mobile/discourse-post-event-core-ext.scss", :mobile
|
||||
register_asset "stylesheets/common/discourse-post-event-bulk-invite-modal.scss"
|
||||
register_asset "stylesheets/mobile/discourse-calendar.scss", :mobile
|
||||
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"
|
||||
|
|
File diff suppressed because one or more lines are too long
|
@ -43,14 +43,14 @@ describe RRuleConfigurator do
|
|||
context "with every_two_weeks recurrence" do
|
||||
it "generates the correct rule" do
|
||||
rule = RRuleConfigurator.rule(recurrence: "every_two_weeks", starts_at: time)
|
||||
expect(rule).to eq("FREQ=WEEKLY;INTERVAL=2;")
|
||||
expect(rule).to eq("FREQ=WEEKLY;INTERVAL=2")
|
||||
end
|
||||
end
|
||||
|
||||
context "with every_four_weeks recurrence" do
|
||||
it "generates the correct rule" do
|
||||
rule = RRuleConfigurator.rule(recurrence: "every_four_weeks", starts_at: time)
|
||||
expect(rule).to eq("FREQ=WEEKLY;INTERVAL=4;")
|
||||
expect(rule).to eq("FREQ=WEEKLY;INTERVAL=4")
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -283,26 +283,6 @@ module DiscoursePostEvent
|
|||
)
|
||||
end
|
||||
|
||||
it "includes events' details when param provided" do
|
||||
get "/discourse-post-event/events.json?category_id=#{category.id}&include_subcategories=true&include_details=true"
|
||||
|
||||
expect(response.status).to eq(200)
|
||||
events = response.parsed_body["events"]
|
||||
expect(events.length).to eq(2)
|
||||
expect(events[0].keys).to include(
|
||||
"creator",
|
||||
"sample_invitees",
|
||||
"watching_invitee",
|
||||
"stats",
|
||||
"status",
|
||||
"can_update_attendance",
|
||||
"should_display_invitees",
|
||||
"is_public",
|
||||
"is_private",
|
||||
"is_standalone",
|
||||
)
|
||||
end
|
||||
|
||||
it "includes expired events when param provided" do
|
||||
get "/discourse-post-event/events.json?category_id=#{category.id}&include_subcategories=true&include_expired=true"
|
||||
|
||||
|
|
|
@ -1,182 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
describe DiscoursePostEvent::EventSummarySerializer do
|
||||
before do
|
||||
SiteSetting.calendar_enabled = true
|
||||
SiteSetting.discourse_post_event_enabled = true
|
||||
end
|
||||
|
||||
fab!(:category)
|
||||
fab!(:topic) { Fabricate(:topic, category: category, title: "Topic title :tada:") }
|
||||
fab!(:post) { Fabricate(:post, topic: topic) }
|
||||
fab!(:event) { Fabricate(:event, post: post) }
|
||||
|
||||
it "returns the event summary" do
|
||||
json = described_class.new(event, scope: Guardian.new).as_json
|
||||
summary = json[:event_summary]
|
||||
expect(summary[:starts_at]).to eq(event.starts_at)
|
||||
expect(summary[:ends_at]).to eq(event.ends_at)
|
||||
expect(summary[:timezone]).to eq(event.timezone)
|
||||
expect(summary[:name]).to eq(event.name)
|
||||
expect(summary[:post][:url]).to eq(post.url)
|
||||
expect(summary[:post][:topic][:title]).to eq("Topic title 🎉")
|
||||
expect(summary[:category_id]).to eq(category.id)
|
||||
end
|
||||
|
||||
context "when recurrent event" do
|
||||
before { freeze_time Time.utc(2023, 1, 1, 1, 1) } # Sunday
|
||||
fab!(:post_2) { Fabricate(:post, topic: topic) }
|
||||
let(:every_day_event) do
|
||||
Fabricate(
|
||||
:event,
|
||||
post: post_2,
|
||||
recurrence: "every_day",
|
||||
original_starts_at: "2023-01-01 15:00",
|
||||
original_ends_at: "2023-01-01 16:00",
|
||||
)
|
||||
end
|
||||
let(:every_week_event) do
|
||||
Fabricate(
|
||||
:event,
|
||||
post: post_2,
|
||||
recurrence: "every_week",
|
||||
original_starts_at: "2023-01-01 15:00",
|
||||
original_ends_at: "2023-01-01 16:00",
|
||||
)
|
||||
end
|
||||
let(:every_two_weeks_event) do
|
||||
Fabricate(
|
||||
:event,
|
||||
post: post_2,
|
||||
recurrence: "every_two_weeks",
|
||||
original_starts_at: "2023-01-01 15:00",
|
||||
original_ends_at: "2023-01-01 16:00",
|
||||
)
|
||||
end
|
||||
let(:every_four_weeks_event) do
|
||||
Fabricate(
|
||||
:event,
|
||||
post: post_2,
|
||||
recurrence: "every_four_weeks",
|
||||
original_starts_at: "2023-01-01 15:00",
|
||||
original_ends_at: "2023-01-01 16:00",
|
||||
)
|
||||
end
|
||||
let(:every_month_event) do
|
||||
Fabricate(
|
||||
:event,
|
||||
post: post_2,
|
||||
recurrence: "every_month",
|
||||
original_starts_at: "2023-01-01 15:00",
|
||||
original_ends_at: "2023-01-01 16:00",
|
||||
)
|
||||
end
|
||||
let(:every_weekday_event) do
|
||||
Fabricate(
|
||||
:event,
|
||||
post: post_2,
|
||||
recurrence: "every_weekday",
|
||||
original_starts_at: "2023-01-01 15:00",
|
||||
original_ends_at: "2023-01-01 16:00",
|
||||
)
|
||||
end
|
||||
|
||||
it "returns next dates for the every day event" do
|
||||
json = described_class.new(every_day_event, scope: Guardian.new).as_json
|
||||
expect(json[:event_summary][:upcoming_dates].length).to eq(31)
|
||||
expect(json[:event_summary][:upcoming_dates].last).to eq(
|
||||
{
|
||||
starts_at: "2023-01-31 15:00:00.000000000 +0000",
|
||||
ends_at: "2023-01-31 16:00:00.000000000 +0000",
|
||||
},
|
||||
)
|
||||
end
|
||||
|
||||
it "returns next dates for the every week event" do
|
||||
json = described_class.new(every_week_event, scope: Guardian.new).as_json
|
||||
expect(json[:event_summary][:upcoming_dates].length).to eq(31)
|
||||
expect(json[:event_summary][:upcoming_dates].last).to eq(
|
||||
{
|
||||
starts_at: "2023-07-30 15:00:00.000000000 +0000", # Sunday
|
||||
ends_at: "2023-07-30 16:00:00.000000000 +0000",
|
||||
},
|
||||
)
|
||||
end
|
||||
|
||||
it "returns next dates for the every two weeks event" do
|
||||
json = described_class.new(every_two_weeks_event, scope: Guardian.new).as_json
|
||||
expect(json[:event_summary][:upcoming_dates].length).to eq(26)
|
||||
expect(json[:event_summary][:upcoming_dates].last).to eq(
|
||||
{
|
||||
starts_at: "2023-12-17 15:00:00.000000000 +0000", # Sunday
|
||||
ends_at: "2023-12-17 16:00:00.000000000 +0000",
|
||||
},
|
||||
)
|
||||
end
|
||||
|
||||
it "returns next dates for the every four weeks event" do
|
||||
json = described_class.new(every_four_weeks_event, scope: Guardian.new).as_json
|
||||
expect(json[:event_summary][:upcoming_dates].length).to eq(13)
|
||||
expect(json[:event_summary][:upcoming_dates].last).to eq(
|
||||
{
|
||||
starts_at: "2023-12-03 15:00:00.000000000 +0000", # Sunday
|
||||
ends_at: "2023-12-03 16:00:00.000000000 +0000",
|
||||
},
|
||||
)
|
||||
end
|
||||
|
||||
it "returns next dates for the every weekday event" do
|
||||
json = described_class.new(every_weekday_event, scope: Guardian.new).as_json
|
||||
expect(json[:event_summary][:upcoming_dates].length).to eq(31)
|
||||
expect(json[:event_summary][:upcoming_dates].last).to eq(
|
||||
{
|
||||
starts_at: "2023-02-13 15:00:00.000000000 +0000", # Friday
|
||||
ends_at: "2023-02-13 16:00:00.000000000 +0000",
|
||||
},
|
||||
)
|
||||
end
|
||||
|
||||
it "returns next dates for the every month event" do
|
||||
json = described_class.new(every_month_event, scope: Guardian.new).as_json
|
||||
expect(json[:event_summary][:upcoming_dates].length).to eq(12)
|
||||
expect(json[:event_summary][:upcoming_dates].last).to eq(
|
||||
{
|
||||
starts_at: "2023-12-03 15:00:00.000000000 +0000", # Sunday
|
||||
ends_at: "2023-12-03 16:00:00.000000000 +0000",
|
||||
},
|
||||
)
|
||||
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
|
|
@ -10,22 +10,34 @@ describe "Category calendar", type: :system do
|
|||
SiteSetting.calendar_enabled = true
|
||||
SiteSetting.discourse_post_event_enabled = true
|
||||
SiteSetting.events_calendar_categories = category.id.to_s
|
||||
|
||||
PostCreator.create!(
|
||||
admin,
|
||||
title: "Sell a boat party",
|
||||
category: category.id,
|
||||
raw: "[event start=\"#{Time.now.iso8601}\"]\n[/event]",
|
||||
)
|
||||
|
||||
sign_in(admin)
|
||||
end
|
||||
|
||||
it "shows the calendar on the category page" do
|
||||
category_page.visit(category)
|
||||
|
||||
expect(category_page).to have_selector("#category-events-calendar.fc")
|
||||
expect(category_page).to have_selector("#category-events-calendar .fc")
|
||||
expect(category_page).to have_css(
|
||||
".fc-daygrid-event-harness .fc-event-title",
|
||||
text: "Sell a boat party",
|
||||
)
|
||||
|
||||
find(".nav-item_hot").click
|
||||
|
||||
expect(page).to have_current_path("#{category.relative_url}/l/hot")
|
||||
expect(category_page).to have_selector("#category-events-calendar.fc")
|
||||
expect(category_page).to have_selector("#category-events-calendar .fc")
|
||||
|
||||
find(".nav-item_latest").click
|
||||
|
||||
expect(page).to have_current_path("#{category.relative_url}/l/latest")
|
||||
expect(category_page).to have_selector("#category-events-calendar.fc")
|
||||
expect(category_page).to have_selector("#category-events-calendar .fc")
|
||||
end
|
||||
end
|
||||
|
|
|
@ -8,8 +8,112 @@ module PageObjects
|
|||
super("/upcoming-events")
|
||||
end
|
||||
|
||||
def open_year_list
|
||||
find(".fc-listNextYear-button").click
|
||||
def next
|
||||
find(".fc-next-button").click
|
||||
end
|
||||
|
||||
def prev
|
||||
find(".fc-prev-button").click
|
||||
end
|
||||
|
||||
def today
|
||||
find(".fc-today-button").click
|
||||
end
|
||||
|
||||
def open_year_view
|
||||
find(".fc-listYear-button").click
|
||||
end
|
||||
|
||||
def open_day_view
|
||||
find(".fc-timeGridDay-button").click
|
||||
end
|
||||
|
||||
def open_week_view
|
||||
find(".fc-timeGridWeek-button").click
|
||||
end
|
||||
|
||||
def open_month_view
|
||||
find(".fc-dayGridMonth-button").click
|
||||
end
|
||||
|
||||
def open_mine_events
|
||||
find(".fc-mineEvents-button").click
|
||||
end
|
||||
|
||||
def open_all_events
|
||||
find(".fc-allEvents-button").click
|
||||
end
|
||||
|
||||
def has_calendar?
|
||||
has_css?("#upcoming-events-calendar .fc")
|
||||
end
|
||||
|
||||
def has_event?(title)
|
||||
has_css?(".fc-event-title", text: title)
|
||||
end
|
||||
|
||||
def has_no_event?(title)
|
||||
has_no_css?(".fc-event-title", text: title)
|
||||
end
|
||||
|
||||
def has_event_count?(count)
|
||||
has_css?(".fc-daygrid-event-harness", count: count)
|
||||
end
|
||||
|
||||
def has_content_in_calendar?(text)
|
||||
has_css?("#upcoming-events-calendar .fc", text: text)
|
||||
end
|
||||
|
||||
def has_event_at_position?(title, row:, col:)
|
||||
has_css?(".fc tr:nth-child(#{row}) td:nth-child(#{col}) .fc-event-title", text: title)
|
||||
end
|
||||
|
||||
def find_event_by_position(position)
|
||||
find(".fc-event:nth-child(#{position})")
|
||||
end
|
||||
|
||||
def event_time_text(event_element)
|
||||
event_element.find(".fc-list-event-time").text
|
||||
end
|
||||
|
||||
def event_title_text(event_element)
|
||||
event_element.find(".fc-list-event-title").text
|
||||
end
|
||||
|
||||
def current_view_title
|
||||
find(".fc-toolbar-title").text
|
||||
end
|
||||
|
||||
def has_current_path?(path)
|
||||
page.has_current_path?(path)
|
||||
end
|
||||
|
||||
def has_content?(text)
|
||||
page.has_content?(text)
|
||||
end
|
||||
|
||||
def expect_to_be_on_path(path)
|
||||
expect(self).to have_current_path(path)
|
||||
end
|
||||
|
||||
def expect_content(text)
|
||||
expect(self).to have_content(text)
|
||||
end
|
||||
|
||||
def expect_event_visible(title)
|
||||
expect(self).to have_event(title)
|
||||
end
|
||||
|
||||
def expect_event_not_visible(title)
|
||||
expect(self).to have_no_event(title)
|
||||
end
|
||||
|
||||
def expect_event_count(count)
|
||||
expect(self).to have_event_count(count)
|
||||
end
|
||||
|
||||
def expect_event_at_position(title, row:, col:)
|
||||
expect(self).to have_event_at_position(title, row: row, col: col)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -207,7 +207,7 @@ describe "Post event", type: :system do
|
|||
find(".d-modal .btn-primary").click
|
||||
composer.submit
|
||||
|
||||
expect(page).to have_css(".discourse-post-event.is-loaded")
|
||||
expect(page).to have_css(".discourse-post-event")
|
||||
|
||||
post_event_page.edit
|
||||
|
||||
|
|
|
@ -15,105 +15,239 @@ describe "Upcoming Events", type: :system do
|
|||
sign_in(admin)
|
||||
end
|
||||
|
||||
context "when user is signed in" do
|
||||
describe "basic functionality" do
|
||||
fab!(:event)
|
||||
|
||||
before { sign_in(admin) }
|
||||
|
||||
it "shows the upcoming events" do
|
||||
it "displays events in the calendar" do
|
||||
upcoming_events.visit
|
||||
|
||||
expect(page).to have_css(
|
||||
"#upcoming-events-calendar .fc-event-container",
|
||||
text: event.post.topic.title,
|
||||
)
|
||||
expect(upcoming_events).to have_content_in_calendar(event.post.topic.title)
|
||||
end
|
||||
end
|
||||
|
||||
context "when display events with showLocalTime" do
|
||||
let(:fixed_time) { Time.utc(2025, 6, 2, 19, 00) }
|
||||
describe "event display and formatting" do
|
||||
describe "local time display" do
|
||||
before do
|
||||
admin.user_option.update!(timezone: "America/New_York")
|
||||
|
||||
before do
|
||||
freeze_time(fixed_time)
|
||||
PostCreator.create!(
|
||||
admin,
|
||||
title: "Event with local time",
|
||||
raw: "[event showLocalTime=true timezone=CET start=\"2025-09-11 08:05\"]\n[/event]",
|
||||
)
|
||||
|
||||
admin.user_option.update!(timezone: "America/New_York")
|
||||
PostCreator.create!(
|
||||
admin,
|
||||
title: "Event without local time",
|
||||
raw: "[event timezone=CET start=\"2025-09-11 19:00\"]\n[/event]",
|
||||
)
|
||||
|
||||
PostCreator.create!(
|
||||
admin,
|
||||
title: "Event with local time",
|
||||
raw: "[event showLocalTime=true timezone=CET start=\"2025-09-11 08:05\"]\n[/event]",
|
||||
)
|
||||
PostCreator.create!(
|
||||
admin,
|
||||
title: "Event with local time and same timezone than user",
|
||||
raw:
|
||||
"[event showLocalTime=true timezone=\"America/New_York\" start=\"2025-09-12 08:05\"]\n[/event]",
|
||||
)
|
||||
end
|
||||
|
||||
PostCreator.create!(
|
||||
admin,
|
||||
title: "Event without local time",
|
||||
raw: "[event timezone=CET start=\"2025-09-11 19:00\"]\n[/event]",
|
||||
)
|
||||
it "shows local time when showLocalTime is enabled",
|
||||
timezone: "Australia/Brisbane",
|
||||
time: Time.utc(2025, 6, 2, 19, 00) do
|
||||
upcoming_events.visit
|
||||
upcoming_events.open_year_view
|
||||
|
||||
PostCreator.create!(
|
||||
admin,
|
||||
title: "Event with local time and same timezone than user",
|
||||
raw:
|
||||
"[event showLocalTime=true timezone=\"America/New_York\" start=\"2025-09-12 08:05\"]\n[/event]",
|
||||
)
|
||||
first_item = upcoming_events.find_event_by_position(2)
|
||||
expect(upcoming_events.event_time_text(first_item)).to include("2:05am")
|
||||
expect(upcoming_events.event_title_text(first_item)).to eq(
|
||||
"Event with local time (Local time: 8:05am)",
|
||||
)
|
||||
|
||||
second_item = upcoming_events.find_event_by_position(3)
|
||||
expect(upcoming_events.event_time_text(second_item)).to include("1:00pm")
|
||||
expect(upcoming_events.event_title_text(second_item)).to eq("Event without local time")
|
||||
|
||||
third_item = upcoming_events.find_event_by_position(5)
|
||||
expect(upcoming_events.event_time_text(third_item)).to include("8:05am")
|
||||
expect(upcoming_events.event_title_text(third_item)).to eq(
|
||||
"Event with local time and same timezone than user",
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
it "shows the local time in the title", timezone: "Australia/Brisbane" do
|
||||
page.driver.with_playwright_page { |pw_page| pw_page.clock.set_fixed_time(fixed_time) }
|
||||
upcoming_events.visit
|
||||
upcoming_events.open_year_list
|
||||
describe "recurring events" do
|
||||
fab!(:event)
|
||||
|
||||
first_item = find(".fc-list-item:nth-child(2)")
|
||||
expect(first_item.find(".fc-list-item-time")).to have_text("2:05am")
|
||||
expect(first_item.find(".fc-list-item-title")).to have_text(
|
||||
"Event with local time (Local time: 8:05am)",
|
||||
)
|
||||
before do
|
||||
event.update!(
|
||||
original_starts_at: Time.utc(2025, 3, 18, 13, 00),
|
||||
timezone: "Australia/Brisbane",
|
||||
recurrence: "every_week",
|
||||
recurrence_until: 21.days.from_now,
|
||||
)
|
||||
end
|
||||
|
||||
second_item = find(".fc-list-item:nth-child(3)")
|
||||
expect(second_item.find(".fc-list-item-time")).to have_text("1:00pm")
|
||||
expect(second_item.find(".fc-list-item-title")).to have_text("Event without local time")
|
||||
it "displays recurring events until the specified end date",
|
||||
time: Time.utc(2025, 6, 2, 19, 00) do
|
||||
upcoming_events.visit
|
||||
|
||||
third_item = find(".fc-list-item:nth-child(5)")
|
||||
expect(third_item.find(".fc-list-item-time")).to have_text("8:05am")
|
||||
expect(third_item.find(".fc-list-item-title")).to have_text(
|
||||
"Event with local time and same timezone than user",
|
||||
)
|
||||
upcoming_events.expect_event_count(4)
|
||||
upcoming_events.expect_event_at_position(event.post.topic.title, row: 2, col: 2)
|
||||
upcoming_events.expect_event_at_position(event.post.topic.title, row: 3, col: 2)
|
||||
upcoming_events.expect_event_at_position(event.post.topic.title, row: 4, col: 2)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "when event is recurring" do
|
||||
fab!(:event)
|
||||
|
||||
let(:fixed_time) { Time.utc(2025, 6, 2, 19, 00) }
|
||||
|
||||
before do
|
||||
freeze_time(fixed_time)
|
||||
|
||||
event.update!(
|
||||
original_starts_at: Time.utc(2025, 3, 18, 13, 00),
|
||||
timezone: "Australia/Brisbane",
|
||||
recurrence: "every_week",
|
||||
recurrence_until: 21.days.from_now,
|
||||
describe "event filtering" do
|
||||
it "shows only events the user is attending when filtered",
|
||||
time: Time.utc(2025, 6, 2, 19, 00) do
|
||||
attending_event =
|
||||
PostCreator.create!(
|
||||
admin,
|
||||
title: "attending post event",
|
||||
raw: "[event status=\"public\" start=\"2025-06-11 08:05\"]\n[/event]",
|
||||
)
|
||||
PostCreator.create!(
|
||||
admin,
|
||||
title: "non attending post event",
|
||||
raw: "[event start=\"2025-06-12 08:05\"]\n[/event]",
|
||||
)
|
||||
DiscoursePostEvent::Event.find(attending_event.id).create_invitees(
|
||||
[{ user_id: admin.id, status: 0 }],
|
||||
)
|
||||
end
|
||||
|
||||
it "respects the until date" do
|
||||
page.driver.with_playwright_page { |pw_page| pw_page.clock.set_fixed_time(fixed_time) }
|
||||
upcoming_events.visit
|
||||
|
||||
expect(page).to have_css(".fc-day-grid-event", count: 3)
|
||||
expect(page).to have_css(
|
||||
".fc-week:nth-child(2) .fc-content-skeleton:nth-child(2)",
|
||||
text: event.post.topic.title,
|
||||
)
|
||||
expect(page).to have_css(
|
||||
".fc-week:nth-child(3) .fc-content-skeleton:nth-child(2)",
|
||||
text: event.post.topic.title,
|
||||
)
|
||||
expect(page).to have_css(
|
||||
".fc-week:nth-child(4) .fc-content-skeleton:nth-child(2)",
|
||||
text: event.post.topic.title,
|
||||
)
|
||||
upcoming_events.expect_event_visible("Attending post event")
|
||||
upcoming_events.expect_event_visible("Non attending post event")
|
||||
|
||||
upcoming_events.open_mine_events
|
||||
|
||||
upcoming_events.expect_event_visible("Attending post event")
|
||||
upcoming_events.expect_event_not_visible("Non attending post event")
|
||||
end
|
||||
end
|
||||
|
||||
describe "calendar navigation and views" do
|
||||
describe "navigation buttons" do
|
||||
describe "today button" do
|
||||
it "navigates to current date", time: Time.utc(2025, 6, 2, 19, 00) do
|
||||
visit("/upcoming-events/month/2025/8/1")
|
||||
|
||||
upcoming_events.today
|
||||
|
||||
upcoming_events.expect_to_be_on_path("/upcoming-events/month/2025/6/2")
|
||||
end
|
||||
|
||||
context "in different timezone", timezone: "Europe/London" do
|
||||
it "navigates to current date in day view", time: Time.utc(2025, 6, 2, 19, 00) do
|
||||
visit("/upcoming-events/day/2025/8/1")
|
||||
|
||||
upcoming_events.today
|
||||
|
||||
upcoming_events.expect_to_be_on_path("/upcoming-events/day/2025/6/2")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "next button" do
|
||||
it "navigates to next month" do
|
||||
visit("/upcoming-events/month/2025/8/1")
|
||||
|
||||
upcoming_events.next
|
||||
|
||||
upcoming_events.expect_to_be_on_path("/upcoming-events/month/2025/9/1")
|
||||
end
|
||||
|
||||
it "navigates to next week" do
|
||||
visit("/upcoming-events/week/2025/8/4")
|
||||
|
||||
upcoming_events.next
|
||||
|
||||
upcoming_events.expect_to_be_on_path("/upcoming-events/week/2025/8/11")
|
||||
end
|
||||
|
||||
context "in different timezone", timezone: "Europe/London" do
|
||||
it "navigates to next day" do
|
||||
visit("/upcoming-events/day/2025/8/4")
|
||||
|
||||
upcoming_events.next
|
||||
|
||||
upcoming_events.expect_to_be_on_path("/upcoming-events/day/2025/8/5")
|
||||
end
|
||||
|
||||
it "navigates to next week" do
|
||||
visit("/upcoming-events/week/2025/8/4")
|
||||
|
||||
upcoming_events.next
|
||||
|
||||
upcoming_events.expect_to_be_on_path("/upcoming-events/week/2025/8/11")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "prev button" do
|
||||
it "navigates to previous day" do
|
||||
visit("/upcoming-events/day/2025/8/1")
|
||||
|
||||
upcoming_events.prev
|
||||
|
||||
upcoming_events.expect_to_be_on_path("/upcoming-events/day/2025/7/31")
|
||||
end
|
||||
|
||||
it "navigates to previous month" do
|
||||
visit("/upcoming-events/month/2025/8/1")
|
||||
|
||||
upcoming_events.prev
|
||||
|
||||
upcoming_events.expect_to_be_on_path("/upcoming-events/month/2025/7/1")
|
||||
end
|
||||
|
||||
it "navigates to previous week" do
|
||||
visit("/upcoming-events/week/2025/8/4")
|
||||
|
||||
upcoming_events.prev
|
||||
|
||||
upcoming_events.expect_to_be_on_path("/upcoming-events/week/2025/7/28")
|
||||
end
|
||||
|
||||
context "in different timezone", timezone: "Europe/London" do
|
||||
it "navigates to previous day" do
|
||||
visit("/upcoming-events/day/2025/8/1")
|
||||
|
||||
upcoming_events.prev
|
||||
|
||||
upcoming_events.expect_to_be_on_path("/upcoming-events/day/2025/7/31")
|
||||
end
|
||||
|
||||
it "navigates to previous month" do
|
||||
visit("/upcoming-events/month/2025/8/1")
|
||||
|
||||
upcoming_events.prev
|
||||
|
||||
upcoming_events.expect_to_be_on_path("/upcoming-events/month/2025/7/1")
|
||||
end
|
||||
|
||||
it "navigates to previous week" do
|
||||
visit("/upcoming-events/week/2025/8/4")
|
||||
|
||||
upcoming_events.prev
|
||||
|
||||
upcoming_events.expect_to_be_on_path("/upcoming-events/week/2025/7/28")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "view switching" do
|
||||
it "switching from month to week view keeps the same day" do
|
||||
visit("/upcoming-events/month/2025/9/16")
|
||||
|
||||
upcoming_events.open_week_view
|
||||
|
||||
upcoming_events.expect_content("Sep 15 – 21, 2025")
|
||||
upcoming_events.expect_to_be_on_path("/upcoming-events/week/2025/9/16")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -50,28 +50,7 @@ acceptance("Discourse Calendar - Category Events Calendar", function (needs) {
|
|||
},
|
||||
},
|
||||
name: "Awesome Event",
|
||||
upcoming_dates: [
|
||||
{
|
||||
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"),
|
||||
},
|
||||
{
|
||||
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"),
|
||||
},
|
||||
],
|
||||
rrule: `DTSTART:${moment().format("YYYYMMDDTHHmmss")}Z\nRRULE:FREQ=DAILY;INTERVAL=1;UNTIL=${moment().add(2, "days").format("YYYYMMDD")}`,
|
||||
},
|
||||
{
|
||||
id: 67502,
|
||||
|
@ -128,7 +107,7 @@ acceptance("Discourse Calendar - Category Events Calendar", function (needs) {
|
|||
await visit("/c/bug/1");
|
||||
|
||||
assert
|
||||
.dom(".fc-event[href='/t/-/18451/1'] .fc-title")
|
||||
.dom(".fc-daygrid-event-harness a[href='/t/-/18451/1'] .fc-event-title")
|
||||
.hasText(
|
||||
"Awesome Event 3<script>alert('my awesome event');</script>",
|
||||
"Elements should be escaped and appear as text rather than be the actual element."
|
||||
|
@ -142,13 +121,21 @@ acceptance("Discourse Calendar - Category Events Calendar", function (needs) {
|
|||
.dom(".fc-event")
|
||||
.exists({ count: 4 }, "Events are displayed on the calendar");
|
||||
|
||||
assert.dom(".fc-event[href='/t/-/18449/1']").hasStyle({
|
||||
"background-color": "rgb(231, 76, 60)",
|
||||
});
|
||||
assert
|
||||
.dom(
|
||||
".fc-daygrid-event-harness a[href='/t/-/18449/1'] .fc-daygrid-event-dot"
|
||||
)
|
||||
.hasStyle({
|
||||
"border-color": "rgb(231, 76, 60)",
|
||||
});
|
||||
|
||||
assert.dom(".fc-event[href='/t/-/18450/1']").hasStyle({
|
||||
"background-color": "rgb(140, 24, 193)",
|
||||
});
|
||||
assert
|
||||
.dom(
|
||||
".fc-daygrid-event-harness a[href='/t/-/18450/1'] .fc-daygrid-event-dot"
|
||||
)
|
||||
.hasStyle({
|
||||
"border-color": "rgb(140, 24, 193)",
|
||||
});
|
||||
});
|
||||
|
||||
test("shows event calendar on category page", async function (assert) {
|
||||
|
@ -157,7 +144,7 @@ acceptance("Discourse Calendar - Category Events Calendar", function (needs) {
|
|||
assert
|
||||
.dom("#category-events-calendar")
|
||||
.exists("Events calendar div exists.");
|
||||
assert.dom(".fc-view-container").exists("FullCalendar is loaded.");
|
||||
assert.dom(".fc").exists("FullCalendar is loaded.");
|
||||
});
|
||||
|
||||
test("uses current locale to display calendar weekday names", async function (assert) {
|
||||
|
@ -166,10 +153,10 @@ acceptance("Discourse Calendar - Category Events Calendar", function (needs) {
|
|||
await visit("/c/bug/1");
|
||||
|
||||
assert.deepEqual(
|
||||
[...document.querySelectorAll(".fc-day-header span")].map(
|
||||
[...document.querySelectorAll(".fc-col-header-cell-cushion")].map(
|
||||
(el) => el.innerText
|
||||
),
|
||||
["seg.", "ter.", "qua.", "qui.", "sex.", "sáb.", "dom."],
|
||||
["SEG.", "TER.", "QUA.", "QUI.", "SEX.", "SÁB.", "DOM."],
|
||||
"Week days are translated in the calendar header"
|
||||
);
|
||||
|
||||
|
@ -179,10 +166,12 @@ acceptance("Discourse Calendar - Category Events Calendar", function (needs) {
|
|||
test("event calendar shows recurrent events", async function (assert) {
|
||||
await visit("/c/bug/1");
|
||||
|
||||
const [first, second] = [...document.querySelectorAll(".fc-event")];
|
||||
const [first, second] = [
|
||||
...document.querySelectorAll(".fc-daygrid-event-harness"),
|
||||
];
|
||||
|
||||
assert.dom(".fc-title", first).hasText("Awesome Event");
|
||||
assert.dom(".fc-title", second).hasText("Awesome Event");
|
||||
assert.dom(".fc-event-title", first).hasText("Awesome Event");
|
||||
assert.dom(".fc-event-title", second).hasText("Awesome Event");
|
||||
|
||||
const firstCell = first.closest("td");
|
||||
const secondCell = second.closest("td");
|
||||
|
|
|
@ -1,27 +0,0 @@
|
|||
import { visit } from "@ember/test-helpers";
|
||||
import { test } from "qunit";
|
||||
import Site from "discourse/models/site";
|
||||
import { acceptance } from "discourse/tests/helpers/qunit-helpers";
|
||||
|
||||
acceptance("Calendar - Disable sorting headers", function (needs) {
|
||||
needs.user();
|
||||
needs.settings({
|
||||
calendar_enabled: true,
|
||||
discourse_post_event_enabled: true,
|
||||
disable_resorting_on_categories_enabled: true,
|
||||
});
|
||||
|
||||
test("visiting a category page", async function (assert) {
|
||||
const site = Site.current();
|
||||
site.categories[15].custom_fields = { disable_topic_resorting: true };
|
||||
|
||||
await visit("/c/bug");
|
||||
assert.dom(".topic-list").exists("The list of topics was rendered");
|
||||
assert
|
||||
.dom(".topic-list .topic-list-data")
|
||||
.exists("The headers were rendered");
|
||||
assert
|
||||
.dom(".topic-list")
|
||||
.doesNotHaveClass("sortable", "The headers are not sortable");
|
||||
});
|
||||
});
|
|
@ -1,138 +0,0 @@
|
|||
import { visit } from "@ember/test-helpers";
|
||||
import { test } from "qunit";
|
||||
import { acceptance, fakeTime } from "discourse/tests/helpers/qunit-helpers";
|
||||
import eventTopicFixture from "../helpers/event-topic-fixture";
|
||||
import getEventByText from "../helpers/get-event-by-text";
|
||||
|
||||
function getRoundedPct(marginString) {
|
||||
return Math.round(marginString.match(/(\d+(\.\d+)?)%/)[1]);
|
||||
}
|
||||
|
||||
function setupClock(needs) {
|
||||
needs.hooks.beforeEach(function () {
|
||||
this.clock = fakeTime("2023-09-10T00:00:00", "Australia/Brisbane", true);
|
||||
});
|
||||
|
||||
needs.hooks.afterEach(function () {
|
||||
this.clock.restore();
|
||||
});
|
||||
}
|
||||
|
||||
acceptance("Discourse Calendar - Timezone Offset", function (needs) {
|
||||
setupClock(needs);
|
||||
|
||||
needs.settings({
|
||||
calendar_enabled: true,
|
||||
enable_timezone_offset_for_calendar_events: true,
|
||||
default_timezone_offset_user_option: true,
|
||||
});
|
||||
|
||||
needs.pretender((server, helper) => {
|
||||
server.get("/t/252.json", () => {
|
||||
return helper.response(eventTopicFixture);
|
||||
});
|
||||
});
|
||||
|
||||
test("doesn't apply an offset for events in the same timezone", async (assert) => {
|
||||
await visit("/t/-/252");
|
||||
|
||||
const eventElement = getEventByText("Lisbon");
|
||||
|
||||
assert.strictEqual(eventElement.style.marginLeft, "");
|
||||
assert.strictEqual(eventElement.style.marginRight, "");
|
||||
});
|
||||
|
||||
test("applies the correct offset for events that extend into the next day", async (assert) => {
|
||||
await visit("/t/-/252");
|
||||
|
||||
const eventElement = getEventByText("Cordoba");
|
||||
|
||||
assert.strictEqual(getRoundedPct(eventElement.style.marginLeft), 8); // ( ( 1 - (-3) ) / 24 ) * 50%
|
||||
assert.strictEqual(getRoundedPct(eventElement.style.marginRight), 42); // ( ( 24 - ( 1 - (-3) ) ) / 24 ) * 50%
|
||||
});
|
||||
|
||||
test("applies the correct offset for events that start on the previous day", async (assert) => {
|
||||
await visit("/t/-/252");
|
||||
|
||||
const eventElement = getEventByText("Tokyo");
|
||||
|
||||
assert.strictEqual(getRoundedPct(eventElement.style.marginLeft), 22); // ( ( 24 - ( 9 - 1 ) ) / 24 ) * 33.33%
|
||||
assert.strictEqual(getRoundedPct(eventElement.style.marginRight), 11); // ( ( 9 - 1 ) / 24 ) * 33.33%
|
||||
});
|
||||
|
||||
test("applies the correct offset for multiline events", async (assert) => {
|
||||
await visit("/t/-/252");
|
||||
|
||||
const eventElement = getEventByText("Moscow");
|
||||
|
||||
assert.strictEqual(getRoundedPct(eventElement[0].style.marginLeft), 46); // ( ( 24 - ( 1 - (-1) ) ) / 24 ) * 50%
|
||||
assert.strictEqual(eventElement[0].style.marginRight, "");
|
||||
|
||||
assert.strictEqual(eventElement[1].style.marginLeft, "");
|
||||
assert.strictEqual(getRoundedPct(eventElement[1].style.marginRight), 8); // ( ( 1 - (-1) ) / 24 ) * 100%
|
||||
});
|
||||
});
|
||||
|
||||
acceptance("Discourse Calendar - Splitted Grouped Events", function (needs) {
|
||||
setupClock(needs);
|
||||
|
||||
needs.settings({
|
||||
calendar_enabled: true,
|
||||
enable_timezone_offset_for_calendar_events: true,
|
||||
default_timezone_offset_user_option: true,
|
||||
split_grouped_events_by_timezone_threshold: 0,
|
||||
});
|
||||
|
||||
needs.pretender((server, helper) => {
|
||||
server.get("/t/252.json", () => {
|
||||
return helper.response(eventTopicFixture);
|
||||
});
|
||||
});
|
||||
|
||||
test("splits holidays events by timezone", async (assert) => {
|
||||
await visit("/t/-/252");
|
||||
|
||||
const eventElement = document.querySelectorAll(
|
||||
".fc-day-grid-event.grouped-event"
|
||||
);
|
||||
assert.strictEqual(eventElement.length, 3);
|
||||
|
||||
assert.strictEqual(getRoundedPct(eventElement[0].style.marginLeft), 13); // ( ( 1 - (-5) ) / 24 ) * 50%
|
||||
assert.strictEqual(getRoundedPct(eventElement[0].style.marginRight), 38); // ( ( 24 - ( 1 - (-5) ) ) / 24 ) * 50%
|
||||
|
||||
assert.strictEqual(getRoundedPct(eventElement[1].style.marginLeft), 15); // ( ( 1 - (-6) ) / 24 ) * 50%
|
||||
assert.strictEqual(getRoundedPct(eventElement[1].style.marginRight), 35); // ( ( 24 - ( 1 - (-6) ) ) / 24 ) * 50%
|
||||
|
||||
assert.strictEqual(getRoundedPct(eventElement[2].style.marginLeft), 17); // ( ( 1 - (-7) ) / 24 ) * 50%
|
||||
assert.strictEqual(getRoundedPct(eventElement[2].style.marginRight), 33); // ( ( 24 - ( 1 - (-7) ) ) / 24 ) * 50%
|
||||
});
|
||||
});
|
||||
|
||||
acceptance("Discourse Calendar - Grouped Events", function (needs) {
|
||||
setupClock(needs);
|
||||
|
||||
needs.settings({
|
||||
calendar_enabled: true,
|
||||
enable_timezone_offset_for_calendar_events: true,
|
||||
default_timezone_offset_user_option: true,
|
||||
split_grouped_events_by_timezone_threshold: 2,
|
||||
});
|
||||
|
||||
needs.pretender((server, helper) => {
|
||||
server.get("/t/252.json", () => {
|
||||
return helper.response(eventTopicFixture);
|
||||
});
|
||||
});
|
||||
|
||||
test("groups holidays events according to threshold", async (assert) => {
|
||||
await visit("/t/-/252");
|
||||
|
||||
const eventElement = document.querySelectorAll(
|
||||
".fc-day-grid-event.grouped-event"
|
||||
);
|
||||
assert.strictEqual(eventElement.length, 1);
|
||||
|
||||
assert.strictEqual(getRoundedPct(eventElement[0].style.marginLeft), 15); // ( ( 1 - (-6) ) / 24 ) * 50%
|
||||
assert.strictEqual(getRoundedPct(eventElement[0].style.marginRight), 35); // ( ( 24 - ( 1 - (-6) ) ) / 24 ) * 50%
|
||||
});
|
||||
});
|
|
@ -43,9 +43,9 @@ acceptance("Discourse Calendar - Topic Calendar Holidays", function (needs) {
|
|||
await visit("/t/-/252");
|
||||
|
||||
assert
|
||||
.dom(".fc-week:nth-child(5) .fc-content-skeleton tbody td:first-child")
|
||||
.hasClass(
|
||||
"fc-event-container",
|
||||
.dom(".fc-daygrid-body tr:nth-of-type(5) .fc-event-title")
|
||||
.hasText(
|
||||
"gmt+1_user",
|
||||
"Italian Christmas Day is displayed on Monday 2023-12-25"
|
||||
);
|
||||
});
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { visit } from "@ember/test-helpers";
|
||||
import { test } from "qunit";
|
||||
import { tomorrow, twoDays } from "discourse/lib/time-utils";
|
||||
import { tomorrow } from "discourse/lib/time-utils";
|
||||
import { acceptance } from "discourse/tests/helpers/qunit-helpers";
|
||||
|
||||
acceptance("Discourse Calendar - Upcoming Events Calendar", function (needs) {
|
||||
|
@ -47,17 +47,8 @@ acceptance("Discourse Calendar - Upcoming Events Calendar", function (needs) {
|
|||
},
|
||||
},
|
||||
name: "Awesome Event",
|
||||
upcoming_dates: [
|
||||
{
|
||||
starts_at: tomorrow().format("YYYY-MM-DDT15:14:00.000Z"),
|
||||
ends_at: tomorrow().format("YYYY-MM-DDT16:14:00.000Z"),
|
||||
},
|
||||
{
|
||||
starts_at: twoDays().format("YYYY-MM-DDT15:14:00.000Z"),
|
||||
ends_at: twoDays().format("YYYY-MM-DDT16:14:00.000Z"),
|
||||
},
|
||||
],
|
||||
category_id: 1,
|
||||
rrule: `DTSTART:${tomorrow().add(1, "hour").format("YYYYMMDDTHHmmss")}Z\nRRULE:FREQ=DAILY;INTERVAL=1;UNTIL=${tomorrow().add(2, "days").format("YYYYMMDD")}`,
|
||||
},
|
||||
{
|
||||
id: 67502,
|
||||
|
@ -88,24 +79,28 @@ acceptance("Discourse Calendar - Upcoming Events Calendar", function (needs) {
|
|||
.dom("#upcoming-events-calendar")
|
||||
.exists("Upcoming Events calendar is shown");
|
||||
|
||||
assert.dom(".fc-view-container").exists("FullCalendar is loaded");
|
||||
assert.dom(".fc").exists("FullCalendar is loaded");
|
||||
});
|
||||
|
||||
test("upcoming events category colors", async function (assert) {
|
||||
await visit("/upcoming-events");
|
||||
|
||||
const [first, second] = [...document.querySelectorAll(".fc-event")];
|
||||
const [first, second] = [
|
||||
...document.querySelectorAll(
|
||||
".fc-daygrid-event-harness .fc-daygrid-event-dot"
|
||||
),
|
||||
];
|
||||
assert
|
||||
.dom(first)
|
||||
.hasStyle(
|
||||
{ backgroundColor: "rgb(190, 10, 10)" },
|
||||
{ borderColor: "rgb(190, 10, 10)" },
|
||||
"Event item uses the proper color from category 1"
|
||||
);
|
||||
|
||||
assert
|
||||
.dom(second)
|
||||
.hasStyle(
|
||||
{ backgroundColor: "rgb(15, 120, 190)" },
|
||||
{ borderColor: "rgb(15, 120, 190)" },
|
||||
"Event item uses the proper color from category 2"
|
||||
);
|
||||
});
|
||||
|
@ -113,9 +108,11 @@ acceptance("Discourse Calendar - Upcoming Events Calendar", function (needs) {
|
|||
test("upcoming events calendar shows recurrent events", async function (assert) {
|
||||
await visit("/upcoming-events");
|
||||
|
||||
const [, second, third] = [...document.querySelectorAll(".fc-event")];
|
||||
assert.dom(".fc-title", second).hasText("Awesome Event");
|
||||
assert.dom(".fc-title", third).hasText("Awesome Event");
|
||||
const [, second, third] = [
|
||||
...document.querySelectorAll(".fc-daygrid-event-harness"),
|
||||
];
|
||||
assert.dom(".fc-event-title", second).hasText("Awesome Event");
|
||||
assert.dom(".fc-event-title", third).hasText("Awesome Event");
|
||||
|
||||
const secondCell = second.closest("td");
|
||||
const thirdCell = third.closest("td");
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { render } from "@ember/test-helpers";
|
||||
import { module, test } from "qunit";
|
||||
import { module, skip, test } from "qunit";
|
||||
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
|
||||
import { fakeTime } from "discourse/tests/helpers/qunit-helpers";
|
||||
import Dates from "../../discourse/components/discourse-post-event/dates";
|
||||
|
@ -186,7 +186,8 @@ module("Integration | Component | Dates", function (hooks) {
|
|||
);
|
||||
});
|
||||
|
||||
test("formats same day range", async function (assert) {
|
||||
// this test goest against the current implementation in local-dates
|
||||
skip("formats same day range", async function (assert) {
|
||||
await render(
|
||||
<template><Dates @event={{events.currentYear.endsSameDay}} /></template>
|
||||
);
|
||||
|
|
38
pnpm-lock.yaml
generated
38
pnpm-lock.yaml
generated
|
@ -272,6 +272,12 @@ importers:
|
|||
'@fullcalendar/list':
|
||||
specifier: ^6.1.18
|
||||
version: 6.1.18(@fullcalendar/core@6.1.18)
|
||||
'@fullcalendar/moment-timezone':
|
||||
specifier: ^6.1.19
|
||||
version: 6.1.19(@fullcalendar/core@6.1.18)(moment-timezone@0.5.45)
|
||||
'@fullcalendar/rrule':
|
||||
specifier: ^6.1.18
|
||||
version: 6.1.19(@fullcalendar/core@6.1.18)(rrule@2.8.1)
|
||||
'@fullcalendar/timegrid':
|
||||
specifier: ^6.1.18
|
||||
version: 6.1.18(@fullcalendar/core@6.1.18)
|
||||
|
@ -380,6 +386,9 @@ importers:
|
|||
prosemirror-view:
|
||||
specifier: ^1.40.0
|
||||
version: 1.40.0
|
||||
rrule:
|
||||
specifier: ^2.8.1
|
||||
version: 2.8.1
|
||||
devDependencies:
|
||||
'@babel/core':
|
||||
specifier: ^7.28.3
|
||||
|
@ -2166,6 +2175,18 @@ packages:
|
|||
peerDependencies:
|
||||
'@fullcalendar/core': ~6.1.18
|
||||
|
||||
'@fullcalendar/moment-timezone@6.1.19':
|
||||
resolution: {integrity: sha512-6UOhMThdzDnh10/SPW5t5zmNq+betGebK3i7ytg2EHzlEb2EztfHJC5mbqEU2B2AoKNr2FUIonWuergYe7OVhA==}
|
||||
peerDependencies:
|
||||
'@fullcalendar/core': ~6.1.19
|
||||
moment-timezone: ^0.5.40
|
||||
|
||||
'@fullcalendar/rrule@6.1.19':
|
||||
resolution: {integrity: sha512-8N/QYz2Nuot9oDT9qhtgJwXboX8XaEmtG2PqeSGoSotHIAsWoTXkQ13yXzwwf+3mF0NaQ+RaRQHG//69oo1EEQ==}
|
||||
peerDependencies:
|
||||
'@fullcalendar/core': ~6.1.19
|
||||
rrule: ^2.6.0
|
||||
|
||||
'@fullcalendar/timegrid@6.1.18':
|
||||
resolution: {integrity: sha512-T/ouhs+T1tM8JcW7Cjx+KiohL/qQWKqvRITwjol8ktJ1e1N/6noC40/obR1tyolqOxMRWHjJkYoj9fUqfoez9A==}
|
||||
peerDependencies:
|
||||
|
@ -7868,6 +7889,9 @@ packages:
|
|||
route-recognizer: ^0.3.4
|
||||
rsvp: ^4.8.5
|
||||
|
||||
rrule@2.8.1:
|
||||
resolution: {integrity: sha512-hM3dHSBMeaJ0Ktp7W38BJZ7O1zOgaFEsn41PDk+yHoEtfLV+PoJt9E9xAlZiWgf/iqEqionN0ebHFZIDAp+iGw==}
|
||||
|
||||
rrweb-cssom@0.7.1:
|
||||
resolution: {integrity: sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==}
|
||||
|
||||
|
@ -10602,6 +10626,16 @@ snapshots:
|
|||
dependencies:
|
||||
'@fullcalendar/core': 6.1.18
|
||||
|
||||
'@fullcalendar/moment-timezone@6.1.19(@fullcalendar/core@6.1.18)(moment-timezone@0.5.45)':
|
||||
dependencies:
|
||||
'@fullcalendar/core': 6.1.18
|
||||
moment-timezone: 0.5.45
|
||||
|
||||
'@fullcalendar/rrule@6.1.19(@fullcalendar/core@6.1.18)(rrule@2.8.1)':
|
||||
dependencies:
|
||||
'@fullcalendar/core': 6.1.18
|
||||
rrule: 2.8.1
|
||||
|
||||
'@fullcalendar/timegrid@6.1.18(@fullcalendar/core@6.1.18)':
|
||||
dependencies:
|
||||
'@fullcalendar/core': 6.1.18
|
||||
|
@ -17602,6 +17636,10 @@ snapshots:
|
|||
route-recognizer: 0.3.4
|
||||
rsvp: 4.8.5
|
||||
|
||||
rrule@2.8.1:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
rrweb-cssom@0.7.1: {}
|
||||
|
||||
rrweb-cssom@0.8.0: {}
|
||||
|
|
|
@ -467,6 +467,15 @@ RSpec.configure do |config|
|
|||
|
||||
Capybara::Node::Base.prepend(CapybaraTimeoutExtension)
|
||||
|
||||
config.before(:each, type: :system) do |example|
|
||||
if example.metadata[:time]
|
||||
freeze_time(example.metadata[:time])
|
||||
page.driver.with_playwright_page do |pw_page|
|
||||
pw_page.clock.set_fixed_time(example.metadata[:time])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
config.after(:each, type: :system) do |example|
|
||||
# If test passed, but we had a capybara finder timeout, raise it now
|
||||
if example.exception.nil? &&
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue