mirror of
https://github.com/discourse/discourse.git
synced 2025-10-03 17:21:20 +08:00
DEV: Move discourse-calendar to core (#33570)
https://meta.discourse.org/t/373574 Internal `/t/-/156778`
This commit is contained in:
parent
48a1f9cd2f
commit
a925474ed0
764 changed files with 79331 additions and 0 deletions
4
.github/labeler.yml
vendored
4
.github/labeler.yml
vendored
|
@ -106,6 +106,10 @@ discourse-gamification:
|
||||||
- changed-files:
|
- changed-files:
|
||||||
- any-glob-to-any-file: plugins/discourse-gamification/**/*
|
- any-glob-to-any-file: plugins/discourse-gamification/**/*
|
||||||
|
|
||||||
|
discourse-calendar:
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file: plugins/discourse-calendar/**/*
|
||||||
|
|
||||||
footnote:
|
footnote:
|
||||||
- changed-files:
|
- changed-files:
|
||||||
- any-glob-to-any-file: plugins/footnote/**/*
|
- any-glob-to-any-file: plugins/footnote/**/*
|
||||||
|
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -69,6 +69,7 @@
|
||||||
!/plugins/discourse-subscriptions
|
!/plugins/discourse-subscriptions
|
||||||
!/plugins/discourse-hcaptcha
|
!/plugins/discourse-hcaptcha
|
||||||
!/plugins/discourse-gamification
|
!/plugins/discourse-gamification
|
||||||
|
!/plugins/discourse-calendar
|
||||||
/plugins/*/auto_generated
|
/plugins/*/auto_generated
|
||||||
|
|
||||||
/spec/fixtures/plugins/my_plugin/auto_generated
|
/spec/fixtures/plugins/my_plugin/auto_generated
|
||||||
|
|
1
.streerc
1
.streerc
|
@ -1,2 +1,3 @@
|
||||||
--print-width=100
|
--print-width=100
|
||||||
--plugins=plugin/trailing_comma,plugin/disable_auto_ternary
|
--plugins=plugin/trailing_comma,plugin/disable_auto_ternary
|
||||||
|
--ignore-files=plugins/discourse-calendar/vendor/*
|
||||||
|
|
2
plugins/discourse-calendar/.prettierignore
Normal file
2
plugins/discourse-calendar/.prettierignore
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
assets/stylesheets/vendor/*.scss
|
||||||
|
public/
|
42
plugins/discourse-calendar/README.md
Normal file
42
plugins/discourse-calendar/README.md
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
# Discourse Calendar
|
||||||
|
|
||||||
|
Adds the ability to create a dynamic calendar in the first post of a topic.
|
||||||
|
|
||||||
|
Topic discussing the plugin itself can be found here: [https://meta.discourse.org/t/discourse-calendar/97376](https://meta.discourse.org/t/discourse-calendar/97376)
|
||||||
|
|
||||||
|
## Customization
|
||||||
|
|
||||||
|
### Events
|
||||||
|
|
||||||
|
- `discourse_post_event_event_will_start` this DiscourseEvent will be triggered one hour before an event starts
|
||||||
|
- `discourse_post_event_event_started` this DiscourseEvent will be triggered when an event starts
|
||||||
|
- `discourse_post_event_event_ended` this DiscourseEvent will be triggered when an event ends
|
||||||
|
|
||||||
|
### Custom Fields
|
||||||
|
|
||||||
|
Custom fields can be set in plugin settings. Once added a new form will appear on event UI.
|
||||||
|
These custom fields are available when a plugin event is triggered.
|
||||||
|
|
||||||
|
### Holidays
|
||||||
|
|
||||||
|
See an incorrect or missing holiday? Familiarize yourself with the [holiday definition Syntax](vendor/holidays/definitions/doc/SYNTAX.md). Then make your updates in the `vendor/holiday/definitions` directory.
|
||||||
|
|
||||||
|
Generate updated holidays as follows.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd vendor/holidays
|
||||||
|
|
||||||
|
# Generate holiday definitions
|
||||||
|
rake generate:definitions
|
||||||
|
```
|
||||||
|
|
||||||
|
Install the plugin and switch to the discourse root(not the plugin directory).
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Collect all holiday regions into assets/javascripts/lib/regions.js
|
||||||
|
bin/rails javascript:update_constants
|
||||||
|
```
|
||||||
|
|
||||||
|
### Interactions with Other Plugins
|
||||||
|
|
||||||
|
You can use an element of this plugin with the [Right Sidebar Blocks](https://github.com/discourse/discourse-right-sidebar-blocks) component. You'll want to ensure the desired route is enabled via the `events calendar categories` setting. In Right Sidebar Block's settings, the block name will be `upcoming-events-list`, and the params use this [syntax](https://momentjs.com/docs/#/displaying/format/), for example `MMMM D, YYYY`.
|
|
@ -0,0 +1,48 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Admin::DiscourseCalendar
|
||||||
|
class AdminHolidaysController < Admin::AdminController
|
||||||
|
requires_plugin DiscourseCalendar::PLUGIN_NAME
|
||||||
|
|
||||||
|
def index
|
||||||
|
region_code = params[:region_code]
|
||||||
|
|
||||||
|
begin
|
||||||
|
holidays = DiscourseCalendar::Holiday.find_holidays_for(region_code: region_code)
|
||||||
|
rescue Holidays::InvalidRegion
|
||||||
|
return(
|
||||||
|
render_json_error(
|
||||||
|
I18n.t("system_messages.discourse_calendar_holiday_region_invalid"),
|
||||||
|
422,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
render json: { region_code: region_code, holidays: holidays }
|
||||||
|
end
|
||||||
|
|
||||||
|
def disable
|
||||||
|
DiscourseCalendar::DisabledHoliday.create!(disabled_holiday_params)
|
||||||
|
CalendarEvent.destroy_by(
|
||||||
|
description: disabled_holiday_params[:holiday_name],
|
||||||
|
region: disabled_holiday_params[:region_code],
|
||||||
|
)
|
||||||
|
|
||||||
|
render json: success_json
|
||||||
|
end
|
||||||
|
|
||||||
|
def enable
|
||||||
|
if DiscourseCalendar::DisabledHoliday.destroy_by(disabled_holiday_params).present?
|
||||||
|
render json: success_json
|
||||||
|
else
|
||||||
|
render_json_error(I18n.t("system_messages.discourse_calendar_enable_holiday_failed"), 422)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def disabled_holiday_params
|
||||||
|
params.require(:disabled_holiday).permit(:holiday_name, :region_code)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,14 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module DiscoursePostEvent
|
||||||
|
class DiscoursePostEventController < ::ApplicationController
|
||||||
|
requires_plugin DiscourseCalendar::PLUGIN_NAME
|
||||||
|
before_action :ensure_discourse_post_event_enabled
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def ensure_discourse_post_event_enabled
|
||||||
|
raise Discourse::NotFound if !SiteSetting.discourse_post_event_enabled
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,130 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module DiscoursePostEvent
|
||||||
|
class EventsController < DiscoursePostEventController
|
||||||
|
def index
|
||||||
|
@events =
|
||||||
|
DiscoursePostEvent::EventFinder.search(current_user, filtered_events_params).includes(
|
||||||
|
post: :topic,
|
||||||
|
)
|
||||||
|
|
||||||
|
# The detailed serializer is currently not used anywhere in the frontend, but available via API
|
||||||
|
serializer = params[:include_details] == "true" ? EventSerializer : EventSummarySerializer
|
||||||
|
|
||||||
|
render json:
|
||||||
|
ActiveModel::ArraySerializer.new(
|
||||||
|
@events,
|
||||||
|
each_serializer: serializer,
|
||||||
|
scope: guardian,
|
||||||
|
).as_json
|
||||||
|
end
|
||||||
|
|
||||||
|
def invite
|
||||||
|
event = Event.find(params[:id])
|
||||||
|
guardian.ensure_can_act_on_discourse_post_event!(event)
|
||||||
|
invites = Array(params.permit(invites: [])[:invites])
|
||||||
|
users = User.real.where(username: invites)
|
||||||
|
|
||||||
|
users.each { |user| event.create_notification!(user, event.post) }
|
||||||
|
|
||||||
|
render json: success_json
|
||||||
|
end
|
||||||
|
|
||||||
|
def show
|
||||||
|
event = Event.find(params[:id])
|
||||||
|
guardian.ensure_can_see!(event.post)
|
||||||
|
serializer = EventSerializer.new(event, scope: guardian)
|
||||||
|
render_json_dump(serializer)
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
event = Event.find(params[:id])
|
||||||
|
guardian.ensure_can_act_on_discourse_post_event!(event)
|
||||||
|
event.publish_update!
|
||||||
|
event.destroy
|
||||||
|
render json: success_json
|
||||||
|
end
|
||||||
|
|
||||||
|
def csv_bulk_invite
|
||||||
|
require "csv"
|
||||||
|
|
||||||
|
event = Event.find(params[:id])
|
||||||
|
guardian.ensure_can_edit!(event.post)
|
||||||
|
guardian.ensure_can_create_discourse_post_event!
|
||||||
|
|
||||||
|
file = params[:file] || (params[:files] || []).first
|
||||||
|
raise Discourse::InvalidParameters.new(:file) if file.blank?
|
||||||
|
|
||||||
|
hijack do
|
||||||
|
begin
|
||||||
|
invitees = []
|
||||||
|
|
||||||
|
CSV.foreach(file.tempfile) do |row|
|
||||||
|
invitees << { identifier: row[0], attendance: row[1] || "going" } if row[0].present?
|
||||||
|
end
|
||||||
|
|
||||||
|
if invitees.present?
|
||||||
|
Jobs.enqueue(
|
||||||
|
:discourse_post_event_bulk_invite,
|
||||||
|
event_id: event.id,
|
||||||
|
invitees: invitees,
|
||||||
|
current_user_id: current_user.id,
|
||||||
|
)
|
||||||
|
render json: success_json
|
||||||
|
else
|
||||||
|
render json:
|
||||||
|
failed_json.merge(
|
||||||
|
errors: [I18n.t("discourse_post_event.errors.bulk_invite.error")],
|
||||||
|
),
|
||||||
|
status: 422
|
||||||
|
end
|
||||||
|
rescue StandardError
|
||||||
|
render json:
|
||||||
|
failed_json.merge(
|
||||||
|
errors: [I18n.t("discourse_post_event.errors.bulk_invite.error")],
|
||||||
|
),
|
||||||
|
status: 422
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def bulk_invite
|
||||||
|
event = Event.find(params[:id])
|
||||||
|
guardian.ensure_can_edit!(event.post)
|
||||||
|
guardian.ensure_can_create_discourse_post_event!
|
||||||
|
|
||||||
|
invitees = Array(params[:invitees]).reject { |x| x.empty? }
|
||||||
|
raise Discourse::InvalidParameters.new(:invitees) if invitees.blank?
|
||||||
|
|
||||||
|
begin
|
||||||
|
Jobs.enqueue(
|
||||||
|
:discourse_post_event_bulk_invite,
|
||||||
|
event_id: event.id,
|
||||||
|
invitees: invitees.as_json,
|
||||||
|
current_user_id: current_user.id,
|
||||||
|
)
|
||||||
|
render json: success_json
|
||||||
|
rescue StandardError
|
||||||
|
render json:
|
||||||
|
failed_json.merge(
|
||||||
|
errors: [I18n.t("discourse_post_event.errors.bulk_invite.error")],
|
||||||
|
),
|
||||||
|
status: 422
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def filtered_events_params
|
||||||
|
params.permit(
|
||||||
|
:post_id,
|
||||||
|
:category_id,
|
||||||
|
:include_subcategories,
|
||||||
|
:include_expired,
|
||||||
|
:limit,
|
||||||
|
:before,
|
||||||
|
:attending_user,
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,99 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module DiscoursePostEvent
|
||||||
|
class InviteesController < DiscoursePostEventController
|
||||||
|
def index
|
||||||
|
event = Event.find(params[:post_id])
|
||||||
|
guardian.ensure_can_see!(event.post)
|
||||||
|
|
||||||
|
filter = params[:filter].downcase if params[:filter]
|
||||||
|
|
||||||
|
event_invitees = event.invitees
|
||||||
|
event_invitees = event_invitees.with_status(params[:type].to_sym) if params[:type]
|
||||||
|
|
||||||
|
suggested_users = []
|
||||||
|
if filter.present? && guardian.can_act_on_discourse_post_event?(event)
|
||||||
|
missing_users = event.missing_users(event_invitees.select(:user_id))
|
||||||
|
|
||||||
|
if filter
|
||||||
|
missing_users = missing_users.where("LOWER(username) LIKE :filter", filter: "%#{filter}%")
|
||||||
|
|
||||||
|
custom_order = <<~SQL
|
||||||
|
CASE
|
||||||
|
WHEN LOWER(username) = ? THEN 0
|
||||||
|
ELSE 1
|
||||||
|
END ASC,
|
||||||
|
LOWER(username) ASC
|
||||||
|
SQL
|
||||||
|
|
||||||
|
custom_order = ActiveRecord::Base.sanitize_sql_array([custom_order, filter])
|
||||||
|
missing_users = missing_users.order(custom_order).limit(10)
|
||||||
|
else
|
||||||
|
missing_users = missing_users.order(:username_lower).limit(10)
|
||||||
|
end
|
||||||
|
|
||||||
|
suggested_users = missing_users
|
||||||
|
end
|
||||||
|
|
||||||
|
if filter
|
||||||
|
event_invitees =
|
||||||
|
event_invitees.joins(:user).where(
|
||||||
|
"LOWER(users.username) LIKE :filter",
|
||||||
|
filter: "%#{filter}%",
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
event_invitees = event_invitees.order(%i[status username_lower]).limit(200)
|
||||||
|
|
||||||
|
render json:
|
||||||
|
InviteeListSerializer.new(invitees: event_invitees, suggested_users: suggested_users)
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
invitee = Invitee.find_by(id: params[:invitee_id], post_id: params[:event_id])
|
||||||
|
guardian.ensure_can_act_on_invitee!(invitee)
|
||||||
|
invitee.update_attendance!(invitee_params[:status])
|
||||||
|
render json: InviteeSerializer.new(invitee)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
event = Event.find(params[:event_id])
|
||||||
|
guardian.ensure_can_see!(event.post)
|
||||||
|
|
||||||
|
invitee_params = invitee_params(event)
|
||||||
|
|
||||||
|
user = current_user
|
||||||
|
if user_id = invitee_params[:user_id]
|
||||||
|
user = User.find(user_id.to_i)
|
||||||
|
end
|
||||||
|
|
||||||
|
raise Discourse::InvalidAccess if !event.can_user_update_attendance(user)
|
||||||
|
|
||||||
|
if current_user.id != user.id
|
||||||
|
raise Discourse::InvalidAccess if !guardian.can_act_on_discourse_post_event?(event)
|
||||||
|
end
|
||||||
|
|
||||||
|
invitee = Invitee.create_attendance!(user.id, params[:event_id], invitee_params[:status])
|
||||||
|
render json: InviteeSerializer.new(invitee)
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
event = Event.find_by(id: params[:post_id])
|
||||||
|
invitee = event.invitees.find_by(id: params[:id])
|
||||||
|
guardian.ensure_can_act_on_invitee!(invitee)
|
||||||
|
invitee.destroy!
|
||||||
|
event.publish_update!
|
||||||
|
render json: success_json
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def invitee_params(event = nil)
|
||||||
|
if event && guardian.can_act_on_discourse_post_event?(event)
|
||||||
|
params.require(:invitee).permit(:status, :user_id)
|
||||||
|
else
|
||||||
|
params.require(:invitee).permit(:status)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,8 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module DiscoursePostEvent
|
||||||
|
class UpcomingEventsController < DiscoursePostEventController
|
||||||
|
def index
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
131
plugins/discourse-calendar/app/models/calendar_event.rb
Normal file
131
plugins/discourse-calendar/app/models/calendar_event.rb
Normal file
|
@ -0,0 +1,131 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class CalendarEvent < ActiveRecord::Base
|
||||||
|
belongs_to :topic
|
||||||
|
belongs_to :post
|
||||||
|
belongs_to :user
|
||||||
|
|
||||||
|
after_save do
|
||||||
|
if SiteSetting.enable_user_status && is_holiday? && underway?
|
||||||
|
DiscourseCalendar::HolidayStatus.set!(user, ends_at)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
after_destroy { DiscourseCalendar::HolidayStatus.clear!(user) if SiteSetting.enable_user_status }
|
||||||
|
|
||||||
|
def ends_at
|
||||||
|
end_date || (start_date + 24.hours)
|
||||||
|
end
|
||||||
|
|
||||||
|
def underway?
|
||||||
|
now = Time.zone.now
|
||||||
|
start_date <= now && now < ends_at
|
||||||
|
end
|
||||||
|
|
||||||
|
def is_holiday?
|
||||||
|
SiteSetting.holiday_calendar_topic_id.to_i == topic_id
|
||||||
|
end
|
||||||
|
|
||||||
|
def in_future?
|
||||||
|
start_date > Time.zone.now
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.update(post)
|
||||||
|
CalendarEvent.where(post_id: post.id).destroy_all
|
||||||
|
|
||||||
|
dates = post.local_dates
|
||||||
|
return if !dates || dates.size < 1 || dates.size > 2
|
||||||
|
|
||||||
|
first_post = post.topic&.first_post
|
||||||
|
return if !first_post || !first_post.custom_fields[DiscourseCalendar::CALENDAR_CUSTOM_FIELD]
|
||||||
|
|
||||||
|
from = self.convert_to_date_time(dates[0])
|
||||||
|
to = self.convert_to_date_time(dates[1]) if dates.size == 2
|
||||||
|
|
||||||
|
adjust_to = !to || !dates[1]["time"]
|
||||||
|
if !to && dates[0]["time"]
|
||||||
|
to = from + 1.hour
|
||||||
|
artificial_to = true
|
||||||
|
end
|
||||||
|
|
||||||
|
if SiteSetting.all_day_event_start_time.present? && SiteSetting.all_day_event_end_time.present?
|
||||||
|
from = from.change(hour_adjustment(SiteSetting.all_day_event_start_time)) if !dates[0]["time"]
|
||||||
|
to = (to || from).change(hour_adjustment(SiteSetting.all_day_event_end_time)) if adjust_to &&
|
||||||
|
!artificial_to
|
||||||
|
end
|
||||||
|
|
||||||
|
doc = Nokogiri::HTML5.fragment(post.cooked)
|
||||||
|
doc.css(".discourse-local-date").each(&:remove)
|
||||||
|
html = doc.to_html.sub(/\s*→\s*/, "")
|
||||||
|
|
||||||
|
description =
|
||||||
|
PrettyText.excerpt(
|
||||||
|
html,
|
||||||
|
1000,
|
||||||
|
strip_links: true,
|
||||||
|
text_entities: true,
|
||||||
|
keep_emoji_images: true,
|
||||||
|
)
|
||||||
|
recurrence = dates[0]["recurring"].presence
|
||||||
|
timezone = dates[0]["timezone"].presence
|
||||||
|
|
||||||
|
CalendarEvent.create!(
|
||||||
|
topic_id: post.topic_id,
|
||||||
|
post_id: post.id,
|
||||||
|
post_number: post.post_number,
|
||||||
|
user_id: post.user_id,
|
||||||
|
username: post.user.username,
|
||||||
|
description: description,
|
||||||
|
start_date: from,
|
||||||
|
end_date: to,
|
||||||
|
recurrence: recurrence,
|
||||||
|
timezone: timezone,
|
||||||
|
)
|
||||||
|
|
||||||
|
post.publish_change_to_clients!(:calendar_change)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def self.convert_to_date_time(value)
|
||||||
|
return if value.blank?
|
||||||
|
|
||||||
|
datetime = value["date"].to_s
|
||||||
|
datetime << " #{value["time"]}" if value["time"]
|
||||||
|
timezone = value["timezone"] || "UTC"
|
||||||
|
|
||||||
|
ActiveSupport::TimeZone[timezone].parse(datetime)
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.hour_adjustment(setting)
|
||||||
|
setting = setting.split(":")
|
||||||
|
|
||||||
|
{ hour: setting.first, min: setting.last }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# == Schema Information
|
||||||
|
#
|
||||||
|
# Table name: calendar_events
|
||||||
|
#
|
||||||
|
# id :bigint not null, primary key
|
||||||
|
# topic_id :integer not null
|
||||||
|
# post_id :integer
|
||||||
|
# post_number :integer
|
||||||
|
# user_id :integer
|
||||||
|
# username :string
|
||||||
|
# description :string
|
||||||
|
# start_date :datetime not null
|
||||||
|
# end_date :datetime
|
||||||
|
# recurrence :string
|
||||||
|
# region :string
|
||||||
|
# created_at :datetime not null
|
||||||
|
# updated_at :datetime not null
|
||||||
|
# timezone :string
|
||||||
|
#
|
||||||
|
# Indexes
|
||||||
|
#
|
||||||
|
# index_calendar_events_on_post_id (post_id)
|
||||||
|
# index_calendar_events_on_topic_id (topic_id)
|
||||||
|
# index_calendar_events_on_user_id (user_id)
|
||||||
|
#
|
|
@ -0,0 +1,24 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module DiscourseCalendar
|
||||||
|
class DisabledHoliday < ActiveRecord::Base
|
||||||
|
validates :holiday_name, presence: true
|
||||||
|
validates :region_code, presence: true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# == Schema Information
|
||||||
|
#
|
||||||
|
# Table name: discourse_calendar_disabled_holidays
|
||||||
|
#
|
||||||
|
# id :bigint not null, primary key
|
||||||
|
# holiday_name :string not null
|
||||||
|
# region_code :string not null
|
||||||
|
# disabled :boolean default(TRUE), not null
|
||||||
|
# created_at :datetime not null
|
||||||
|
# updated_at :datetime not null
|
||||||
|
#
|
||||||
|
# Indexes
|
||||||
|
#
|
||||||
|
# index_disabled_holidays_on_holiday_name_and_region_code (holiday_name,region_code)
|
||||||
|
#
|
|
@ -0,0 +1,437 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module DiscoursePostEvent
|
||||||
|
class Event < ActiveRecord::Base
|
||||||
|
PUBLIC_GROUP = "trust_level_0"
|
||||||
|
MIN_NAME_LENGTH = 5
|
||||||
|
MAX_NAME_LENGTH = 255
|
||||||
|
self.table_name = "discourse_post_event_events"
|
||||||
|
self.ignored_columns = %w[starts_at ends_at]
|
||||||
|
|
||||||
|
has_many :event_dates, dependent: :destroy
|
||||||
|
# this is a cross plugin dependency, only called if chat is enabled
|
||||||
|
belongs_to :chat_channel, class_name: "Chat::Channel"
|
||||||
|
has_many :invitees, foreign_key: :post_id, dependent: :delete_all
|
||||||
|
belongs_to :post, foreign_key: :id
|
||||||
|
|
||||||
|
scope :visible, -> { where(deleted_at: nil) }
|
||||||
|
|
||||||
|
after_commit :destroy_topic_custom_field, on: %i[destroy]
|
||||||
|
after_commit :create_or_update_event_date, on: %i[create update]
|
||||||
|
before_save :chat_channel_sync
|
||||||
|
|
||||||
|
validate :raw_invitees_are_groups
|
||||||
|
validates :original_starts_at, presence: true
|
||||||
|
validates :name,
|
||||||
|
length: {
|
||||||
|
in: MIN_NAME_LENGTH..MAX_NAME_LENGTH,
|
||||||
|
},
|
||||||
|
unless: ->(event) { event.name.blank? }
|
||||||
|
|
||||||
|
validate :raw_invitees_length
|
||||||
|
validate :ends_before_start
|
||||||
|
validate :allowed_custom_fields
|
||||||
|
|
||||||
|
def self.attributes_protected_by_default
|
||||||
|
super - %w[id]
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy_topic_custom_field
|
||||||
|
if self.post && self.post.is_first_post?
|
||||||
|
TopicCustomField.where(
|
||||||
|
topic_id: self.post.topic_id,
|
||||||
|
name: TOPIC_POST_EVENT_STARTS_AT,
|
||||||
|
).delete_all
|
||||||
|
|
||||||
|
TopicCustomField.where(
|
||||||
|
topic_id: self.post.topic_id,
|
||||||
|
name: TOPIC_POST_EVENT_ENDS_AT,
|
||||||
|
).delete_all
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_or_update_event_date
|
||||||
|
starts_at_changed = saved_change_to_original_starts_at
|
||||||
|
ends_at_changed = saved_change_to_original_ends_at
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
event_dates.create!(
|
||||||
|
starts_at: next_dates[:starts_at],
|
||||||
|
ends_at: next_dates[:ends_at],
|
||||||
|
) do |event_date|
|
||||||
|
if next_dates[:ends_at] && next_dates[:ends_at] < Time.current
|
||||||
|
event_date.finished_at = next_dates[:ends_at]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
invitees.where.not(status: Invitee.statuses[:going]).update_all(status: nil, notified: false)
|
||||||
|
|
||||||
|
if !next_dates[:rescheduled]
|
||||||
|
notify_invitees!
|
||||||
|
notify_missing_invitees!
|
||||||
|
end
|
||||||
|
|
||||||
|
publish_update!
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_topic_bump
|
||||||
|
date = nil
|
||||||
|
|
||||||
|
return if reminders.blank?
|
||||||
|
reminders
|
||||||
|
.split(",")
|
||||||
|
.each do |reminder|
|
||||||
|
type, value, unit = reminder.split(".")
|
||||||
|
next if type != "bumpTopic" || !validate_reminder_unit(unit)
|
||||||
|
date = starts_at - value.to_i.public_send(unit)
|
||||||
|
break
|
||||||
|
end
|
||||||
|
|
||||||
|
return if date.blank?
|
||||||
|
Jobs.enqueue(:discourse_post_event_bump_topic, topic_id: self.post.topic_id, date: date)
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_reminder_unit(input)
|
||||||
|
ActiveSupport::Duration::PARTS.any? { |part| part.to_s == input }
|
||||||
|
end
|
||||||
|
|
||||||
|
def expired?
|
||||||
|
(ends_at || starts_at.end_of_day) <= Time.now
|
||||||
|
end
|
||||||
|
|
||||||
|
def starts_at
|
||||||
|
event_dates.pending.order(:starts_at).last&.starts_at ||
|
||||||
|
event_dates.order(:updated_at, :id).last&.starts_at
|
||||||
|
end
|
||||||
|
|
||||||
|
def ends_at
|
||||||
|
event_dates.pending.order(:starts_at).last&.ends_at ||
|
||||||
|
event_dates.order(:updated_at, :id).last&.ends_at
|
||||||
|
end
|
||||||
|
|
||||||
|
def on_going_event_invitees
|
||||||
|
return [] if !self.ends_at && self.starts_at < Time.now
|
||||||
|
|
||||||
|
if self.ends_at
|
||||||
|
extended_ends_at =
|
||||||
|
self.ends_at + SiteSetting.discourse_post_event_edit_notifications_time_extension.minutes
|
||||||
|
return [] if !(self.starts_at..extended_ends_at).cover?(Time.now)
|
||||||
|
end
|
||||||
|
|
||||||
|
invitees.where(status: DiscoursePostEvent::Invitee.statuses[:going])
|
||||||
|
end
|
||||||
|
|
||||||
|
def raw_invitees_length
|
||||||
|
if self.raw_invitees && self.raw_invitees.length > 10
|
||||||
|
errors.add(
|
||||||
|
:base,
|
||||||
|
I18n.t("discourse_post_event.errors.models.event.raw_invitees_length", count: 10),
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def raw_invitees_are_groups
|
||||||
|
if self.raw_invitees && User.select(:id).where(username: self.raw_invitees).limit(1).count > 0
|
||||||
|
errors.add(
|
||||||
|
:base,
|
||||||
|
I18n.t("discourse_post_event.errors.models.event.raw_invitees.only_group"),
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def ends_before_start
|
||||||
|
if self.original_starts_at && self.original_ends_at &&
|
||||||
|
self.original_starts_at >= self.original_ends_at
|
||||||
|
errors.add(
|
||||||
|
:base,
|
||||||
|
I18n.t("discourse_post_event.errors.models.event.ends_at_before_starts_at"),
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def allowed_custom_fields
|
||||||
|
allowed_custom_fields = SiteSetting.discourse_post_event_allowed_custom_fields.split("|")
|
||||||
|
self.custom_fields.each do |key, value|
|
||||||
|
if !allowed_custom_fields.include?(key)
|
||||||
|
errors.add(
|
||||||
|
:base,
|
||||||
|
I18n.t("discourse_post_event.errors.models.event.custom_field_is_invalid", field: key),
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_invitees(attrs)
|
||||||
|
timestamp = Time.now
|
||||||
|
attrs.map! do |attr|
|
||||||
|
{ post_id: self.id, created_at: timestamp, updated_at: timestamp }.merge(attr)
|
||||||
|
end
|
||||||
|
result = self.invitees.insert_all!(attrs)
|
||||||
|
|
||||||
|
# batch event does not call calleback
|
||||||
|
ChatChannelSync.sync(self) if chat_enabled?
|
||||||
|
|
||||||
|
result
|
||||||
|
end
|
||||||
|
|
||||||
|
def notify_invitees!(predefined_attendance: false)
|
||||||
|
self
|
||||||
|
.invitees
|
||||||
|
.where(notified: false)
|
||||||
|
.find_each do |invitee|
|
||||||
|
create_notification!(
|
||||||
|
invitee.user,
|
||||||
|
self.post,
|
||||||
|
predefined_attendance: predefined_attendance,
|
||||||
|
)
|
||||||
|
invitee.update!(notified: true)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def notify_missing_invitees!
|
||||||
|
self.missing_users.each { |user| create_notification!(user, self.post) } if self.private?
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_notification!(user, post, predefined_attendance: false)
|
||||||
|
return if post.event.starts_at < Time.current
|
||||||
|
|
||||||
|
message =
|
||||||
|
if predefined_attendance
|
||||||
|
"discourse_post_event.notifications.invite_user_predefined_attendance_notification"
|
||||||
|
else
|
||||||
|
"discourse_post_event.notifications.invite_user_notification"
|
||||||
|
end
|
||||||
|
|
||||||
|
attrs = {
|
||||||
|
notification_type: Notification.types[:event_invitation] || Notification.types[:custom],
|
||||||
|
topic_id: post.topic_id,
|
||||||
|
post_number: post.post_number,
|
||||||
|
data: {
|
||||||
|
user_id: user.id,
|
||||||
|
topic_title: self.name || post.topic.title,
|
||||||
|
display_username: post.user.username,
|
||||||
|
message: message,
|
||||||
|
}.to_json,
|
||||||
|
}
|
||||||
|
|
||||||
|
user.notifications.consolidate_or_create!(attrs)
|
||||||
|
end
|
||||||
|
|
||||||
|
def ongoing?
|
||||||
|
return false if self.closed || self.expired?
|
||||||
|
finishes_at = self.ends_at || self.starts_at.end_of_day
|
||||||
|
(self.starts_at..finishes_at).cover?(Time.now)
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.statuses
|
||||||
|
@statuses ||= Enum.new(standalone: 0, public: 1, private: 2)
|
||||||
|
end
|
||||||
|
|
||||||
|
def public?
|
||||||
|
status == Event.statuses[:public]
|
||||||
|
end
|
||||||
|
|
||||||
|
def standalone?
|
||||||
|
status == Event.statuses[:standalone]
|
||||||
|
end
|
||||||
|
|
||||||
|
def private?
|
||||||
|
status == Event.statuses[:private]
|
||||||
|
end
|
||||||
|
|
||||||
|
def recurring?
|
||||||
|
recurrence.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def most_likely_going(limit = SiteSetting.displayed_invitees_limit)
|
||||||
|
going = self.invitees.order(%i[status user_id]).limit(limit)
|
||||||
|
|
||||||
|
if self.private? && going.count < limit
|
||||||
|
# invitees are only created when an attendance is set
|
||||||
|
# so we create a dummy invitee object with only what's needed for serializer
|
||||||
|
going =
|
||||||
|
going +
|
||||||
|
missing_users(going.pluck(:user_id))
|
||||||
|
.limit(limit - going.count)
|
||||||
|
.map { |user| Invitee.new(user: user, post_id: self.id) }
|
||||||
|
end
|
||||||
|
|
||||||
|
going
|
||||||
|
end
|
||||||
|
|
||||||
|
def publish_update!
|
||||||
|
self.post.publish_message!("/discourse-post-event/#{self.post.topic_id}", id: self.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch_users
|
||||||
|
@fetched_users ||= Invitee.extract_uniq_usernames(self.raw_invitees)
|
||||||
|
end
|
||||||
|
|
||||||
|
def enforce_private_invitees!
|
||||||
|
self.invitees.where.not(user_id: fetch_users.select(:id)).delete_all
|
||||||
|
end
|
||||||
|
|
||||||
|
def can_user_update_attendance(user)
|
||||||
|
return false if self.closed || self.expired?
|
||||||
|
return true if self.public?
|
||||||
|
|
||||||
|
self.private? &&
|
||||||
|
(
|
||||||
|
self.invitees.exists?(user_id: user.id) ||
|
||||||
|
(user.groups.pluck(:name) & self.raw_invitees).any?
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.update_from_raw(post)
|
||||||
|
events = DiscoursePostEvent::EventParser.extract_events(post)
|
||||||
|
|
||||||
|
if events.present?
|
||||||
|
event_params = events.first
|
||||||
|
event = post.event || DiscoursePostEvent::Event.new(id: post.id)
|
||||||
|
|
||||||
|
tz = ActiveSupport::TimeZone[event_params[:timezone] || "UTC"]
|
||||||
|
parsed_starts_at = tz.parse(event_params[:start])
|
||||||
|
parsed_ends_at = event_params[:end] ? tz.parse(event_params[:end]) : nil
|
||||||
|
parsed_recurrence_until =
|
||||||
|
event_params[:"recurrence-until"] ? tz.parse(event_params[:"recurrence-until"]) : nil
|
||||||
|
|
||||||
|
params = {
|
||||||
|
name: event_params[:name],
|
||||||
|
original_starts_at: parsed_starts_at,
|
||||||
|
original_ends_at: parsed_ends_at,
|
||||||
|
url: event_params[:url],
|
||||||
|
description: event_params[:description],
|
||||||
|
location: event_params[:location],
|
||||||
|
recurrence: event_params[:recurrence],
|
||||||
|
recurrence_until: parsed_recurrence_until,
|
||||||
|
timezone: event_params[:timezone],
|
||||||
|
show_local_time: event_params[:"show-local-time"] == "true",
|
||||||
|
status: Event.statuses[event_params[:status]&.to_sym] || event.status,
|
||||||
|
reminders: event_params[:reminders],
|
||||||
|
raw_invitees: event_params[:"allowed-groups"]&.split(","),
|
||||||
|
minimal: event_params[:minimal],
|
||||||
|
closed: event_params[:closed] || false,
|
||||||
|
chat_enabled: event_params[:"chat-enabled"]&.downcase == "true",
|
||||||
|
}
|
||||||
|
|
||||||
|
params[:custom_fields] = {}
|
||||||
|
SiteSetting
|
||||||
|
.discourse_post_event_allowed_custom_fields
|
||||||
|
.split("|")
|
||||||
|
.each do |setting|
|
||||||
|
if event_params[setting.to_sym].present?
|
||||||
|
params[:custom_fields][setting] = event_params[setting.to_sym]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
event.update_with_params!(params)
|
||||||
|
event.set_topic_bump
|
||||||
|
elsif post.event
|
||||||
|
post.event.destroy!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def missing_users(excluded_ids = self.invitees.select(:user_id))
|
||||||
|
users = User.real.activated.not_silenced.not_suspended.not_staged
|
||||||
|
|
||||||
|
if self.raw_invitees.present?
|
||||||
|
user_ids =
|
||||||
|
users
|
||||||
|
.joins(:groups)
|
||||||
|
.where("groups.name" => self.raw_invitees)
|
||||||
|
.where.not(id: excluded_ids)
|
||||||
|
.select(:id)
|
||||||
|
User.where(id: user_ids)
|
||||||
|
else
|
||||||
|
users.where.not(id: excluded_ids)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_with_params!(params)
|
||||||
|
case params[:status] ? params[:status].to_i : self.status
|
||||||
|
when Event.statuses[:private]
|
||||||
|
if params.key?(:raw_invitees)
|
||||||
|
params = params.merge(raw_invitees: Array(params[:raw_invitees]) - [PUBLIC_GROUP])
|
||||||
|
else
|
||||||
|
params = params.merge(raw_invitees: Array(self.raw_invitees) - [PUBLIC_GROUP])
|
||||||
|
end
|
||||||
|
self.update!(params)
|
||||||
|
self.enforce_private_invitees!
|
||||||
|
when Event.statuses[:public]
|
||||||
|
self.update!(params.merge(raw_invitees: [PUBLIC_GROUP]))
|
||||||
|
when Event.statuses[:standalone]
|
||||||
|
self.update!(params.merge(raw_invitees: []))
|
||||||
|
self.invitees.destroy_all
|
||||||
|
end
|
||||||
|
|
||||||
|
self.publish_update!
|
||||||
|
end
|
||||||
|
|
||||||
|
def chat_channel_sync
|
||||||
|
if self.chat_enabled && self.chat_channel_id.blank? && post.last_editor_id.present?
|
||||||
|
DiscoursePostEvent::ChatChannelSync.sync(
|
||||||
|
self,
|
||||||
|
guardian: Guardian.new(User.find_by(id: post.last_editor_id)),
|
||||||
|
)
|
||||||
|
end
|
||||||
|
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 }
|
||||||
|
end
|
||||||
|
|
||||||
|
next_starts_at =
|
||||||
|
RRuleGenerator.generate(
|
||||||
|
starts_at: original_starts_at.in_time_zone(timezone),
|
||||||
|
timezone:,
|
||||||
|
recurrence:,
|
||||||
|
recurrence_until:,
|
||||||
|
).first
|
||||||
|
|
||||||
|
if original_ends_at
|
||||||
|
difference = original_ends_at - original_starts_at
|
||||||
|
next_ends_at = next_starts_at + difference.seconds
|
||||||
|
else
|
||||||
|
next_ends_at = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
{ starts_at: next_starts_at, ends_at: next_ends_at, rescheduled: true }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# == Schema Information
|
||||||
|
#
|
||||||
|
# Table name: discourse_post_event_events
|
||||||
|
#
|
||||||
|
# id :bigint not null, primary key
|
||||||
|
# status :integer default(0), not null
|
||||||
|
# original_starts_at :datetime not null
|
||||||
|
# original_ends_at :datetime
|
||||||
|
# deleted_at :datetime
|
||||||
|
# raw_invitees :string is an Array
|
||||||
|
# name :string
|
||||||
|
# url :string(1000)
|
||||||
|
# description :string(1000)
|
||||||
|
# location :string(1000)
|
||||||
|
# custom_fields :jsonb not null
|
||||||
|
# reminders :string
|
||||||
|
# recurrence :string
|
||||||
|
# timezone :string
|
||||||
|
# minimal :boolean
|
||||||
|
# closed :boolean default(FALSE), not null
|
||||||
|
# chat_enabled :boolean default(FALSE), not null
|
||||||
|
# chat_channel_id :bigint
|
||||||
|
# recurrence_until :datetime
|
||||||
|
# show_local_time :boolean default(FALSE), not null
|
||||||
|
#
|
|
@ -0,0 +1,73 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module DiscoursePostEvent
|
||||||
|
class EventDate < ActiveRecord::Base
|
||||||
|
self.table_name = "discourse_calendar_post_event_dates"
|
||||||
|
belongs_to :event
|
||||||
|
|
||||||
|
scope :pending,
|
||||||
|
-> do
|
||||||
|
where(finished_at: nil).joins(:event).where(
|
||||||
|
"discourse_post_event_events.deleted_at is NULL",
|
||||||
|
)
|
||||||
|
end
|
||||||
|
scope :expired, -> { where("ends_at IS NOT NULL AND ends_at < ?", Time.now) }
|
||||||
|
scope :not_expired, -> { where("ends_at IS NULL OR ends_at > ?", Time.now) }
|
||||||
|
|
||||||
|
after_commit :upsert_topic_custom_field, on: %i[create]
|
||||||
|
def upsert_topic_custom_field
|
||||||
|
if self.event.post && self.event.post.is_first_post?
|
||||||
|
TopicCustomField.upsert(
|
||||||
|
{
|
||||||
|
topic_id: self.event.post.topic_id,
|
||||||
|
name: TOPIC_POST_EVENT_STARTS_AT,
|
||||||
|
value: self.starts_at,
|
||||||
|
created_at: Time.now,
|
||||||
|
updated_at: Time.now,
|
||||||
|
},
|
||||||
|
unique_by: "idx_topic_custom_fields_topic_post_event_starts_at",
|
||||||
|
)
|
||||||
|
|
||||||
|
TopicCustomField.upsert(
|
||||||
|
{
|
||||||
|
topic_id: self.event.post.topic_id,
|
||||||
|
name: TOPIC_POST_EVENT_ENDS_AT,
|
||||||
|
value: self.ends_at,
|
||||||
|
created_at: Time.now,
|
||||||
|
updated_at: Time.now,
|
||||||
|
},
|
||||||
|
unique_by: "idx_topic_custom_fields_topic_post_event_ends_at",
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def started?
|
||||||
|
starts_at <= Time.current
|
||||||
|
end
|
||||||
|
|
||||||
|
def ended?
|
||||||
|
(ends_at || starts_at.end_of_day) <= Time.current
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# == Schema Information
|
||||||
|
#
|
||||||
|
# Table name: discourse_calendar_post_event_dates
|
||||||
|
#
|
||||||
|
# id :bigint not null, primary key
|
||||||
|
# event_id :integer
|
||||||
|
# starts_at :datetime
|
||||||
|
# ends_at :datetime
|
||||||
|
# reminder_counter :integer default(0)
|
||||||
|
# event_will_start_sent_at :datetime
|
||||||
|
# event_started_sent_at :datetime
|
||||||
|
# finished_at :datetime
|
||||||
|
# created_at :datetime not null
|
||||||
|
# updated_at :datetime not null
|
||||||
|
#
|
||||||
|
# Indexes
|
||||||
|
#
|
||||||
|
# index_discourse_calendar_post_event_dates_on_event_id (event_id)
|
||||||
|
# index_discourse_calendar_post_event_dates_on_finished_at (finished_at)
|
||||||
|
#
|
|
@ -0,0 +1,90 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module DiscoursePostEvent
|
||||||
|
class Invitee < ActiveRecord::Base
|
||||||
|
UNKNOWN_ATTENDANCE = "unknown"
|
||||||
|
|
||||||
|
self.table_name = "discourse_post_event_invitees"
|
||||||
|
|
||||||
|
belongs_to :event, foreign_key: :post_id
|
||||||
|
belongs_to :user
|
||||||
|
|
||||||
|
default_scope { joins(:user).includes(:user).where("users.id IS NOT NULL") }
|
||||||
|
scope :with_status, ->(status) { where(status: Invitee.statuses[status]) }
|
||||||
|
|
||||||
|
after_commit :sync_chat_channel_members
|
||||||
|
|
||||||
|
def self.statuses
|
||||||
|
@statuses ||= Enum.new(going: 0, interested: 1, not_going: 2)
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.create_attendance!(user_id, post_id, status)
|
||||||
|
invitee =
|
||||||
|
Invitee.create!(status: Invitee.statuses[status.to_sym], post_id: post_id, user_id: user_id)
|
||||||
|
invitee.event.publish_update!
|
||||||
|
invitee.update_topic_tracking!
|
||||||
|
DiscourseEvent.trigger(:discourse_calendar_post_event_invitee_status_changed, invitee)
|
||||||
|
invitee
|
||||||
|
rescue ActiveRecord::RecordNotUnique
|
||||||
|
# do nothing in case multiple new attendances would be created very fast
|
||||||
|
Invitee.find_by(post_id: post_id, user_id: user_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_attendance!(status)
|
||||||
|
new_status = Invitee.statuses[status.to_sym]
|
||||||
|
status_changed = self.status != new_status
|
||||||
|
self.update(status: new_status)
|
||||||
|
self.event.publish_update!
|
||||||
|
self.update_topic_tracking! if status_changed
|
||||||
|
DiscourseEvent.trigger(:discourse_calendar_post_event_invitee_status_changed, self)
|
||||||
|
self
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.extract_uniq_usernames(groups)
|
||||||
|
User.real.where(
|
||||||
|
id: GroupUser.where(group_id: Group.where(name: groups).select(:id)).select(:user_id),
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def sync_chat_channel_members
|
||||||
|
return if !self.event.chat_enabled?
|
||||||
|
ChatChannelSync.sync(self.event)
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_topic_tracking!
|
||||||
|
topic_id = self.event.post.topic.id
|
||||||
|
user_id = self.user.id
|
||||||
|
tracking = :regular
|
||||||
|
|
||||||
|
case self.status
|
||||||
|
when Invitee.statuses[:going]
|
||||||
|
tracking = :watching
|
||||||
|
when Invitee.statuses[:interested]
|
||||||
|
tracking = :tracking
|
||||||
|
end
|
||||||
|
|
||||||
|
TopicUser.change(
|
||||||
|
user_id,
|
||||||
|
topic_id,
|
||||||
|
notification_level: TopicUser.notification_levels[tracking],
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# == Schema Information
|
||||||
|
#
|
||||||
|
# Table name: discourse_post_event_invitees
|
||||||
|
#
|
||||||
|
# id :bigint not null, primary key
|
||||||
|
# post_id :integer not null
|
||||||
|
# user_id :integer not null
|
||||||
|
# status :integer
|
||||||
|
# created_at :datetime not null
|
||||||
|
# updated_at :datetime not null
|
||||||
|
# notified :boolean default(FALSE), not null
|
||||||
|
#
|
||||||
|
# Indexes
|
||||||
|
#
|
||||||
|
# discourse_post_event_invitees_post_id_user_id_idx (post_id,user_id) UNIQUE
|
||||||
|
#
|
|
@ -0,0 +1,156 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module DiscoursePostEvent
|
||||||
|
class EventSerializer < ApplicationSerializer
|
||||||
|
attributes :can_act_on_discourse_post_event
|
||||||
|
attributes :can_update_attendance
|
||||||
|
attributes :category_id
|
||||||
|
attributes :creator
|
||||||
|
attributes :custom_fields
|
||||||
|
attributes :ends_at
|
||||||
|
attributes :id
|
||||||
|
attributes :is_closed
|
||||||
|
attributes :is_expired
|
||||||
|
attributes :is_ongoing
|
||||||
|
attributes :is_private
|
||||||
|
attributes :is_public
|
||||||
|
attributes :is_standalone
|
||||||
|
attributes :minimal
|
||||||
|
attributes :name
|
||||||
|
attributes :post
|
||||||
|
attributes :raw_invitees
|
||||||
|
attributes :recurrence
|
||||||
|
attributes :recurrence_rule
|
||||||
|
attributes :recurrence_until
|
||||||
|
attributes :reminders
|
||||||
|
attributes :sample_invitees
|
||||||
|
attributes :should_display_invitees
|
||||||
|
attributes :starts_at
|
||||||
|
attributes :stats
|
||||||
|
attributes :status
|
||||||
|
attributes :timezone
|
||||||
|
attributes :show_local_time
|
||||||
|
attributes :url
|
||||||
|
attributes :description
|
||||||
|
attributes :location
|
||||||
|
attributes :watching_invitee
|
||||||
|
attributes :chat_enabled
|
||||||
|
attributes :channel
|
||||||
|
|
||||||
|
def channel
|
||||||
|
::Chat::ChannelSerializer.new(object.chat_channel, root: false, scope:)
|
||||||
|
end
|
||||||
|
|
||||||
|
def include_channel?
|
||||||
|
object.chat_enabled && defined?(::Chat::ChannelSerializer) && object.chat_channel.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def can_act_on_discourse_post_event
|
||||||
|
scope.can_act_on_discourse_post_event?(object)
|
||||||
|
end
|
||||||
|
|
||||||
|
def reminders
|
||||||
|
(object.reminders || "")
|
||||||
|
.split(",")
|
||||||
|
.map do |reminder|
|
||||||
|
unit, value, type = reminder.split(".").reverse
|
||||||
|
type ||= "notification"
|
||||||
|
|
||||||
|
value = value.to_i
|
||||||
|
{ value: value.to_i.abs, unit: unit, period: value > 0 ? "before" : "after", type: type }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def is_expired
|
||||||
|
object.expired?
|
||||||
|
end
|
||||||
|
|
||||||
|
def is_ongoing
|
||||||
|
object.ongoing?
|
||||||
|
end
|
||||||
|
|
||||||
|
def is_public
|
||||||
|
object.public?
|
||||||
|
end
|
||||||
|
|
||||||
|
def is_private
|
||||||
|
object.private?
|
||||||
|
end
|
||||||
|
|
||||||
|
def is_standalone
|
||||||
|
object.standalone?
|
||||||
|
end
|
||||||
|
|
||||||
|
def is_closed
|
||||||
|
object.closed
|
||||||
|
end
|
||||||
|
|
||||||
|
def status
|
||||||
|
Event.statuses[object.status]
|
||||||
|
end
|
||||||
|
|
||||||
|
# lightweight post object containing
|
||||||
|
# only needed info for client
|
||||||
|
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 can_update_attendance
|
||||||
|
scope.current_user && object.can_user_update_attendance(scope.current_user)
|
||||||
|
end
|
||||||
|
|
||||||
|
def creator
|
||||||
|
BasicUserSerializer.new(object.post.user, embed: :objects, root: false)
|
||||||
|
end
|
||||||
|
|
||||||
|
def stats
|
||||||
|
EventStatsSerializer.new(object, root: false).as_json
|
||||||
|
end
|
||||||
|
|
||||||
|
def watching_invitee
|
||||||
|
if scope.current_user
|
||||||
|
watching_invitee = Invitee.find_by(user_id: scope.current_user.id, post_id: object.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
InviteeSerializer.new(watching_invitee, root: false) if watching_invitee
|
||||||
|
end
|
||||||
|
|
||||||
|
def sample_invitees
|
||||||
|
invitees = object.most_likely_going
|
||||||
|
ActiveModel::ArraySerializer.new(invitees, each_serializer: InviteeSerializer)
|
||||||
|
end
|
||||||
|
|
||||||
|
def should_display_invitees
|
||||||
|
(object.public? && object.invitees.count > 0) ||
|
||||||
|
(object.private? && object.raw_invitees.count > 0)
|
||||||
|
end
|
||||||
|
|
||||||
|
def category_id
|
||||||
|
object.post.topic.category_id
|
||||||
|
end
|
||||||
|
|
||||||
|
def include_url?
|
||||||
|
object.url.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def include_recurrence_rule?
|
||||||
|
object.recurring?
|
||||||
|
end
|
||||||
|
|
||||||
|
def recurrence_rule
|
||||||
|
RRuleConfigurator.rule(
|
||||||
|
recurrence: object.recurrence,
|
||||||
|
starts_at: object.starts_at.in_time_zone(object.timezone),
|
||||||
|
recurrence_until: object.recurrence_until&.in_time_zone(object.timezone),
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,36 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module DiscoursePostEvent
|
||||||
|
class EventStatsSerializer < ApplicationSerializer
|
||||||
|
attributes :going
|
||||||
|
attributes :interested
|
||||||
|
attributes :not_going
|
||||||
|
attributes :invited
|
||||||
|
|
||||||
|
def invited
|
||||||
|
unanswered = counts[nil] || 0
|
||||||
|
|
||||||
|
# when a group is private we know the list of possible users
|
||||||
|
# even if an invitee has not been created yet
|
||||||
|
unanswered += object.missing_users.count if object.private?
|
||||||
|
|
||||||
|
going + interested + not_going + unanswered
|
||||||
|
end
|
||||||
|
|
||||||
|
def going
|
||||||
|
@going ||= counts[Invitee.statuses[:going]] || 0
|
||||||
|
end
|
||||||
|
|
||||||
|
def interested
|
||||||
|
@interested ||= counts[Invitee.statuses[:interested]] || 0
|
||||||
|
end
|
||||||
|
|
||||||
|
def not_going
|
||||||
|
@not_going ||= counts[Invitee.statuses[:not_going]] || 0
|
||||||
|
end
|
||||||
|
|
||||||
|
def counts
|
||||||
|
@counts ||= object.invitees.group(:status).count
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,62 @@
|
||||||
|
# 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 } }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,32 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module DiscoursePostEvent
|
||||||
|
class InviteeListSerializer < ApplicationSerializer
|
||||||
|
root false
|
||||||
|
attributes :meta
|
||||||
|
has_many :invitees, serializer: InviteeSerializer, embed: :objects
|
||||||
|
|
||||||
|
def invitees
|
||||||
|
object[:invitees]
|
||||||
|
end
|
||||||
|
|
||||||
|
def meta
|
||||||
|
{
|
||||||
|
suggested_users:
|
||||||
|
ActiveModel::ArraySerializer.new(
|
||||||
|
suggested_users,
|
||||||
|
each_serializer: BasicUserSerializer,
|
||||||
|
scope: scope,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def include_meta?
|
||||||
|
suggested_users.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def suggested_users
|
||||||
|
object[:suggested_users]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,28 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module DiscoursePostEvent
|
||||||
|
class InviteeSerializer < ApplicationSerializer
|
||||||
|
attributes :id, :status, :user, :post_id, :meta
|
||||||
|
|
||||||
|
def status
|
||||||
|
object.status ? Invitee.statuses[object.status] : nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def include_id?
|
||||||
|
object.id
|
||||||
|
end
|
||||||
|
|
||||||
|
def user
|
||||||
|
BasicUserSerializer.new(object.user, embed: :objects, root: false)
|
||||||
|
end
|
||||||
|
|
||||||
|
def meta
|
||||||
|
{
|
||||||
|
event_should_display_invitees:
|
||||||
|
(object.event.public? && object.event.invitees.count > 0) ||
|
||||||
|
(object.event.private? && object.event.raw_invitees.count > 0),
|
||||||
|
event_stats: EventStatsSerializer.new(object.event, root: false),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,9 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class UserTimezoneSerializer < BasicUserSerializer
|
||||||
|
attributes :timezone, :on_holiday
|
||||||
|
|
||||||
|
def on_holiday
|
||||||
|
@options[:on_holiday] || false
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,30 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "holidays"
|
||||||
|
|
||||||
|
module DiscourseCalendar
|
||||||
|
class Holiday
|
||||||
|
def self.find_holidays_for(
|
||||||
|
region_code:,
|
||||||
|
start_date: Date.current.beginning_of_year,
|
||||||
|
end_date: Date.current.end_of_year,
|
||||||
|
show_holiday_observed_on_dates: false
|
||||||
|
)
|
||||||
|
holidays =
|
||||||
|
Holidays.between(
|
||||||
|
start_date,
|
||||||
|
end_date,
|
||||||
|
[region_code],
|
||||||
|
show_holiday_observed_on_dates ? :observed : [],
|
||||||
|
)
|
||||||
|
|
||||||
|
holidays.map do |holiday|
|
||||||
|
holiday[:disabled] = DiscourseCalendar::DisabledHoliday.where(
|
||||||
|
region_code: region_code,
|
||||||
|
).exists?(holiday_name: holiday[:name])
|
||||||
|
end
|
||||||
|
|
||||||
|
holidays
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,69 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
#
|
||||||
|
module DiscoursePostEvent
|
||||||
|
class ChatChannelSync
|
||||||
|
def self.sync(event, guardian: nil)
|
||||||
|
return if !event.chat_enabled?
|
||||||
|
if !event.chat_channel_id && guardian&.can_create_chat_channel?
|
||||||
|
ensure_chat_channel!(event, guardian:)
|
||||||
|
end
|
||||||
|
sync_chat_channel_members!(event) if event.chat_channel_id
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.sync_chat_channel_members!(event)
|
||||||
|
missing_members_sql = <<~SQL
|
||||||
|
SELECT user_id
|
||||||
|
FROM discourse_post_event_invitees
|
||||||
|
WHERE post_id = :post_id
|
||||||
|
AND status in (:statuses)
|
||||||
|
AND user_id NOT IN (
|
||||||
|
SELECT user_id
|
||||||
|
FROM user_chat_channel_memberships
|
||||||
|
WHERE chat_channel_id = :chat_channel_id
|
||||||
|
)
|
||||||
|
SQL
|
||||||
|
|
||||||
|
missing_user_ids =
|
||||||
|
DB.query_single(
|
||||||
|
missing_members_sql,
|
||||||
|
post_id: event.post.id,
|
||||||
|
statuses: [
|
||||||
|
DiscoursePostEvent::Invitee.statuses[:going],
|
||||||
|
DiscoursePostEvent::Invitee.statuses[:interested],
|
||||||
|
],
|
||||||
|
chat_channel_id: event.chat_channel_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if missing_user_ids.present?
|
||||||
|
ActiveRecord::Base.transaction do
|
||||||
|
missing_user_ids.each do |user_id|
|
||||||
|
event.chat_channel.user_chat_channel_memberships.create!(
|
||||||
|
user_id:,
|
||||||
|
chat_channel_id: event.chat_channel_id,
|
||||||
|
following: true,
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.ensure_chat_channel!(event, guardian:)
|
||||||
|
name = event.name
|
||||||
|
|
||||||
|
channel = nil
|
||||||
|
Chat::CreateCategoryChannel.call(
|
||||||
|
guardian:,
|
||||||
|
params: {
|
||||||
|
name:,
|
||||||
|
category_id: event.post.topic.category_id,
|
||||||
|
},
|
||||||
|
) do |result|
|
||||||
|
on_success { channel = result.channel }
|
||||||
|
on_failure { raise StandardError, result.inspect_steps }
|
||||||
|
end
|
||||||
|
|
||||||
|
# event creator will be a member of the channel
|
||||||
|
event.chat_channel_id = channel.id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,7 @@
|
||||||
|
import RestAdapter from "discourse/adapters/rest";
|
||||||
|
|
||||||
|
export default class DiscoursePostEventAdapter extends RestAdapter {
|
||||||
|
basePath() {
|
||||||
|
return "/discourse-post-event/";
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { underscore } from "@ember/string";
|
||||||
|
import DiscoursePostEventAdapter from "./discourse-post-event-adapter";
|
||||||
|
|
||||||
|
export default class DiscoursePostEventEvent extends DiscoursePostEventAdapter {
|
||||||
|
pathFor(store, type, findArgs) {
|
||||||
|
const path =
|
||||||
|
this.basePath(store, type, findArgs) +
|
||||||
|
underscore(store.pluralize(this.apiNameFor(type)));
|
||||||
|
return this.appendQueryParams(path, findArgs);
|
||||||
|
}
|
||||||
|
|
||||||
|
apiNameFor() {
|
||||||
|
return "event";
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
import DiscoursePostEventNestedAdapter from "./discourse-post-event-nested-adapter";
|
||||||
|
|
||||||
|
export default class DiscoursePostEventInvitee extends DiscoursePostEventNestedAdapter {
|
||||||
|
apiNameFor() {
|
||||||
|
return "invitee";
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { underscore } from "@ember/string";
|
||||||
|
import { Result } from "discourse/adapters/rest";
|
||||||
|
import { ajax } from "discourse/lib/ajax";
|
||||||
|
import DiscoursePostEventAdapter from "./discourse-post-event-adapter";
|
||||||
|
|
||||||
|
export default class DiscoursePostEventNestedAdapter extends DiscoursePostEventAdapter {
|
||||||
|
// TODO: destroy/update/create should be improved in core to allow for nested models
|
||||||
|
destroyRecord(store, type, record) {
|
||||||
|
return ajax(
|
||||||
|
this.pathFor(store, type, {
|
||||||
|
post_id: record.post_id,
|
||||||
|
id: record.id,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
type: "DELETE",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(store, type, id, attrs) {
|
||||||
|
const data = {};
|
||||||
|
const typeField = underscore(this.apiNameFor(type));
|
||||||
|
data[typeField] = attrs;
|
||||||
|
|
||||||
|
return ajax(
|
||||||
|
this.pathFor(store, type, { id, post_id: attrs.post_id }),
|
||||||
|
this.getPayload("PUT", data)
|
||||||
|
).then(function (json) {
|
||||||
|
return new Result(json[typeField], json);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
createRecord(store, type, attrs) {
|
||||||
|
const data = {};
|
||||||
|
const typeField = underscore(this.apiNameFor(type));
|
||||||
|
data[typeField] = attrs;
|
||||||
|
return ajax(
|
||||||
|
this.pathFor(store, type, attrs),
|
||||||
|
this.getPayload("POST", data)
|
||||||
|
).then(function (json) {
|
||||||
|
return new Result(json[typeField], json);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pathFor(store, type, findArgs) {
|
||||||
|
const post_id = findArgs["post_id"];
|
||||||
|
delete findArgs["post_id"];
|
||||||
|
|
||||||
|
const id = findArgs["id"];
|
||||||
|
delete findArgs["id"];
|
||||||
|
|
||||||
|
let path =
|
||||||
|
this.basePath(store, type, {}) +
|
||||||
|
"events/" +
|
||||||
|
post_id +
|
||||||
|
"/" +
|
||||||
|
underscore(store.pluralize(this.apiNameFor()));
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
path += `/${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.appendQueryParams(path, findArgs);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
import DiscoursePostEventNestedAdapter from "./discourse-post-event-nested-adapter";
|
||||||
|
|
||||||
|
export default class DiscoursePostEventReminder extends DiscoursePostEventNestedAdapter {
|
||||||
|
apiNameFor() {
|
||||||
|
return "reminder";
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
export default {
|
||||||
|
resource: "admin.adminPlugins",
|
||||||
|
path: "/plugins",
|
||||||
|
map() {
|
||||||
|
this.route("calendar");
|
||||||
|
},
|
||||||
|
};
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { apiInitializer } from "discourse/lib/api";
|
||||||
|
import GroupTimezones from "../components/group-timezones";
|
||||||
|
|
||||||
|
const GroupTimezonesShim = <template>
|
||||||
|
<GroupTimezones
|
||||||
|
@members={{@data.members}}
|
||||||
|
@group={{@data.group}}
|
||||||
|
@size={{@data.size}}
|
||||||
|
/>
|
||||||
|
</template>;
|
||||||
|
|
||||||
|
export default apiInitializer((api) => {
|
||||||
|
api.decorateCookedElement((element, helper) => {
|
||||||
|
element.querySelectorAll(".group-timezones").forEach((el) => {
|
||||||
|
const post = helper.getModel();
|
||||||
|
|
||||||
|
if (!post) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const group = el.dataset.group;
|
||||||
|
if (!group) {
|
||||||
|
throw new Error(
|
||||||
|
"Group timezone element is missing 'data-group' attribute"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
helper.renderGlimmer(el, GroupTimezonesShim, {
|
||||||
|
group,
|
||||||
|
members: (post.group_timezones || {})[group] || [],
|
||||||
|
size: el.dataset.size || "medium",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,69 @@
|
||||||
|
import Component from "@ember/component";
|
||||||
|
import { action } from "@ember/object";
|
||||||
|
import { classNameBindings, tagName } from "@ember-decorators/component";
|
||||||
|
import DButton from "discourse/components/d-button";
|
||||||
|
import { ajax } from "discourse/lib/ajax";
|
||||||
|
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||||
|
|
||||||
|
@tagName("tr")
|
||||||
|
@classNameBindings("isHolidayDisabled:disabled")
|
||||||
|
export default class AdminHolidaysListItem extends Component {
|
||||||
|
loading = false;
|
||||||
|
isHolidayDisabled = false;
|
||||||
|
|
||||||
|
@action
|
||||||
|
disableHoliday(holiday, region_code) {
|
||||||
|
if (this.loading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.set("loading", true);
|
||||||
|
|
||||||
|
return ajax({
|
||||||
|
url: `/admin/discourse-calendar/holidays/disable`,
|
||||||
|
type: "POST",
|
||||||
|
data: { disabled_holiday: { holiday_name: holiday.name, region_code } },
|
||||||
|
})
|
||||||
|
.then(() => this.set("isHolidayDisabled", true))
|
||||||
|
.catch(popupAjaxError)
|
||||||
|
.finally(() => this.set("loading", false));
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
enableHoliday(holiday, region_code) {
|
||||||
|
if (this.loading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.set("loading", true);
|
||||||
|
|
||||||
|
return ajax({
|
||||||
|
url: `/admin/discourse-calendar/holidays/enable`,
|
||||||
|
type: "DELETE",
|
||||||
|
data: { disabled_holiday: { holiday_name: holiday.name, region_code } },
|
||||||
|
})
|
||||||
|
.then(() => this.set("isHolidayDisabled", false))
|
||||||
|
.catch(popupAjaxError)
|
||||||
|
.finally(() => this.set("loading", false));
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<td>{{this.holiday.date}}</td>
|
||||||
|
<td>{{this.holiday.name}}</td>
|
||||||
|
<td>
|
||||||
|
{{#if this.isHolidayDisabled}}
|
||||||
|
<DButton
|
||||||
|
{{! template-lint-disable no-action }}
|
||||||
|
@action={{action "enableHoliday" this.holiday this.region_code}}
|
||||||
|
@label="discourse_calendar.enable_holiday"
|
||||||
|
/>
|
||||||
|
{{else}}
|
||||||
|
<DButton
|
||||||
|
{{! template-lint-disable no-action }}
|
||||||
|
@action={{action "disableHoliday" this.holiday this.region_code}}
|
||||||
|
@label="discourse_calendar.disable_holiday"
|
||||||
|
/>
|
||||||
|
{{/if}}
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
|
}
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { i18n } from "discourse-i18n";
|
||||||
|
import AdminHolidaysListItem from "./admin-holidays-list-item";
|
||||||
|
|
||||||
|
const AdminHolidaysList = <template>
|
||||||
|
<table class="holidays-list">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<td>{{i18n "discourse_calendar.date"}}</td>
|
||||||
|
<td colspan="2">{{i18n "discourse_calendar.holiday"}}</td>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
{{#each @holidays as |holiday|}}
|
||||||
|
<AdminHolidaysListItem
|
||||||
|
@holiday={{holiday}}
|
||||||
|
@isHolidayDisabled={{holiday.disabled}}
|
||||||
|
@region_code={{@region_code}}
|
||||||
|
/>
|
||||||
|
{{/each}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</template>;
|
||||||
|
|
||||||
|
export default AdminHolidaysList;
|
|
@ -0,0 +1,36 @@
|
||||||
|
import Component from "@ember/component";
|
||||||
|
import { action } from "@ember/object";
|
||||||
|
import DButton from "discourse/components/d-button";
|
||||||
|
|
||||||
|
export default class BulkInviteSampleCsvFile extends Component {
|
||||||
|
@action
|
||||||
|
downloadSampleCsv() {
|
||||||
|
const sampleData = [
|
||||||
|
["my_awesome_group", "going"],
|
||||||
|
["lucy", "interested"],
|
||||||
|
["mark", "not_going"],
|
||||||
|
["sam", "unknown"],
|
||||||
|
];
|
||||||
|
|
||||||
|
let csv = "";
|
||||||
|
sampleData.forEach((row) => {
|
||||||
|
csv += row.join(",");
|
||||||
|
csv += "\n";
|
||||||
|
});
|
||||||
|
|
||||||
|
const btn = document.createElement("a");
|
||||||
|
btn.href = `data:text/csv;charset=utf-8,${encodeURI(csv)}`;
|
||||||
|
btn.target = "_blank";
|
||||||
|
btn.rel = "noopener noreferrer";
|
||||||
|
btn.download = "bulk-invite-sample.csv";
|
||||||
|
btn.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DButton
|
||||||
|
@label="discourse_post_event.bulk_invite_modal.download_sample_csv"
|
||||||
|
{{! template-lint-disable no-action }}
|
||||||
|
@action={{action "downloadSampleCsv"}}
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
}
|
|
@ -0,0 +1,64 @@
|
||||||
|
import Component from "@glimmer/component";
|
||||||
|
import { getOwner } from "@ember/owner";
|
||||||
|
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
|
||||||
|
import { service } from "@ember/service";
|
||||||
|
import { or } from "truth-helpers";
|
||||||
|
import icon from "discourse/helpers/d-icon";
|
||||||
|
import UppyUpload from "discourse/lib/uppy/uppy-upload";
|
||||||
|
import { i18n } from "discourse-i18n";
|
||||||
|
|
||||||
|
export default class CsvUploader extends Component {
|
||||||
|
@service dialog;
|
||||||
|
|
||||||
|
uppyUpload = new UppyUpload(getOwner(this), {
|
||||||
|
type: "csv",
|
||||||
|
id: "discourse-post-event-csv-uploader",
|
||||||
|
autoStartUploads: false,
|
||||||
|
uploadUrl: this.args.uploadUrl,
|
||||||
|
uppyReady: () => {
|
||||||
|
this.uppyUpload.uppyWrapper.uppyInstance.on("file-added", () => {
|
||||||
|
this.dialog.confirm({
|
||||||
|
message: i18n(`${this.args.i18nPrefix}.confirmation_message`),
|
||||||
|
didConfirm: () => this.uppyUpload.startUpload(),
|
||||||
|
didCancel: () => this.uppyUpload.reset(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
uploadDone: () => {
|
||||||
|
this.dialog.alert(i18n(`${this.args.i18nPrefix}.success`));
|
||||||
|
},
|
||||||
|
validateUploadedFilesOptions: {
|
||||||
|
csvOnly: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
get uploadButtonText() {
|
||||||
|
return this.uppyUpload.uploading
|
||||||
|
? i18n("uploading")
|
||||||
|
: i18n(`${this.args.i18nPrefix}.text`);
|
||||||
|
}
|
||||||
|
|
||||||
|
get uploadButtonDisabled() {
|
||||||
|
// https://github.com/emberjs/ember.js/issues/10976#issuecomment-132417731
|
||||||
|
return this.uppyUpload.uploading || this.uppyUpload.processing || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span>
|
||||||
|
<label class="btn" disabled={{this.uploadButtonDisabled}}>
|
||||||
|
{{icon "upload"}} {{this.uploadButtonText}}
|
||||||
|
<input
|
||||||
|
{{didInsert this.uppyUpload.setup}}
|
||||||
|
class="hidden-upload-field"
|
||||||
|
disabled={{this.uppyUpload.uploading}}
|
||||||
|
type="file"
|
||||||
|
accept=".csv"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{{#if (or this.uppyUpload.uploading this.uppyUpload.processing)}}
|
||||||
|
<span>{{i18n "upload_selector.uploading"}}
|
||||||
|
{{this.uppyUpload.uploadProgress}}%</span>
|
||||||
|
{{/if}}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
}
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { LinkTo } from "@ember/routing";
|
||||||
|
import { and } from "truth-helpers";
|
||||||
|
import { optionalRequire } from "discourse/lib/utilities";
|
||||||
|
|
||||||
|
const ChannelTitle = optionalRequire(
|
||||||
|
"discourse/plugins/chat/discourse/components/channel-title"
|
||||||
|
);
|
||||||
|
|
||||||
|
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}}>
|
||||||
|
<ChannelTitle @channel={{@event.channel}} />
|
||||||
|
</LinkTo>
|
||||||
|
</section>
|
||||||
|
{{/if}}
|
||||||
|
</template>;
|
||||||
|
|
||||||
|
export default DiscoursePostEventChatChannel;
|
|
@ -0,0 +1,23 @@
|
||||||
|
import Component from "@glimmer/component";
|
||||||
|
import avatar from "discourse/helpers/avatar";
|
||||||
|
import { formatUsername } from "discourse/lib/utilities";
|
||||||
|
import { i18n } from "discourse-i18n";
|
||||||
|
|
||||||
|
export default class DiscoursePostEventCreator extends Component {
|
||||||
|
get username() {
|
||||||
|
return this.args.user.name || formatUsername(this.args.user.username);
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span class="creators">
|
||||||
|
<span class="created-by">{{i18n "discourse_post_event.created_by"}}</span>
|
||||||
|
|
||||||
|
<span class="event-creator">
|
||||||
|
<a class="topic-invitee-avatar" data-user-card={{@user.username}}>
|
||||||
|
{{avatar @user imageSize="tiny"}}
|
||||||
|
<span class="username">{{this.username}}</span>
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
}
|
|
@ -0,0 +1,154 @@
|
||||||
|
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 { service } from "@ember/service";
|
||||||
|
import { htmlSafe } from "@ember/template";
|
||||||
|
import icon from "discourse/helpers/d-icon";
|
||||||
|
import { applyLocalDates } from "discourse/lib/local-dates";
|
||||||
|
import { cook } from "discourse/lib/text";
|
||||||
|
|
||||||
|
export default class DiscoursePostEventDates extends Component {
|
||||||
|
@service siteSettings;
|
||||||
|
|
||||||
|
@tracked htmlDates = "";
|
||||||
|
|
||||||
|
get startsAt() {
|
||||||
|
return moment(this.args.event.startsAt).tz(this.timezone);
|
||||||
|
}
|
||||||
|
|
||||||
|
get endsAt() {
|
||||||
|
return (
|
||||||
|
this.args.event.endsAt && moment(this.args.event.endsAt).tz(this.timezone)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get timezone() {
|
||||||
|
return this.args.event.timezone || "UTC";
|
||||||
|
}
|
||||||
|
|
||||||
|
get startsAtFormat() {
|
||||||
|
return this._buildFormat(this.startsAt, {
|
||||||
|
includeYear: !this.isSameYear(this.startsAt),
|
||||||
|
includeTime: this.hasTime(this.startsAt) || this.isSingleDayEvent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get endsAtFormat() {
|
||||||
|
if (this.isSingleDayEvent) {
|
||||||
|
return "LT";
|
||||||
|
}
|
||||||
|
|
||||||
|
return this._buildFormat(this.endsAt, {
|
||||||
|
includeYear:
|
||||||
|
!this.isSameYear(this.endsAt) ||
|
||||||
|
!this.isSameYear(this.endsAt, this.startsAt),
|
||||||
|
includeTime: this.hasTime(this.endsAt),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildFormat(date, { includeYear, includeTime }) {
|
||||||
|
const formatParts = ["ddd, MMM D"];
|
||||||
|
if (includeYear) {
|
||||||
|
formatParts.push("YYYY");
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateString = formatParts.join(", ");
|
||||||
|
const timeString = includeTime ? " LT" : "";
|
||||||
|
|
||||||
|
return `\u0022${dateString}${timeString}\u0022`;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isSingleDayEvent() {
|
||||||
|
return this.startsAt.isSame(this.endsAt, "day");
|
||||||
|
}
|
||||||
|
|
||||||
|
get datesBBCode() {
|
||||||
|
const dates = [];
|
||||||
|
|
||||||
|
dates.push(
|
||||||
|
this.buildDateBBCode({
|
||||||
|
date: this.startsAt,
|
||||||
|
format: this.startsAtFormat,
|
||||||
|
range: !!this.endsAt && "from",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (this.endsAt) {
|
||||||
|
dates.push(
|
||||||
|
this.buildDateBBCode({
|
||||||
|
date: this.endsAt,
|
||||||
|
format: this.endsAtFormat,
|
||||||
|
range: "to",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return dates;
|
||||||
|
}
|
||||||
|
|
||||||
|
isSameYear(date1, date2) {
|
||||||
|
return date1.isSame(date2 || moment(), "year");
|
||||||
|
}
|
||||||
|
|
||||||
|
hasTime(date) {
|
||||||
|
return date.hour() || date.minute();
|
||||||
|
}
|
||||||
|
|
||||||
|
buildDateBBCode({ date, format, range }) {
|
||||||
|
const bbcode = {
|
||||||
|
date: date.format("YYYY-MM-DD"),
|
||||||
|
time: date.format("HH:mm"),
|
||||||
|
format,
|
||||||
|
timezone: this.timezone,
|
||||||
|
hideTimezone: this.args.event.showLocalTime,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.args.event.showLocalTime) {
|
||||||
|
bbcode.displayedTimezone = this.args.event.timezone;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (range) {
|
||||||
|
bbcode.range = range;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = Object.entries(bbcode)
|
||||||
|
.map(([key, value]) => `${key}=${value}`)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
return `[${content}]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
async computeDates(element) {
|
||||||
|
if (this.siteSettings.discourse_local_dates_enabled) {
|
||||||
|
const result = await cook(this.datesBBCode.join("<span> → </span>"));
|
||||||
|
this.htmlDates = htmlSafe(result.toString());
|
||||||
|
|
||||||
|
next(() => {
|
||||||
|
if (this.isDestroying || this.isDestroyed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
applyLocalDates(
|
||||||
|
element.querySelectorAll(
|
||||||
|
`[data-post-id="${this.args.event.id}"] .discourse-local-date`
|
||||||
|
),
|
||||||
|
this.siteSettings
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
let dates = `${this.startsAt.format(this.startsAtFormat)}`;
|
||||||
|
if (this.endsAt) {
|
||||||
|
dates += ` → ${moment(this.endsAt).format(this.endsAtFormat)}`;
|
||||||
|
}
|
||||||
|
this.htmlDates = htmlSafe(dates);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="event__section event-dates" {{didInsert this.computeDates}}>
|
||||||
|
{{icon "clock"}}{{this.htmlDates}}</section>
|
||||||
|
</template>
|
||||||
|
}
|
|
@ -0,0 +1,11 @@
|
||||||
|
import CookText from "discourse/components/cook-text";
|
||||||
|
|
||||||
|
const DiscoursePostEventDescription = <template>
|
||||||
|
{{#if @description}}
|
||||||
|
<section class="event__section event-description">
|
||||||
|
<CookText @rawText={{@description}} />
|
||||||
|
</section>
|
||||||
|
{{/if}}
|
||||||
|
</template>;
|
||||||
|
|
||||||
|
export default DiscoursePostEventDescription;
|
|
@ -0,0 +1,36 @@
|
||||||
|
import Component from "@glimmer/component";
|
||||||
|
import { i18n } from "discourse-i18n";
|
||||||
|
|
||||||
|
export default class EventStatus extends Component {
|
||||||
|
get eventStatusLabel() {
|
||||||
|
return i18n(
|
||||||
|
`discourse_post_event.models.event.status.${this.args.event.status}.title`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get eventStatusDescription() {
|
||||||
|
return i18n(
|
||||||
|
`discourse_post_event.models.event.status.${this.args.event.status}.description`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get statusClass() {
|
||||||
|
return `status ${this.args.event.status}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
{{#if @event.isExpired}}
|
||||||
|
<span class="status expired">
|
||||||
|
{{i18n "discourse_post_event.models.event.expired"}}
|
||||||
|
</span>
|
||||||
|
{{else if @event.isClosed}}
|
||||||
|
<span class="status closed">
|
||||||
|
{{i18n "discourse_post_event.models.event.closed"}}
|
||||||
|
</span>
|
||||||
|
{{else}}
|
||||||
|
<span class={{this.statusClass}} title={{this.eventStatusDescription}}>
|
||||||
|
{{this.eventStatusLabel}}
|
||||||
|
</span>
|
||||||
|
{{/if}}
|
||||||
|
</template>
|
||||||
|
}
|
|
@ -0,0 +1,157 @@
|
||||||
|
import Component from "@glimmer/component";
|
||||||
|
import { service } from "@ember/service";
|
||||||
|
import { modifier } from "ember-modifier";
|
||||||
|
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 ChatChannel from "./chat-channel";
|
||||||
|
import Creator from "./creator";
|
||||||
|
import Dates from "./dates";
|
||||||
|
import Description from "./description";
|
||||||
|
import EventStatus from "./event-status";
|
||||||
|
import Invitees from "./invitees";
|
||||||
|
import Location from "./location";
|
||||||
|
import MoreMenu from "./more-menu";
|
||||||
|
import Status from "./status";
|
||||||
|
import Url from "./url";
|
||||||
|
|
||||||
|
const StatusSeparator = <template>
|
||||||
|
<span class="separator">·</span>
|
||||||
|
</template>;
|
||||||
|
|
||||||
|
const InfoSection = <template>
|
||||||
|
<section class="event__section" ...attributes>
|
||||||
|
{{#if @icon}}
|
||||||
|
{{icon @icon}}
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{yield}}
|
||||||
|
</section>
|
||||||
|
</template>;
|
||||||
|
|
||||||
|
export default class DiscoursePostEvent extends Component {
|
||||||
|
@service currentUser;
|
||||||
|
@service discoursePostEventApi;
|
||||||
|
@service messageBus;
|
||||||
|
|
||||||
|
setupMessageBus = modifier(() => {
|
||||||
|
const { event } = this.args;
|
||||||
|
const path = `/discourse-post-event/${event.post.topic.id}`;
|
||||||
|
this.messageBus.subscribe(path, async (msg) => {
|
||||||
|
const eventData = await this.discoursePostEventApi.event(msg.id);
|
||||||
|
event.updateFromEvent(eventData);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => this.messageBus.unsubscribe(path);
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
return time;
|
||||||
|
}
|
||||||
|
|
||||||
|
get startsAtMonth() {
|
||||||
|
return this.localStartsAtTime.format("MMM");
|
||||||
|
}
|
||||||
|
|
||||||
|
get startsAtDay() {
|
||||||
|
return this.localStartsAtTime.format("D");
|
||||||
|
}
|
||||||
|
|
||||||
|
get eventName() {
|
||||||
|
return this.args.event.name || this.args.event.post.topic.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isPublicEvent() {
|
||||||
|
return this.args.event.status === "public";
|
||||||
|
}
|
||||||
|
|
||||||
|
get isStandaloneEvent() {
|
||||||
|
return this.args.event.status === "standalone";
|
||||||
|
}
|
||||||
|
|
||||||
|
get canActOnEvent() {
|
||||||
|
return this.currentUser && this.args.event.can_act_on_discourse_post_event;
|
||||||
|
}
|
||||||
|
|
||||||
|
get watchingInviteeStatus() {
|
||||||
|
return this.args.event.watchingInvitee?.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<MoreMenu
|
||||||
|
@event={{@event}}
|
||||||
|
@isStandaloneEvent={{this.isStandaloneEvent}}
|
||||||
|
@composePrivateMessage={{routeAction "composePrivateMessage"}}
|
||||||
|
/>
|
||||||
|
</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}} />
|
||||||
|
<Description @description={{@event.description}} />
|
||||||
|
{{#if @event.canUpdateAttendance}}
|
||||||
|
<Status @event={{@event}} />
|
||||||
|
{{/if}}
|
||||||
|
</PluginOutlet>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
}
|
|
@ -0,0 +1,53 @@
|
||||||
|
import Component from "@glimmer/component";
|
||||||
|
import { concat } from "@ember/helper";
|
||||||
|
import { service } from "@ember/service";
|
||||||
|
import { eq } from "truth-helpers";
|
||||||
|
import AvatarFlair from "discourse/components/avatar-flair";
|
||||||
|
import avatar from "discourse/helpers/avatar";
|
||||||
|
import concatClass from "discourse/helpers/concat-class";
|
||||||
|
import { i18n } from "discourse-i18n";
|
||||||
|
|
||||||
|
export default class DiscoursePostEventInvitee extends Component {
|
||||||
|
@service site;
|
||||||
|
@service currentUser;
|
||||||
|
|
||||||
|
get statusIcon() {
|
||||||
|
switch (this.args.invitee.status) {
|
||||||
|
case "going":
|
||||||
|
return "check";
|
||||||
|
case "interested":
|
||||||
|
return "star";
|
||||||
|
case "not_going":
|
||||||
|
return "xmark";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get flairName() {
|
||||||
|
const string = `discourse_post_event.models.invitee.status.${this.args.invitee.status}`;
|
||||||
|
|
||||||
|
return i18n(string);
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<li
|
||||||
|
class={{concatClass
|
||||||
|
"event-invitee"
|
||||||
|
(if @invitee.status (concat "status-" @invitee.status))
|
||||||
|
(if (eq this.currentUser.id @invitee.user.id) "is-current-user")
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<a class="topic-invitee-avatar" data-user-card={{@invitee.user.username}}>
|
||||||
|
{{avatar
|
||||||
|
@invitee.user
|
||||||
|
imageSize=(if this.site.mobileView "tiny" "large")
|
||||||
|
}}
|
||||||
|
{{#if this.statusIcon}}
|
||||||
|
<AvatarFlair
|
||||||
|
@flairName={{this.flairName}}
|
||||||
|
@flairUrl={{this.statusIcon}}
|
||||||
|
/>
|
||||||
|
{{/if}}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
}
|
|
@ -0,0 +1,55 @@
|
||||||
|
import Component from "@glimmer/component";
|
||||||
|
import { service } from "@ember/service";
|
||||||
|
import icon from "discourse/helpers/d-icon";
|
||||||
|
import { i18n } from "discourse-i18n";
|
||||||
|
import Invitee from "./invitee";
|
||||||
|
|
||||||
|
export default class DiscoursePostEventInvitees extends Component {
|
||||||
|
@service modal;
|
||||||
|
@service siteSettings;
|
||||||
|
|
||||||
|
get hasAttendees() {
|
||||||
|
return this.args.event.stats.going > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
get statsInfo() {
|
||||||
|
return this.args.event.stats.going;
|
||||||
|
}
|
||||||
|
|
||||||
|
get inviteesTitle() {
|
||||||
|
return i18n("discourse_post_event.models.invitee.status.going_count", {
|
||||||
|
count: this.args.event.stats.going,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
{{#unless @event.minimal}}
|
||||||
|
{{#if @event.shouldDisplayInvitees}}
|
||||||
|
<section class="event__section event-invitees">
|
||||||
|
<div class="event-invitees-avatars-container">
|
||||||
|
<div class="event-invitees-icon" title={{this.inviteesTitle}}>
|
||||||
|
{{icon "users"}}
|
||||||
|
{{#if this.hasAttendees}}
|
||||||
|
<span class="going">{{this.statsInfo}}</span>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
<ul class="event-invitees-avatars">
|
||||||
|
{{#each @event.sampleInvitees as |invitee|}}
|
||||||
|
<Invitee @invitee={{invitee}} />
|
||||||
|
{{/each}}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{{else}}
|
||||||
|
{{#unless @event.isStandalone}}
|
||||||
|
<section class="event__section event-invitees no-rsvp">
|
||||||
|
<p class="no-rsvp-description">{{i18n
|
||||||
|
"discourse_post_event.models.invitee.status.going_count.other"
|
||||||
|
count="0"
|
||||||
|
}}</p>
|
||||||
|
</section>
|
||||||
|
{{/unless}}
|
||||||
|
{{/if}}
|
||||||
|
{{/unless}}
|
||||||
|
</template>
|
||||||
|
}
|
|
@ -0,0 +1,14 @@
|
||||||
|
import CookText from "discourse/components/cook-text";
|
||||||
|
import icon from "discourse/helpers/d-icon";
|
||||||
|
|
||||||
|
const DiscoursePostEventLocation = <template>
|
||||||
|
{{#if @location}}
|
||||||
|
<section class="event__section event-location">
|
||||||
|
{{icon "location-pin"}}
|
||||||
|
|
||||||
|
<CookText @rawText={{@location}} />
|
||||||
|
</section>
|
||||||
|
{{/if}}
|
||||||
|
</template>;
|
||||||
|
|
||||||
|
export default DiscoursePostEventLocation;
|
|
@ -0,0 +1,382 @@
|
||||||
|
import Component from "@glimmer/component";
|
||||||
|
import { tracked } from "@glimmer/tracking";
|
||||||
|
import { hash } from "@ember/helper";
|
||||||
|
import EmberObject, { action } from "@ember/object";
|
||||||
|
import { service } from "@ember/service";
|
||||||
|
import DButton from "discourse/components/d-button";
|
||||||
|
import DropdownMenu from "discourse/components/dropdown-menu";
|
||||||
|
import concatClass from "discourse/helpers/concat-class";
|
||||||
|
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||||
|
import { downloadCalendar } from "discourse/lib/download-calendar";
|
||||||
|
import { exportEntity } from "discourse/lib/export-csv";
|
||||||
|
import { getAbsoluteURL } from "discourse/lib/get-url";
|
||||||
|
import { cook } from "discourse/lib/text";
|
||||||
|
import { applyValueTransformer } from "discourse/lib/transformer";
|
||||||
|
import { i18n } from "discourse-i18n";
|
||||||
|
import DMenu from "float-kit/components/d-menu";
|
||||||
|
import { buildParams, replaceRaw } from "../../lib/raw-event-helper";
|
||||||
|
import PostEventBuilder from "../modal/post-event-builder";
|
||||||
|
import PostEventBulkInvite from "../modal/post-event-bulk-invite";
|
||||||
|
import PostEventInviteUserOrGroup from "../modal/post-event-invite-user-or-group";
|
||||||
|
import PostEventInvitees from "../modal/post-event-invitees";
|
||||||
|
|
||||||
|
export default class DiscoursePostEventMoreMenu extends Component {
|
||||||
|
@service currentUser;
|
||||||
|
@service dialog;
|
||||||
|
@service discoursePostEventApi;
|
||||||
|
@service modal;
|
||||||
|
@service router;
|
||||||
|
@service siteSettings;
|
||||||
|
@service store;
|
||||||
|
|
||||||
|
@tracked isSavingEvent = false;
|
||||||
|
|
||||||
|
get expiredOrClosed() {
|
||||||
|
return this.args.event.isExpired || this.args.event.isClosed;
|
||||||
|
}
|
||||||
|
|
||||||
|
get canActOnEvent() {
|
||||||
|
return this.currentUser && this.args.event.canActOnDiscoursePostEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
get shouldShowParticipants() {
|
||||||
|
return applyValueTransformer(
|
||||||
|
"discourse-calendar-event-more-menu-should-show-participants",
|
||||||
|
this.canActOnEvent && !this.args.isStandaloneEvent,
|
||||||
|
{
|
||||||
|
event: this.args.event,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get canInvite() {
|
||||||
|
return (
|
||||||
|
!this.expiredOrClosed && this.canActOnEvent && this.args.event.isPublic
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get canSeeUpcomingEvents() {
|
||||||
|
return !this.args.event.isClosed && this.args.event.recurrence;
|
||||||
|
}
|
||||||
|
|
||||||
|
get canBulkInvite() {
|
||||||
|
return !this.expiredOrClosed && !this.args.event.isStandalone;
|
||||||
|
}
|
||||||
|
|
||||||
|
get canSendPmToCreator() {
|
||||||
|
return (
|
||||||
|
this.currentUser &&
|
||||||
|
this.currentUser.username !== this.args.event.creator.username
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
addToCalendar() {
|
||||||
|
this.menuApi.close();
|
||||||
|
|
||||||
|
const event = this.args.event;
|
||||||
|
|
||||||
|
downloadCalendar(
|
||||||
|
event.name || event.post.topic.title,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
startsAt: event.startsAt,
|
||||||
|
endsAt: event.endsAt,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{
|
||||||
|
recurrenceRule: event.recurrenceRule,
|
||||||
|
location: event.url,
|
||||||
|
details: getAbsoluteURL(event.post.url),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
sendPMToCreator() {
|
||||||
|
this.menuApi.close();
|
||||||
|
|
||||||
|
this.args.composePrivateMessage(
|
||||||
|
EmberObject.create(this.args.event.creator),
|
||||||
|
EmberObject.create(this.args.event.post)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
upcomingEvents() {
|
||||||
|
this.router.transitionTo("discourse-post-event-upcoming-events");
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
registerMenuApi(api) {
|
||||||
|
this.menuApi = api;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
async inviteUserOrGroup() {
|
||||||
|
this.menuApi.close();
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.modal.show(PostEventInviteUserOrGroup, {
|
||||||
|
model: { event: this.args.event },
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
popupAjaxError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
exportPostEvent() {
|
||||||
|
this.menuApi.close();
|
||||||
|
|
||||||
|
exportEntity("post_event", {
|
||||||
|
name: "post_event",
|
||||||
|
id: this.args.event.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
bulkInvite() {
|
||||||
|
this.menuApi.close();
|
||||||
|
|
||||||
|
this.modal.show(PostEventBulkInvite, {
|
||||||
|
model: { event: this.args.event },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
async openEvent() {
|
||||||
|
this.menuApi.close();
|
||||||
|
|
||||||
|
this.dialog.yesNoConfirm({
|
||||||
|
message: i18n("discourse_post_event.builder_modal.confirm_open"),
|
||||||
|
didConfirm: async () => {
|
||||||
|
this.isSavingEvent = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const post = await this.store.find("post", this.args.event.id);
|
||||||
|
this.args.event.isClosed = false;
|
||||||
|
|
||||||
|
const eventParams = buildParams(
|
||||||
|
this.args.event.startsAt,
|
||||||
|
this.args.event.endsAt,
|
||||||
|
this.args.event,
|
||||||
|
this.siteSettings
|
||||||
|
);
|
||||||
|
|
||||||
|
const newRaw = replaceRaw(eventParams, post.raw);
|
||||||
|
|
||||||
|
if (newRaw) {
|
||||||
|
const props = {
|
||||||
|
raw: newRaw,
|
||||||
|
edit_reason: i18n("discourse_post_event.edit_reason_opened"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const cooked = await cook(newRaw);
|
||||||
|
props.cooked = cooked.string;
|
||||||
|
await post.save(props);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
popupAjaxError(e);
|
||||||
|
} finally {
|
||||||
|
this.isSavingEvent = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
async editPostEvent() {
|
||||||
|
this.menuApi.close();
|
||||||
|
|
||||||
|
this.modal.show(PostEventBuilder, {
|
||||||
|
model: {
|
||||||
|
event: this.args.event,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
showParticipants() {
|
||||||
|
this.menuApi.close();
|
||||||
|
|
||||||
|
this.modal.show(PostEventInvitees, {
|
||||||
|
model: {
|
||||||
|
event: this.args.event,
|
||||||
|
title: this.args.event.title,
|
||||||
|
extraClass: this.args.event.extraClass,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
async closeEvent() {
|
||||||
|
this.menuApi.close();
|
||||||
|
|
||||||
|
this.dialog.yesNoConfirm({
|
||||||
|
message: i18n("discourse_post_event.builder_modal.confirm_close"),
|
||||||
|
didConfirm: () => {
|
||||||
|
this.isSavingEvent = true;
|
||||||
|
return this.store.find("post", this.args.event.id).then((post) => {
|
||||||
|
this.args.event.isClosed = true;
|
||||||
|
|
||||||
|
const eventParams = buildParams(
|
||||||
|
this.args.event.startsAt,
|
||||||
|
this.args.event.endsAt,
|
||||||
|
this.args.event,
|
||||||
|
this.siteSettings
|
||||||
|
);
|
||||||
|
|
||||||
|
const newRaw = replaceRaw(eventParams, post.raw);
|
||||||
|
|
||||||
|
if (newRaw) {
|
||||||
|
const props = {
|
||||||
|
raw: newRaw,
|
||||||
|
edit_reason: i18n("discourse_post_event.edit_reason_closed"),
|
||||||
|
};
|
||||||
|
|
||||||
|
return cook(newRaw)
|
||||||
|
.then((cooked) => {
|
||||||
|
props.cooked = cooked.string;
|
||||||
|
return post.save(props);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.isSavingEvent = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DMenu
|
||||||
|
@identifier="discourse-post-event-more-menu"
|
||||||
|
@triggerClass={{concatClass
|
||||||
|
"more-dropdown"
|
||||||
|
(if this.isSavingEvent "--saving")
|
||||||
|
}}
|
||||||
|
@icon="ellipsis"
|
||||||
|
@onRegisterApi={{this.registerMenuApi}}
|
||||||
|
>
|
||||||
|
<:content>
|
||||||
|
<DropdownMenu as |dropdown|>
|
||||||
|
{{#unless this.expiredOrClosed}}
|
||||||
|
<dropdown.item class="add-to-calendar">
|
||||||
|
<DButton
|
||||||
|
@icon="file"
|
||||||
|
@label="discourse_post_event.add_to_calendar"
|
||||||
|
@action={{this.addToCalendar}}
|
||||||
|
/>
|
||||||
|
</dropdown.item>
|
||||||
|
{{/unless}}
|
||||||
|
|
||||||
|
{{#if this.canSendPmToCreator}}
|
||||||
|
<dropdown.item class="send-pm-to-creator">
|
||||||
|
<DButton
|
||||||
|
@icon="envelope"
|
||||||
|
class="btn-transparent"
|
||||||
|
@translatedLabel={{i18n
|
||||||
|
"discourse_post_event.send_pm_to_creator"
|
||||||
|
(hash username=@event.creator.username)
|
||||||
|
}}
|
||||||
|
@action={{this.sendPMToCreator}}
|
||||||
|
/>
|
||||||
|
</dropdown.item>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if this.canInvite}}
|
||||||
|
<dropdown.item class="invite-user-or-group">
|
||||||
|
<DButton
|
||||||
|
@icon="user-plus"
|
||||||
|
class="btn-transparent"
|
||||||
|
@translatedLabel={{i18n "discourse_post_event.invite"}}
|
||||||
|
@action={{this.inviteUserOrGroup}}
|
||||||
|
/>
|
||||||
|
</dropdown.item>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if this.canSeeUpcomingEvents}}
|
||||||
|
<dropdown.item class="upcoming-events">
|
||||||
|
<DButton
|
||||||
|
@icon="far-calendar-plus"
|
||||||
|
class="btn-transparent"
|
||||||
|
@translatedLabel={{i18n
|
||||||
|
"discourse_post_event.upcoming_events.title"
|
||||||
|
}}
|
||||||
|
@action={{this.upcomingEvents}}
|
||||||
|
/>
|
||||||
|
</dropdown.item>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if this.shouldShowParticipants}}
|
||||||
|
<dropdown.item class="show-all-participants">
|
||||||
|
<DButton
|
||||||
|
@icon="user-group"
|
||||||
|
class="btn-transparent"
|
||||||
|
@label="discourse_post_event.show_participants"
|
||||||
|
@action={{this.showParticipants}}
|
||||||
|
/>
|
||||||
|
</dropdown.item>
|
||||||
|
|
||||||
|
<dropdown.divider />
|
||||||
|
{{/if}}
|
||||||
|
{{#if this.canActOnEvent}}
|
||||||
|
<dropdown.item class="export-event">
|
||||||
|
<DButton
|
||||||
|
@icon="file-csv"
|
||||||
|
class="btn-transparent"
|
||||||
|
@label="discourse_post_event.export_event"
|
||||||
|
@action={{this.exportPostEvent}}
|
||||||
|
/>
|
||||||
|
</dropdown.item>
|
||||||
|
|
||||||
|
{{#if this.canBulkInvite}}
|
||||||
|
<dropdown.item class="bulk-invite">
|
||||||
|
<DButton
|
||||||
|
@icon="file-arrow-up"
|
||||||
|
class="btn-transparent"
|
||||||
|
@label="discourse_post_event.bulk_invite"
|
||||||
|
@action={{this.bulkInvite}}
|
||||||
|
/>
|
||||||
|
</dropdown.item>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if @event.isClosed}}
|
||||||
|
<dropdown.item class="open-event">
|
||||||
|
<DButton
|
||||||
|
@icon="unlock"
|
||||||
|
class="btn-transparent"
|
||||||
|
@label="discourse_post_event.open_event"
|
||||||
|
@action={{this.openEvent}}
|
||||||
|
@disabled={{this.isSavingEvent}}
|
||||||
|
/>
|
||||||
|
</dropdown.item>
|
||||||
|
{{else}}
|
||||||
|
<dropdown.item class="edit-event">
|
||||||
|
<DButton
|
||||||
|
@icon="pencil"
|
||||||
|
class="btn-transparent"
|
||||||
|
@label="discourse_post_event.edit_event"
|
||||||
|
@action={{this.editPostEvent}}
|
||||||
|
/>
|
||||||
|
</dropdown.item>
|
||||||
|
|
||||||
|
{{#unless @event.isExpired}}
|
||||||
|
<dropdown.item class="close-event">
|
||||||
|
<DButton
|
||||||
|
@icon="xmark"
|
||||||
|
@label="discourse_post_event.close_event"
|
||||||
|
@action={{this.closeEvent}}
|
||||||
|
@disabled={{this.isSavingEvent}}
|
||||||
|
class="btn-transparent btn-danger"
|
||||||
|
/>
|
||||||
|
</dropdown.item>
|
||||||
|
{{/unless}}
|
||||||
|
{{/if}}
|
||||||
|
{{/if}}
|
||||||
|
</DropdownMenu>
|
||||||
|
</:content>
|
||||||
|
</DMenu>
|
||||||
|
</template>
|
||||||
|
}
|
|
@ -0,0 +1,183 @@
|
||||||
|
import Component from "@glimmer/component";
|
||||||
|
import { concat, fn } from "@ember/helper";
|
||||||
|
import { action } from "@ember/object";
|
||||||
|
import { service } from "@ember/service";
|
||||||
|
import DButton from "discourse/components/d-button";
|
||||||
|
import PluginOutlet from "discourse/components/plugin-outlet";
|
||||||
|
import concatClass from "discourse/helpers/concat-class";
|
||||||
|
import lazyHash from "discourse/helpers/lazy-hash";
|
||||||
|
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||||
|
|
||||||
|
export default class DiscoursePostEventStatus extends Component {
|
||||||
|
@service appEvents;
|
||||||
|
@service discoursePostEventApi;
|
||||||
|
@service siteSettings;
|
||||||
|
|
||||||
|
get eventButtons() {
|
||||||
|
return this.siteSettings.event_participation_buttons.split("|");
|
||||||
|
}
|
||||||
|
|
||||||
|
get showGoingButton() {
|
||||||
|
return !!this.eventButtons.find((button) => button === "going");
|
||||||
|
}
|
||||||
|
|
||||||
|
get showInterestedButton() {
|
||||||
|
return !!this.eventButtons.find((button) => button === "interested");
|
||||||
|
}
|
||||||
|
|
||||||
|
get showNotGoingButton() {
|
||||||
|
return !!this.eventButtons.find((button) => button === "not going");
|
||||||
|
}
|
||||||
|
|
||||||
|
get canLeave() {
|
||||||
|
return this.args.event.watchingInvitee && this.args.event.isPublic;
|
||||||
|
}
|
||||||
|
|
||||||
|
get watchingInviteeStatus() {
|
||||||
|
return this.args.event.watchingInvitee?.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
async leaveEvent() {
|
||||||
|
try {
|
||||||
|
const invitee = this.args.event.watchingInvitee;
|
||||||
|
|
||||||
|
await this.discoursePostEventApi.leaveEvent(this.args.event, invitee);
|
||||||
|
|
||||||
|
this.appEvents.trigger("calendar:invitee-left-event", {
|
||||||
|
invitee,
|
||||||
|
postId: this.args.event.id,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
popupAjaxError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
async updateEventAttendance(status) {
|
||||||
|
try {
|
||||||
|
await this.discoursePostEventApi.updateEventAttendance(this.args.event, {
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.appEvents.trigger("calendar:update-invitee-status", {
|
||||||
|
status,
|
||||||
|
postId: this.args.event.id,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
popupAjaxError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
async joinEventWithStatus(status) {
|
||||||
|
try {
|
||||||
|
await this.discoursePostEventApi.joinEvent(this.args.event, {
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.appEvents.trigger("calendar:create-invitee-status", {
|
||||||
|
status,
|
||||||
|
postId: this.args.event.id,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
popupAjaxError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
async changeWatchingInviteeStatus(status) {
|
||||||
|
if (this.args.event.watchingInvitee) {
|
||||||
|
const currentStatus = this.args.event.watchingInvitee.status;
|
||||||
|
if (this.canLeave) {
|
||||||
|
if (status === currentStatus) {
|
||||||
|
await this.leaveEvent();
|
||||||
|
} else {
|
||||||
|
await this.updateEventAttendance(status);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (status === currentStatus) {
|
||||||
|
status = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.updateEventAttendance(status);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await this.joinEventWithStatus(status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section
|
||||||
|
class={{concatClass
|
||||||
|
"event__section event-actions event-status"
|
||||||
|
(if
|
||||||
|
this.watchingInviteeStatus
|
||||||
|
(concat "status-" this.watchingInviteeStatus)
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PluginOutlet
|
||||||
|
@name="discourse-post-event-status-buttons"
|
||||||
|
@outletArgs={{lazyHash event=@event}}
|
||||||
|
>
|
||||||
|
{{#if this.showGoingButton}}
|
||||||
|
{{#unless @event.minimal}}
|
||||||
|
<PluginOutlet
|
||||||
|
@name="discourse-post-event-status-going-button"
|
||||||
|
@outletArgs={{lazyHash
|
||||||
|
event=@event
|
||||||
|
markAsGoing=(fn this.changeWatchingInviteeStatus "going")
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DButton
|
||||||
|
class="going-button"
|
||||||
|
@icon="check"
|
||||||
|
@label="discourse_post_event.models.invitee.status.going"
|
||||||
|
@action={{fn this.changeWatchingInviteeStatus "going"}}
|
||||||
|
/>
|
||||||
|
</PluginOutlet>
|
||||||
|
{{/unless}}
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if this.showInterestedButton}}
|
||||||
|
<PluginOutlet
|
||||||
|
@name="discourse-post-event-status-interested-button"
|
||||||
|
@outletArgs={{lazyHash
|
||||||
|
event=@event
|
||||||
|
markAsInterested=(fn
|
||||||
|
this.changeWatchingInviteeStatus "interested"
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DButton
|
||||||
|
class="interested-button"
|
||||||
|
@icon="star"
|
||||||
|
@label="discourse_post_event.models.invitee.status.interested"
|
||||||
|
@action={{fn this.changeWatchingInviteeStatus "interested"}}
|
||||||
|
/>
|
||||||
|
</PluginOutlet>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if this.showNotGoingButton}}
|
||||||
|
{{#unless @event.minimal}}
|
||||||
|
<PluginOutlet
|
||||||
|
@name="discourse-post-event-status-not-going-button"
|
||||||
|
@outletArgs={{lazyHash
|
||||||
|
event=@event
|
||||||
|
markAsNotGoing=(fn this.changeWatchingInviteeStatus "not_going")
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DButton
|
||||||
|
class="not-going-button"
|
||||||
|
@icon="xmark"
|
||||||
|
@label="discourse_post_event.models.invitee.status.not_going"
|
||||||
|
@action={{fn this.changeWatchingInviteeStatus "not_going"}}
|
||||||
|
/>
|
||||||
|
</PluginOutlet>
|
||||||
|
{{/unless}}
|
||||||
|
{{/if}}
|
||||||
|
</PluginOutlet>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
}
|
|
@ -0,0 +1,26 @@
|
||||||
|
import Component from "@glimmer/component";
|
||||||
|
import icon from "discourse/helpers/d-icon";
|
||||||
|
|
||||||
|
export default class DiscoursePostEventUrl extends Component {
|
||||||
|
get url() {
|
||||||
|
return this.args.url.includes("://") || this.args.url.includes("mailto:")
|
||||||
|
? this.args.url
|
||||||
|
: `https://${this.args.url}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
{{#if @url}}
|
||||||
|
<section class="event__section event-url">
|
||||||
|
{{icon "link"}}
|
||||||
|
<a
|
||||||
|
class="url"
|
||||||
|
href={{this.url}}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{{@url}}
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
{{/if}}
|
||||||
|
</template>
|
||||||
|
}
|
|
@ -0,0 +1,112 @@
|
||||||
|
import Component from "@glimmer/component";
|
||||||
|
import { service } from "@ember/service";
|
||||||
|
import { i18n } from "discourse-i18n";
|
||||||
|
import guessDateFormat from "../lib/guess-best-date-format";
|
||||||
|
|
||||||
|
export default class EventDate extends Component {
|
||||||
|
@service siteSettings;
|
||||||
|
|
||||||
|
<template>
|
||||||
|
{{~#if this.shouldRender~}}
|
||||||
|
<span class="header-topic-title-suffix-outlet event-date-container">
|
||||||
|
{{~#if this.siteSettings.use_local_event_date~}}
|
||||||
|
<span
|
||||||
|
class="event-date event-local-date past"
|
||||||
|
title={{this.dateRange}}
|
||||||
|
data-starts-at={{this.eventStartedAt}}
|
||||||
|
data-ends-at={{this.eventEndedAt}}
|
||||||
|
>
|
||||||
|
{{this.localDateContent}}
|
||||||
|
</span>
|
||||||
|
{{else}}
|
||||||
|
<span
|
||||||
|
class="event-date event-relative-date {{this.relativeDateType}}"
|
||||||
|
title={{this.dateRange}}
|
||||||
|
data-starts-at={{this.eventStartedAt}}
|
||||||
|
data-ends-at={{this.eventEndedAt}}
|
||||||
|
>
|
||||||
|
{{~#if this.isWithinDateRange~}}
|
||||||
|
<span class="indicator"></span>
|
||||||
|
<span class="text">{{this.timeRemainingContent}}</span>
|
||||||
|
{{else}}
|
||||||
|
{{this.relativeDateContent}}
|
||||||
|
{{~/if~}}
|
||||||
|
</span>
|
||||||
|
{{~/if~}}
|
||||||
|
</span>
|
||||||
|
{{~/if~}}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
get shouldRender() {
|
||||||
|
return (
|
||||||
|
this.siteSettings.discourse_post_event_enabled &&
|
||||||
|
this.args.topic.event_starts_at
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get eventStartedAt() {
|
||||||
|
return this._parsedDate(this.args.topic.event_starts_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
get eventEndedAt() {
|
||||||
|
return this.args.topic.event_ends_at
|
||||||
|
? this._parsedDate(this.args.topic.event_ends_at)
|
||||||
|
: this.eventStartedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
get dateRange() {
|
||||||
|
return this.args.topic.event_ends_at
|
||||||
|
? `${this._formattedDate(this.eventStartedAt)} → ${this._formattedDate(
|
||||||
|
this.eventEndedAt
|
||||||
|
)}`
|
||||||
|
: this._formattedDate(this.eventStartedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
get localDateContent() {
|
||||||
|
return this._formattedDate(this.eventStartedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
get relativeDateType() {
|
||||||
|
if (this.isWithinDateRange) {
|
||||||
|
return "current";
|
||||||
|
}
|
||||||
|
if (this.eventStartedAt.isAfter(moment())) {
|
||||||
|
return "future";
|
||||||
|
}
|
||||||
|
return "past";
|
||||||
|
}
|
||||||
|
|
||||||
|
get isWithinDateRange() {
|
||||||
|
return (
|
||||||
|
this.eventStartedAt.isBefore(moment()) &&
|
||||||
|
this.eventEndedAt.isAfter(moment())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get relativeDateContent() {
|
||||||
|
// dateType "current" uses a different implementation
|
||||||
|
const relativeDates = {
|
||||||
|
future: this.eventStartedAt.from(moment()),
|
||||||
|
past: this.eventEndedAt.from(moment()),
|
||||||
|
};
|
||||||
|
return relativeDates[this.relativeDateType];
|
||||||
|
}
|
||||||
|
|
||||||
|
get timeRemainingContent() {
|
||||||
|
return i18n("discourse_post_event.topic_title.ends_in_duration", {
|
||||||
|
duration: this.eventEndedAt.from(moment()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_parsedDate(date) {
|
||||||
|
return moment.utc(date).tz(moment.tz.guess());
|
||||||
|
}
|
||||||
|
|
||||||
|
_guessedDateFormat() {
|
||||||
|
return guessDateFormat(this.eventStartedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
_formattedDate(date) {
|
||||||
|
return date.format(this._guessedDateFormat());
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { notEq } from "truth-helpers";
|
||||||
|
import { i18n } from "discourse-i18n";
|
||||||
|
|
||||||
|
const EventField = <template>
|
||||||
|
{{#if (notEq @enabled false)}}
|
||||||
|
<div class="event-field" ...attributes>
|
||||||
|
{{#if @label}}
|
||||||
|
<div class="event-field-label">
|
||||||
|
<span class="label">{{i18n @label}}</span>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
<div class="event-field-control">
|
||||||
|
{{yield}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
</template>;
|
||||||
|
|
||||||
|
export default EventField;
|
|
@ -0,0 +1,184 @@
|
||||||
|
import Component from "@glimmer/component";
|
||||||
|
import { tracked } from "@glimmer/tracking";
|
||||||
|
import { fn } from "@ember/helper";
|
||||||
|
import { on } from "@ember/modifier";
|
||||||
|
import { action } from "@ember/object";
|
||||||
|
import { service } from "@ember/service";
|
||||||
|
import { eq } from "truth-helpers";
|
||||||
|
import { i18n } from "discourse-i18n";
|
||||||
|
import roundTime from "../../lib/round-time";
|
||||||
|
import NewDay from "./new-day";
|
||||||
|
import TimeTraveller from "./time-traveller";
|
||||||
|
import Timezone from "./timezone";
|
||||||
|
|
||||||
|
const nbsp = "\xa0";
|
||||||
|
|
||||||
|
export default class GroupTimezones extends Component {
|
||||||
|
@service siteSettings;
|
||||||
|
|
||||||
|
@tracked filter = "";
|
||||||
|
@tracked localTimeOffset = 0;
|
||||||
|
|
||||||
|
get groupedTimezones() {
|
||||||
|
let groupedTimezones = [];
|
||||||
|
|
||||||
|
this.args.members.filterBy("timezone").forEach((member) => {
|
||||||
|
if (this.#shouldAddMemberToGroup(this.filter, member)) {
|
||||||
|
const timezone = member.timezone;
|
||||||
|
const identifier = parseInt(moment.tz(timezone).format("YYYYMDHm"), 10);
|
||||||
|
let groupedTimezone = groupedTimezones.findBy("identifier", identifier);
|
||||||
|
|
||||||
|
if (groupedTimezone) {
|
||||||
|
groupedTimezone.members.push(member);
|
||||||
|
} else {
|
||||||
|
const now = this.#roundMoment(moment.tz(timezone));
|
||||||
|
const workingDays = this.#workingDays();
|
||||||
|
const offset = moment.tz(moment.utc(), timezone).utcOffset();
|
||||||
|
|
||||||
|
groupedTimezone = {
|
||||||
|
identifier,
|
||||||
|
offset,
|
||||||
|
type: "discourse-group-timezone",
|
||||||
|
nowWithOffset: now.add(this.localTimeOffset, "minutes"),
|
||||||
|
closeToWorkingHours: this.#closeToWorkingHours(now, workingDays),
|
||||||
|
inWorkingHours: this.#inWorkingHours(now, workingDays),
|
||||||
|
utcOffset: this.#utcOffset(offset),
|
||||||
|
members: [member],
|
||||||
|
};
|
||||||
|
groupedTimezones.push(groupedTimezone);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
groupedTimezones = groupedTimezones
|
||||||
|
.sortBy("offset")
|
||||||
|
.filter((g) => g.members.length);
|
||||||
|
|
||||||
|
let newDayIndex;
|
||||||
|
groupedTimezones.forEach((groupedTimezone, index) => {
|
||||||
|
if (index > 0) {
|
||||||
|
if (
|
||||||
|
groupedTimezones[index - 1].nowWithOffset.format("dddd") !==
|
||||||
|
groupedTimezone.nowWithOffset.format("dddd")
|
||||||
|
) {
|
||||||
|
newDayIndex = index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (newDayIndex) {
|
||||||
|
groupedTimezones.splice(newDayIndex, 0, {
|
||||||
|
type: "discourse-group-timezone-new-day",
|
||||||
|
beforeDate:
|
||||||
|
groupedTimezones[newDayIndex - 1].nowWithOffset.format("dddd"),
|
||||||
|
afterDate: groupedTimezones[newDayIndex].nowWithOffset.format("dddd"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return groupedTimezones;
|
||||||
|
}
|
||||||
|
|
||||||
|
#shouldAddMemberToGroup(filter, member) {
|
||||||
|
if (filter) {
|
||||||
|
filter = filter.toLowerCase();
|
||||||
|
if (
|
||||||
|
member.username.toLowerCase().indexOf(filter) > -1 ||
|
||||||
|
(member.name && member.name.toLowerCase().indexOf(filter) > -1)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
#roundMoment(date) {
|
||||||
|
if (this.localTimeOffset) {
|
||||||
|
date = roundTime(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|
||||||
|
#closeToWorkingHours(moment, workingDays) {
|
||||||
|
const hours = moment.hours();
|
||||||
|
const startHour = this.siteSettings.working_day_start_hour;
|
||||||
|
const endHour = this.siteSettings.working_day_end_hour;
|
||||||
|
const extension = this.siteSettings.close_to_working_day_hours_extension;
|
||||||
|
|
||||||
|
return (
|
||||||
|
((hours >= Math.max(startHour - extension, 0) && hours <= startHour) ||
|
||||||
|
(hours <= Math.min(endHour + extension, 23) && hours >= endHour)) &&
|
||||||
|
workingDays.includes(moment.isoWeekday())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#inWorkingHours(moment, workingDays) {
|
||||||
|
const hours = moment.hours();
|
||||||
|
return (
|
||||||
|
hours > this.siteSettings.working_day_start_hour &&
|
||||||
|
hours < this.siteSettings.working_day_end_hour &&
|
||||||
|
workingDays.includes(moment.isoWeekday())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#utcOffset(offset) {
|
||||||
|
const sign = Math.sign(offset) === 1 ? "+" : "-";
|
||||||
|
offset = Math.abs(offset);
|
||||||
|
let hours = Math.floor(offset / 60).toString();
|
||||||
|
hours = hours.length === 1 ? `0${hours}` : hours;
|
||||||
|
let minutes = (offset % 60).toString();
|
||||||
|
minutes = minutes.length === 1 ? `:${minutes}0` : `:${minutes}`;
|
||||||
|
return `${sign}${hours.replace(/^0(\d)/, "$1")}${minutes.replace(
|
||||||
|
/:00$/,
|
||||||
|
""
|
||||||
|
)}`.replace(/-0/, nbsp);
|
||||||
|
}
|
||||||
|
|
||||||
|
#workingDays() {
|
||||||
|
const enMoment = moment().locale("en");
|
||||||
|
const getIsoWeekday = (day) =>
|
||||||
|
enMoment.localeData()._weekdays.indexOf(day) || 7;
|
||||||
|
return this.siteSettings.working_days
|
||||||
|
.split("|")
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((x) => getIsoWeekday(x));
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
handleFilterChange(event) {
|
||||||
|
this.filter = event.target.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="group-timezones-header">
|
||||||
|
<TimeTraveller
|
||||||
|
@localTimeOffset={{this.localTimeOffset}}
|
||||||
|
@setOffset={{fn (mut this.localTimeOffset)}}
|
||||||
|
/>
|
||||||
|
<span class="title">
|
||||||
|
{{i18n "group_timezones.group_availability" group=@group}}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={{i18n "group_timezones.search"}}
|
||||||
|
class="group-timezones-filter"
|
||||||
|
{{on "input" this.handleFilterChange}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="group-timezones-body">
|
||||||
|
{{#each this.groupedTimezones key="identifier" as |groupedTimezone|}}
|
||||||
|
{{#if (eq groupedTimezone.type "discourse-group-timezone-new-day")}}
|
||||||
|
<NewDay
|
||||||
|
@beforeDate={{groupedTimezone.beforeDate}}
|
||||||
|
@afterDate={{groupedTimezone.afterDate}}
|
||||||
|
/>
|
||||||
|
{{else}}
|
||||||
|
<Timezone @groupedTimezone={{groupedTimezone}} />
|
||||||
|
{{/if}}
|
||||||
|
{{/each}}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
}
|
|
@ -0,0 +1,16 @@
|
||||||
|
import icon from "discourse/helpers/d-icon";
|
||||||
|
|
||||||
|
const NewDay = <template>
|
||||||
|
<div class="group-timezone-new-day">
|
||||||
|
<span class="before">
|
||||||
|
{{icon "chevron-left"}}
|
||||||
|
{{@beforeDate}}
|
||||||
|
</span>
|
||||||
|
<span class="after">
|
||||||
|
{{@afterDate}}
|
||||||
|
{{icon "chevron-right"}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>;
|
||||||
|
|
||||||
|
export default NewDay;
|
|
@ -0,0 +1,58 @@
|
||||||
|
import Component from "@glimmer/component";
|
||||||
|
import { on } from "@ember/modifier";
|
||||||
|
import { action } from "@ember/object";
|
||||||
|
import { not } from "truth-helpers";
|
||||||
|
import DButton from "discourse/components/d-button";
|
||||||
|
import roundTime from "../../lib/round-time";
|
||||||
|
|
||||||
|
export default class TimeTraveller extends Component {
|
||||||
|
get localTimeWithOffset() {
|
||||||
|
let date = moment().add(this.args.localTimeOffset, "minutes");
|
||||||
|
|
||||||
|
if (this.args.localTimeOffset) {
|
||||||
|
date = roundTime(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
return date.format("HH:mm");
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
reset() {
|
||||||
|
this.args.setOffset(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
sliderMoved(event) {
|
||||||
|
const value = parseInt(event.target.value, 10);
|
||||||
|
const offset = value * 15;
|
||||||
|
this.args.setOffset(offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="group-timezones-time-traveler">
|
||||||
|
<span class="time">
|
||||||
|
{{this.localTimeWithOffset}}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="discourse-group-timezones-slider-wrapper">
|
||||||
|
<input
|
||||||
|
class="group-timezones-slider"
|
||||||
|
{{on "input" this.sliderMoved}}
|
||||||
|
step="1"
|
||||||
|
value="0"
|
||||||
|
type="range"
|
||||||
|
min="-48"
|
||||||
|
max="48"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div class="group-timezones-reset">
|
||||||
|
<DButton
|
||||||
|
disabled={{not @localTimeOffset}}
|
||||||
|
@action={{this.reset}}
|
||||||
|
@icon="arrow-rotate-left"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
}
|
|
@ -0,0 +1,44 @@
|
||||||
|
import Component from "@glimmer/component";
|
||||||
|
import UserAvatar from "discourse/components/user-avatar";
|
||||||
|
import concatClass from "discourse/helpers/concat-class";
|
||||||
|
|
||||||
|
export default class GroupTimezone extends Component {
|
||||||
|
get formattedTime() {
|
||||||
|
return this.args.groupedTimezone.nowWithOffset.format("LT");
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class={{concatClass
|
||||||
|
"group-timezone"
|
||||||
|
(if @groupedTimezone.closeToWorkingHours "close-to-working-hours")
|
||||||
|
(if @groupedTimezone.inWorkingHours "in-working-hours")
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="info">
|
||||||
|
<span class="time">
|
||||||
|
{{this.formattedTime}}
|
||||||
|
</span>
|
||||||
|
<span class="offset" title="UTC offset">
|
||||||
|
{{@groupedTimezone.utcOffset}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ul class="group-timezones-members">
|
||||||
|
{{#each @groupedTimezone.members key="username" as |member|}}
|
||||||
|
<li
|
||||||
|
class={{concatClass
|
||||||
|
"group-timezones-member"
|
||||||
|
(if member.on_holiday "on-holiday" "not-on-holiday")
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<UserAvatar
|
||||||
|
@user={{member}}
|
||||||
|
@size="small"
|
||||||
|
class="group-timezones-member-avatar"
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
{{/each}}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
}
|
|
@ -0,0 +1,690 @@
|
||||||
|
import Component from "@glimmer/component";
|
||||||
|
import { tracked } from "@glimmer/tracking";
|
||||||
|
import { Input, Textarea } from "@ember/component";
|
||||||
|
import { concat, fn, get } from "@ember/helper";
|
||||||
|
import { on } from "@ember/modifier";
|
||||||
|
import { action } from "@ember/object";
|
||||||
|
import { service } from "@ember/service";
|
||||||
|
import { eq } from "truth-helpers";
|
||||||
|
import ConditionalLoadingSection from "discourse/components/conditional-loading-section";
|
||||||
|
import DButton from "discourse/components/d-button";
|
||||||
|
import DModal from "discourse/components/d-modal";
|
||||||
|
import DateInput from "discourse/components/date-input";
|
||||||
|
import DateTimeInputRange from "discourse/components/date-time-input-range";
|
||||||
|
import GroupSelector from "discourse/components/group-selector";
|
||||||
|
import PluginOutlet from "discourse/components/plugin-outlet";
|
||||||
|
import RadioButton from "discourse/components/radio-button";
|
||||||
|
import lazyHash from "discourse/helpers/lazy-hash";
|
||||||
|
import { extractError } from "discourse/lib/ajax-error";
|
||||||
|
import { cook } from "discourse/lib/text";
|
||||||
|
import Group from "discourse/models/group";
|
||||||
|
import { i18n } from "discourse-i18n";
|
||||||
|
import ComboBox from "select-kit/components/combo-box";
|
||||||
|
import TimezoneInput from "select-kit/components/timezone-input";
|
||||||
|
import { buildParams, replaceRaw } from "../../lib/raw-event-helper";
|
||||||
|
import EventField from "../event-field";
|
||||||
|
|
||||||
|
export default class PostEventBuilder extends Component {
|
||||||
|
@service dialog;
|
||||||
|
@service siteSettings;
|
||||||
|
@service store;
|
||||||
|
@service currentUser;
|
||||||
|
|
||||||
|
@tracked flash = null;
|
||||||
|
@tracked isSaving = false;
|
||||||
|
|
||||||
|
@tracked startsAt = moment(this.event.startsAt).tz(
|
||||||
|
this.event.timezone || "UTC"
|
||||||
|
);
|
||||||
|
|
||||||
|
@tracked
|
||||||
|
endsAt =
|
||||||
|
this.event.endsAt &&
|
||||||
|
moment(this.event.endsAt).tz(this.event.timezone || "UTC");
|
||||||
|
|
||||||
|
get recurrenceUntil() {
|
||||||
|
return (
|
||||||
|
this.event.recurrenceUntil &&
|
||||||
|
moment(this.event.recurrenceUntil).tz(this.event.timezone || "UTC")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get event() {
|
||||||
|
return this.args.model.event;
|
||||||
|
}
|
||||||
|
|
||||||
|
get reminderTypes() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
value: "notification",
|
||||||
|
name: i18n(
|
||||||
|
"discourse_post_event.builder_modal.reminders.types.notification"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "bumpTopic",
|
||||||
|
name: i18n(
|
||||||
|
"discourse_post_event.builder_modal.reminders.types.bump_topic"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
get reminderUnits() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
value: "minutes",
|
||||||
|
name: i18n(
|
||||||
|
"discourse_post_event.builder_modal.reminders.units.minutes"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "hours",
|
||||||
|
name: i18n("discourse_post_event.builder_modal.reminders.units.hours"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "days",
|
||||||
|
name: i18n("discourse_post_event.builder_modal.reminders.units.days"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "weeks",
|
||||||
|
name: i18n("discourse_post_event.builder_modal.reminders.units.weeks"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
get reminderPeriods() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
value: "before",
|
||||||
|
name: i18n(
|
||||||
|
"discourse_post_event.builder_modal.reminders.periods.before"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "after",
|
||||||
|
name: i18n(
|
||||||
|
"discourse_post_event.builder_modal.reminders.periods.after"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
get shouldRenderUrl() {
|
||||||
|
return this.args.model.event.url !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
get availableRecurrences() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "every_day",
|
||||||
|
name: i18n("discourse_post_event.builder_modal.recurrence.every_day"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "every_month",
|
||||||
|
name: i18n("discourse_post_event.builder_modal.recurrence.every_month"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "every_weekday",
|
||||||
|
name: i18n(
|
||||||
|
"discourse_post_event.builder_modal.recurrence.every_weekday"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "every_week",
|
||||||
|
name: i18n("discourse_post_event.builder_modal.recurrence.every_week"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "every_two_weeks",
|
||||||
|
name: i18n(
|
||||||
|
"discourse_post_event.builder_modal.recurrence.every_two_weeks"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "every_four_weeks",
|
||||||
|
name: i18n(
|
||||||
|
"discourse_post_event.builder_modal.recurrence.every_four_weeks"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
get allowedCustomFields() {
|
||||||
|
return this.siteSettings.discourse_post_event_allowed_custom_fields
|
||||||
|
.split("|")
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
get addReminderDisabled() {
|
||||||
|
return this.event.reminders?.length >= 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
get showChat() {
|
||||||
|
// As of June 2025, chat channel creation is only available to admins and moderators
|
||||||
|
return (
|
||||||
|
this.siteSettings.chat_enabled &&
|
||||||
|
(this.currentUser.admin || this.currentUser.moderator)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
groupFinder(term) {
|
||||||
|
return Group.findAll({ term, ignore_automatic: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
setCustomField(field, e) {
|
||||||
|
this.event.customFields[field] = e.target.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
onChangeDates(dates) {
|
||||||
|
this.event.startsAt = dates.from;
|
||||||
|
this.event.endsAt = dates.to;
|
||||||
|
this.startsAt = dates.from;
|
||||||
|
this.endsAt = dates.to;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
onChangeStatus(newStatus) {
|
||||||
|
this.event.rawInvitees = [];
|
||||||
|
this.event.status = newStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
setRecurrence(newRecurrence) {
|
||||||
|
if (!newRecurrence) {
|
||||||
|
this.event.recurrence = null;
|
||||||
|
this.event.recurrenceUntil = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.event.recurrence = newRecurrence;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
setRecurrenceUntil(until) {
|
||||||
|
if (!until) {
|
||||||
|
this.event.recurrenceUntil = null;
|
||||||
|
} else {
|
||||||
|
this.event.recurrenceUntil = moment(until).endOf("day").toDate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
setRawInvitees(_, newInvitees) {
|
||||||
|
this.event.rawInvitees = newInvitees;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
setNewTimezone(newTz) {
|
||||||
|
this.event.timezone = newTz;
|
||||||
|
this.event.startsAt = moment.tz(
|
||||||
|
this.startsAt.format("YYYY-MM-DDTHH:mm"),
|
||||||
|
newTz
|
||||||
|
);
|
||||||
|
this.event.endsAt = this.endsAt
|
||||||
|
? moment.tz(this.endsAt.format("YYYY-MM-DDTHH:mm"), newTz)
|
||||||
|
: null;
|
||||||
|
this.startsAt = moment(this.event.startsAt).tz(newTz);
|
||||||
|
this.endsAt = this.event.endsAt
|
||||||
|
? moment(this.event.endsAt).tz(newTz)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
async destroyPostEvent() {
|
||||||
|
try {
|
||||||
|
const confirmResult = await this.dialog.yesNoConfirm({
|
||||||
|
message: "Confirm delete",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (confirmResult) {
|
||||||
|
const post = await this.store.find("post", this.event.id);
|
||||||
|
const raw = post.raw;
|
||||||
|
const newRaw = this._removeRawEvent(raw);
|
||||||
|
const props = {
|
||||||
|
raw: newRaw,
|
||||||
|
edit_reason: "Destroy event",
|
||||||
|
};
|
||||||
|
|
||||||
|
const cooked = await cook(newRaw);
|
||||||
|
props.cooked = cooked.string;
|
||||||
|
|
||||||
|
const result = await post.save(props);
|
||||||
|
if (result) {
|
||||||
|
this.args.closeModal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.flash = extractError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
createEvent() {
|
||||||
|
if (!this.startsAt) {
|
||||||
|
this.args.closeModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventParams = buildParams(
|
||||||
|
this.startsAt,
|
||||||
|
this.endsAt,
|
||||||
|
this.event,
|
||||||
|
this.siteSettings
|
||||||
|
);
|
||||||
|
const markdownParams = [];
|
||||||
|
Object.keys(eventParams).forEach((key) => {
|
||||||
|
let value = eventParams[key];
|
||||||
|
markdownParams.push(`${key}="${value}"`);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.args.model.toolbarEvent.addText(
|
||||||
|
`[event ${markdownParams.join(" ")}]\n[/event]`
|
||||||
|
);
|
||||||
|
this.args.closeModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
async updateEvent() {
|
||||||
|
try {
|
||||||
|
this.isSaving = true;
|
||||||
|
|
||||||
|
const post = await this.store.find("post", this.event.id);
|
||||||
|
const raw = post.raw;
|
||||||
|
const eventParams = buildParams(
|
||||||
|
this.startsAt,
|
||||||
|
this.endsAt,
|
||||||
|
this.event,
|
||||||
|
this.siteSettings
|
||||||
|
);
|
||||||
|
const newRaw = replaceRaw(eventParams, raw);
|
||||||
|
if (newRaw) {
|
||||||
|
const props = {
|
||||||
|
raw: newRaw,
|
||||||
|
edit_reason: i18n("discourse_post_event.edit_reason"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const cooked = await cook(newRaw);
|
||||||
|
props.cooked = cooked.string;
|
||||||
|
|
||||||
|
const result = await post.save(props);
|
||||||
|
if (result) {
|
||||||
|
this.args.closeModal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.flash = extractError(e);
|
||||||
|
} finally {
|
||||||
|
this.isSaving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_removeRawEvent(raw) {
|
||||||
|
const eventRegex = new RegExp(`\\[event\\s(.*?)\\]\\n\\[\\/event\\]`, "m");
|
||||||
|
return raw.replace(eventRegex, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DModal
|
||||||
|
@title={{i18n
|
||||||
|
(concat
|
||||||
|
"discourse_post_event.builder_modal."
|
||||||
|
(if @model.event.id "update_event_title" "create_event_title")
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
@closeModal={{@closeModal}}
|
||||||
|
@flash={{this.flash}}
|
||||||
|
class="post-event-builder-modal"
|
||||||
|
>
|
||||||
|
<:body>
|
||||||
|
<ConditionalLoadingSection @isLoading={{this.isSaving}}>
|
||||||
|
<form>
|
||||||
|
<PluginOutlet
|
||||||
|
@name="post-event-builder-form"
|
||||||
|
@outletArgs={{lazyHash event=@model.event}}
|
||||||
|
@connectorTagName="div"
|
||||||
|
>
|
||||||
|
<EventField>
|
||||||
|
<DateTimeInputRange
|
||||||
|
@from={{this.startsAt}}
|
||||||
|
@to={{this.endsAt}}
|
||||||
|
@timezone={{@model.event.timezone}}
|
||||||
|
@onChange={{this.onChangeDates}}
|
||||||
|
/>
|
||||||
|
</EventField>
|
||||||
|
|
||||||
|
<EventField
|
||||||
|
@label="discourse_post_event.builder_modal.name.label"
|
||||||
|
class="name"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
@value={{@model.event.name}}
|
||||||
|
placeholder={{i18n
|
||||||
|
"discourse_post_event.builder_modal.name.placeholder"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</EventField>
|
||||||
|
|
||||||
|
<EventField
|
||||||
|
@label="discourse_post_event.builder_modal.location.label"
|
||||||
|
class="location"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
@value={{@model.event.location}}
|
||||||
|
placeholder={{i18n
|
||||||
|
"discourse_post_event.builder_modal.location.placeholder"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</EventField>
|
||||||
|
|
||||||
|
{{#if this.shouldRenderUrl}}
|
||||||
|
<EventField
|
||||||
|
@label="discourse_post_event.builder_modal.url.label"
|
||||||
|
class="url"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
@value={{@model.event.url}}
|
||||||
|
placeholder={{i18n
|
||||||
|
"discourse_post_event.builder_modal.url.placeholder"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</EventField>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
<EventField
|
||||||
|
@label="discourse_post_event.builder_modal.description.label"
|
||||||
|
class="description"
|
||||||
|
>
|
||||||
|
<Textarea
|
||||||
|
@value={{@model.event.description}}
|
||||||
|
placeholder={{i18n
|
||||||
|
"discourse_post_event.builder_modal.description.placeholder"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</EventField>
|
||||||
|
|
||||||
|
<EventField
|
||||||
|
class="timezone"
|
||||||
|
@label="discourse_post_event.builder_modal.timezone.label"
|
||||||
|
>
|
||||||
|
<TimezoneInput
|
||||||
|
@value={{@model.event.timezone}}
|
||||||
|
@onChange={{this.setNewTimezone}}
|
||||||
|
@none="discourse_post_event.builder_modal.timezone.remove_timezone"
|
||||||
|
/>
|
||||||
|
</EventField>
|
||||||
|
|
||||||
|
<EventField
|
||||||
|
class="show-local-time"
|
||||||
|
@label="discourse_post_event.builder_modal.show_local_time.label"
|
||||||
|
>
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<Input
|
||||||
|
@type="checkbox"
|
||||||
|
@checked={{@model.event.showLocalTime}}
|
||||||
|
/>
|
||||||
|
<span class="message">
|
||||||
|
{{i18n
|
||||||
|
"discourse_post_event.builder_modal.show_local_time.description"
|
||||||
|
timezone=@model.event.timezone
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</EventField>
|
||||||
|
|
||||||
|
<EventField
|
||||||
|
@label="discourse_post_event.builder_modal.status.label"
|
||||||
|
>
|
||||||
|
<label class="radio-label">
|
||||||
|
<RadioButton
|
||||||
|
@name="status"
|
||||||
|
@value="public"
|
||||||
|
@selection={{@model.event.status}}
|
||||||
|
@onChange={{this.onChangeStatus}}
|
||||||
|
/>
|
||||||
|
<span class="message">
|
||||||
|
<span class="title">
|
||||||
|
{{i18n
|
||||||
|
"discourse_post_event.models.event.status.public.title"
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
<span class="description">
|
||||||
|
{{i18n
|
||||||
|
"discourse_post_event.models.event.status.public.description"
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label class="radio-label">
|
||||||
|
<RadioButton
|
||||||
|
@name="status"
|
||||||
|
@value="private"
|
||||||
|
@selection={{@model.event.status}}
|
||||||
|
@onChange={{this.onChangeStatus}}
|
||||||
|
/>
|
||||||
|
<span class="message">
|
||||||
|
<span class="title">
|
||||||
|
{{i18n
|
||||||
|
"discourse_post_event.models.event.status.private.title"
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
<span class="description">
|
||||||
|
{{i18n
|
||||||
|
"discourse_post_event.models.event.status.private.description"
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label class="radio-label">
|
||||||
|
<RadioButton
|
||||||
|
@name="status"
|
||||||
|
@value="standalone"
|
||||||
|
@selection={{@model.event.status}}
|
||||||
|
@onChange={{this.onChangeStatus}}
|
||||||
|
/>
|
||||||
|
<span class="message">
|
||||||
|
<span class="title">
|
||||||
|
{{i18n
|
||||||
|
"discourse_post_event.models.event.status.standalone.title"
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
<span class="description">
|
||||||
|
{{i18n
|
||||||
|
"discourse_post_event.models.event.status.standalone.description"
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</EventField>
|
||||||
|
|
||||||
|
<EventField
|
||||||
|
@enabled={{eq @model.event.status "private"}}
|
||||||
|
@label="discourse_post_event.builder_modal.invitees.label"
|
||||||
|
>
|
||||||
|
<GroupSelector
|
||||||
|
@fullWidthWrap={{true}}
|
||||||
|
@groupFinder={{this.groupFinder}}
|
||||||
|
@groupNames={{@model.event.rawInvitees}}
|
||||||
|
@onChangeCallback={{this.setRawInvitees}}
|
||||||
|
@placeholderKey="topic.invite_private.group_name"
|
||||||
|
/>
|
||||||
|
</EventField>
|
||||||
|
|
||||||
|
<EventField
|
||||||
|
class="reminders"
|
||||||
|
@label="discourse_post_event.builder_modal.reminders.label"
|
||||||
|
>
|
||||||
|
<div class="reminders-list">
|
||||||
|
{{#each @model.event.reminders as |reminder|}}
|
||||||
|
<div class="reminder-item">
|
||||||
|
<ComboBox
|
||||||
|
@value={{reminder.type}}
|
||||||
|
@nameProperty="name"
|
||||||
|
@valueProperty="value"
|
||||||
|
@content={{this.reminderTypes}}
|
||||||
|
class="reminder-type"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
@value={{reminder.value}}
|
||||||
|
min="0"
|
||||||
|
placeholder={{i18n
|
||||||
|
"discourse_post_event.builder_modal.name.placeholder"
|
||||||
|
}}
|
||||||
|
class="reminder-value"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ComboBox
|
||||||
|
@value={{reminder.unit}}
|
||||||
|
@nameProperty="name"
|
||||||
|
@valueProperty="value"
|
||||||
|
@content={{this.reminderUnits}}
|
||||||
|
class="reminder-unit"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ComboBox
|
||||||
|
@value={{reminder.period}}
|
||||||
|
@nameProperty="name"
|
||||||
|
@valueProperty="value"
|
||||||
|
@content={{this.reminderPeriods}}
|
||||||
|
class="reminder-period"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DButton
|
||||||
|
@action={{fn @model.event.removeReminder reminder}}
|
||||||
|
@icon="xmark"
|
||||||
|
class="remove-reminder"
|
||||||
|
/>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{{/each}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DButton
|
||||||
|
@disabled={{this.addReminderDisabled}}
|
||||||
|
@icon="plus"
|
||||||
|
@label="discourse_post_event.builder_modal.add_reminder"
|
||||||
|
@action={{@model.event.addReminder}}
|
||||||
|
class="add-reminder"
|
||||||
|
/>
|
||||||
|
</EventField>
|
||||||
|
|
||||||
|
<EventField
|
||||||
|
class="recurrence"
|
||||||
|
@label="discourse_post_event.builder_modal.recurrence.label"
|
||||||
|
>
|
||||||
|
<ComboBox
|
||||||
|
class="available-recurrences"
|
||||||
|
@value={{@model.event.recurrence}}
|
||||||
|
@content={{this.availableRecurrences}}
|
||||||
|
@onChange={{this.setRecurrence}}
|
||||||
|
@options={{lazyHash
|
||||||
|
none="discourse_post_event.builder_modal.recurrence.none"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</EventField>
|
||||||
|
|
||||||
|
{{#if @model.event.recurrence}}
|
||||||
|
<EventField
|
||||||
|
@label="discourse_post_event.builder_modal.recurrence_until.label"
|
||||||
|
class="recurrence-until"
|
||||||
|
>
|
||||||
|
<DateInput
|
||||||
|
@date={{this.recurrenceUntil}}
|
||||||
|
@onChange={{this.setRecurrenceUntil}}
|
||||||
|
@timezone={{@model.event.timezone}}
|
||||||
|
/>
|
||||||
|
</EventField>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
<EventField
|
||||||
|
class="minimal-event"
|
||||||
|
@label="discourse_post_event.builder_modal.minimal.label"
|
||||||
|
>
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<Input @type="checkbox" @checked={{@model.event.minimal}} />
|
||||||
|
<span class="message">
|
||||||
|
{{i18n
|
||||||
|
"discourse_post_event.builder_modal.minimal.checkbox_label"
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</EventField>
|
||||||
|
|
||||||
|
{{#if this.showChat}}
|
||||||
|
<EventField
|
||||||
|
class="allow-chat"
|
||||||
|
@label="discourse_post_event.builder_modal.allow_chat.label"
|
||||||
|
>
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<Input
|
||||||
|
@type="checkbox"
|
||||||
|
@checked={{@model.event.chatEnabled}}
|
||||||
|
/>
|
||||||
|
<span class="message">
|
||||||
|
{{i18n
|
||||||
|
"discourse_post_event.builder_modal.allow_chat.checkbox_label"
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</EventField>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if this.allowedCustomFields.length}}
|
||||||
|
<EventField
|
||||||
|
@label="discourse_post_event.builder_modal.custom_fields.label"
|
||||||
|
>
|
||||||
|
<p class="event-field-description">
|
||||||
|
{{i18n
|
||||||
|
"discourse_post_event.builder_modal.custom_fields.description"
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
{{#each this.allowedCustomFields as |allowedCustomField|}}
|
||||||
|
<span class="label custom-field-label">
|
||||||
|
{{allowedCustomField}}
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
{{on "input" (fn this.setCustomField allowedCustomField)}}
|
||||||
|
@value={{readonly
|
||||||
|
(get @model.event.customFields allowedCustomField)
|
||||||
|
}}
|
||||||
|
placeholder={{i18n
|
||||||
|
"discourse_post_event.builder_modal.custom_fields.placeholder"
|
||||||
|
}}
|
||||||
|
class="custom-field-input"
|
||||||
|
/>
|
||||||
|
{{/each}}
|
||||||
|
</EventField>
|
||||||
|
{{/if}}
|
||||||
|
</PluginOutlet>
|
||||||
|
</form>
|
||||||
|
</ConditionalLoadingSection>
|
||||||
|
</:body>
|
||||||
|
<:footer>
|
||||||
|
{{#if @model.event.id}}
|
||||||
|
<DButton
|
||||||
|
class="btn-primary"
|
||||||
|
@label="discourse_post_event.builder_modal.update"
|
||||||
|
@icon="calendar-day"
|
||||||
|
@action={{this.updateEvent}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DButton
|
||||||
|
@icon="trash-can"
|
||||||
|
class="btn-danger"
|
||||||
|
@action={{this.destroyPostEvent}}
|
||||||
|
/>
|
||||||
|
{{else}}
|
||||||
|
<DButton
|
||||||
|
class="btn-primary"
|
||||||
|
@label="discourse_post_event.builder_modal.create"
|
||||||
|
@icon="calendar-day"
|
||||||
|
@action={{this.createEvent}}
|
||||||
|
/>
|
||||||
|
{{/if}}
|
||||||
|
</:footer>
|
||||||
|
</DModal>
|
||||||
|
</template>
|
||||||
|
}
|
|
@ -0,0 +1,227 @@
|
||||||
|
import Component from "@glimmer/component";
|
||||||
|
import { tracked } from "@glimmer/tracking";
|
||||||
|
import { concat, fn, hash } from "@ember/helper";
|
||||||
|
import EmberObject, { action } from "@ember/object";
|
||||||
|
import { service } from "@ember/service";
|
||||||
|
import { isPresent } from "@ember/utils";
|
||||||
|
import { TrackedArray } from "@ember-compat/tracked-built-ins";
|
||||||
|
import DButton from "discourse/components/d-button";
|
||||||
|
import DModal from "discourse/components/d-modal";
|
||||||
|
import GroupSelector from "discourse/components/group-selector";
|
||||||
|
import { ajax } from "discourse/lib/ajax";
|
||||||
|
import { extractError } from "discourse/lib/ajax-error";
|
||||||
|
import Group from "discourse/models/group";
|
||||||
|
import { i18n } from "discourse-i18n";
|
||||||
|
import ComboBox from "select-kit/components/combo-box";
|
||||||
|
import EmailGroupUserChooser from "select-kit/components/email-group-user-chooser";
|
||||||
|
import BulkInviteSampleCsvFile from "../bulk-invite-sample-csv-file";
|
||||||
|
import CsvUploader from "../csv-uploader";
|
||||||
|
|
||||||
|
export default class PostEventBulkInvite extends Component {
|
||||||
|
@service dialog;
|
||||||
|
|
||||||
|
@tracked
|
||||||
|
bulkInvites = new TrackedArray([
|
||||||
|
EmberObject.create({ identifier: null, attendance: "unknown" }),
|
||||||
|
]);
|
||||||
|
@tracked bulkInviteDisabled = true;
|
||||||
|
@tracked flash = null;
|
||||||
|
|
||||||
|
get bulkInviteStatuses() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: i18n("discourse_post_event.models.invitee.status.unknown"),
|
||||||
|
name: "unknown",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: i18n("discourse_post_event.models.invitee.status.going"),
|
||||||
|
name: "going",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: i18n("discourse_post_event.models.invitee.status.not_going"),
|
||||||
|
name: "not_going",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: i18n("discourse_post_event.models.invitee.status.interested"),
|
||||||
|
name: "interested",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
groupFinder(term) {
|
||||||
|
return Group.findAll({ term, ignore_automatic: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
setBulkInviteDisabled() {
|
||||||
|
this.bulkInviteDisabled =
|
||||||
|
this.bulkInvites.filter((x) => isPresent(x.identifier)).length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
async sendBulkInvites() {
|
||||||
|
try {
|
||||||
|
const response = await ajax(
|
||||||
|
`/discourse-post-event/events/${this.args.model.event.id}/bulk-invite.json`,
|
||||||
|
{
|
||||||
|
type: "POST",
|
||||||
|
dataType: "json",
|
||||||
|
contentType: "application/json",
|
||||||
|
data: JSON.stringify({
|
||||||
|
invitees: this.bulkInvites.filter((x) => isPresent(x.identifier)),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
this.args.closeModal();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.flash = extractError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
removeBulkInvite(bulkInvite) {
|
||||||
|
this.bulkInvites.removeObject(bulkInvite);
|
||||||
|
|
||||||
|
if (!this.bulkInvites.length) {
|
||||||
|
this.bulkInvites.pushObject(
|
||||||
|
EmberObject.create({ identifier: null, attendance: "unknown" })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
addBulkInvite() {
|
||||||
|
const attendance =
|
||||||
|
this.bulkInvites[this.bulkInvites.length - 1]?.attendance || "unknown";
|
||||||
|
this.bulkInvites.pushObject(
|
||||||
|
EmberObject.create({ identifier: null, attendance })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
async uploadDone() {
|
||||||
|
await this.dialog.alert(
|
||||||
|
i18n("discourse_post_event.bulk_invite_modal.success")
|
||||||
|
);
|
||||||
|
this.args.closeModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
updateInviteIdentifier(bulkInvite, selected) {
|
||||||
|
bulkInvite.set("identifier", selected[0]);
|
||||||
|
this.setBulkInviteDisabled();
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
updateBulkGroupInviteIdentifier(bulkInvite, _, groupNames) {
|
||||||
|
bulkInvite.set("identifier", groupNames[0]);
|
||||||
|
this.setBulkInviteDisabled();
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DModal
|
||||||
|
@closeModal={{@closeModal}}
|
||||||
|
@title={{i18n "discourse_post_event.bulk_invite_modal.title"}}
|
||||||
|
class="post-event-bulk-invite"
|
||||||
|
@flash={{this.flash}}
|
||||||
|
>
|
||||||
|
<:body>
|
||||||
|
<div class="bulk-invites">
|
||||||
|
<p class="bulk-event-help">
|
||||||
|
{{i18n
|
||||||
|
(concat
|
||||||
|
"discourse_post_event.bulk_invite_modal.description_"
|
||||||
|
@model.event.status
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<h3>{{i18n
|
||||||
|
"discourse_post_event.bulk_invite_modal.inline_title"
|
||||||
|
}}</h3>
|
||||||
|
|
||||||
|
<div class="bulk-invite-rows">
|
||||||
|
{{#each this.bulkInvites as |bulkInvite|}}
|
||||||
|
<div class="bulk-invite-row">
|
||||||
|
{{#if @model.event.isPrivate}}
|
||||||
|
<GroupSelector
|
||||||
|
class="bulk-invite-identifier"
|
||||||
|
@single={{true}}
|
||||||
|
@groupFinder={{this.groupFinder}}
|
||||||
|
@groupNames={{bulkInvite.identifier}}
|
||||||
|
@placeholderKey="discourse_post_event.bulk_invite_modal.group_selector_placeholder"
|
||||||
|
@onChangeCallback={{fn
|
||||||
|
this.updateBulkGroupInviteIdentifier
|
||||||
|
bulkInvite
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{{/if}}
|
||||||
|
{{#if @model.event.isPublic}}
|
||||||
|
<EmailGroupUserChooser
|
||||||
|
class="bulk-invite-identifier"
|
||||||
|
@value={{bulkInvite.identifier}}
|
||||||
|
@onChange={{fn this.updateInviteIdentifier bulkInvite}}
|
||||||
|
@options={{hash
|
||||||
|
maximum=1
|
||||||
|
filterPlaceholder="discourse_post_event.bulk_invite_modal.user_selector_placeholder"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
<ComboBox
|
||||||
|
class="bulk-invite-attendance"
|
||||||
|
@value={{bulkInvite.attendance}}
|
||||||
|
@content={{this.bulkInviteStatuses}}
|
||||||
|
@nameProperty="name"
|
||||||
|
@valueProperty="name"
|
||||||
|
@onChange={{fn (mut bulkInvite.attendance)}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DButton
|
||||||
|
@icon="trash-can"
|
||||||
|
@action={{fn this.removeBulkInvite bulkInvite}}
|
||||||
|
class="remove-bulk-invite"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{{/each}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bulk-invite-actions">
|
||||||
|
<DButton
|
||||||
|
class="send-bulk-invites btn-primary"
|
||||||
|
@label="discourse_post_event.bulk_invite_modal.send_bulk_invites"
|
||||||
|
@action={{this.sendBulkInvites}}
|
||||||
|
@disabled={{this.bulkInviteDisabled}}
|
||||||
|
/>
|
||||||
|
<DButton
|
||||||
|
class="add-bulk-invite"
|
||||||
|
@icon="plus"
|
||||||
|
@action={{this.addBulkInvite}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="csv-bulk-invites">
|
||||||
|
<h3>{{i18n "discourse_post_event.bulk_invite_modal.csv_title"}}</h3>
|
||||||
|
|
||||||
|
<div class="bulk-invite-actions">
|
||||||
|
<BulkInviteSampleCsvFile />
|
||||||
|
|
||||||
|
<CsvUploader
|
||||||
|
@uploadUrl={{concat
|
||||||
|
"/discourse-post-event/events/"
|
||||||
|
@model.event.id
|
||||||
|
"/csv-bulk-invite"
|
||||||
|
}}
|
||||||
|
@i18nPrefix="discourse_post_event.bulk_invite_modal"
|
||||||
|
@uploadDone={{this.uploadDone}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</:body>
|
||||||
|
</DModal>
|
||||||
|
</template>
|
||||||
|
}
|
|
@ -0,0 +1,64 @@
|
||||||
|
import Component from "@glimmer/component";
|
||||||
|
import { tracked } from "@glimmer/tracking";
|
||||||
|
import { hash } from "@ember/helper";
|
||||||
|
import { action } from "@ember/object";
|
||||||
|
import DButton from "discourse/components/d-button";
|
||||||
|
import DModal from "discourse/components/d-modal";
|
||||||
|
import { ajax } from "discourse/lib/ajax";
|
||||||
|
import { extractError } from "discourse/lib/ajax-error";
|
||||||
|
import { i18n } from "discourse-i18n";
|
||||||
|
import EmailGroupUserChooser from "select-kit/components/email-group-user-chooser";
|
||||||
|
import EventField from "../event-field";
|
||||||
|
|
||||||
|
export default class PostEventInviteUserOrGroup extends Component {
|
||||||
|
@tracked invitedNames = [];
|
||||||
|
@tracked flash = null;
|
||||||
|
|
||||||
|
@action
|
||||||
|
async invite() {
|
||||||
|
try {
|
||||||
|
await ajax(
|
||||||
|
`/discourse-post-event/events/${this.args.model.event.id}/invite.json`,
|
||||||
|
{
|
||||||
|
data: { invites: this.invitedNames },
|
||||||
|
type: "POST",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
this.args.closeModal();
|
||||||
|
} catch (e) {
|
||||||
|
this.flash = extractError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DModal
|
||||||
|
@title={{i18n "discourse_post_event.invite_user_or_group.title"}}
|
||||||
|
@closeModal={{@closeModal}}
|
||||||
|
@flash={{this.flash}}
|
||||||
|
>
|
||||||
|
<:body>
|
||||||
|
<form>
|
||||||
|
<EventField>
|
||||||
|
<EmailGroupUserChooser
|
||||||
|
@value={{this.invitedNames}}
|
||||||
|
@options={{hash
|
||||||
|
fullWidthWrap=true
|
||||||
|
includeMessageableGroups=true
|
||||||
|
filterPlaceholder="composer.users_placeholder"
|
||||||
|
excludeCurrentUser=true
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</EventField>
|
||||||
|
</form>
|
||||||
|
</:body>
|
||||||
|
<:footer>
|
||||||
|
<DButton
|
||||||
|
@type="button"
|
||||||
|
class="btn-primary"
|
||||||
|
@label="discourse_post_event.invite_user_or_group.invite"
|
||||||
|
@action={{this.invite}}
|
||||||
|
/>
|
||||||
|
</:footer>
|
||||||
|
</DModal>
|
||||||
|
</template>
|
||||||
|
}
|
|
@ -0,0 +1,157 @@
|
||||||
|
import Component from "@glimmer/component";
|
||||||
|
import { tracked } from "@glimmer/tracking";
|
||||||
|
import { fn } from "@ember/helper";
|
||||||
|
import { on } from "@ember/modifier";
|
||||||
|
import { action } from "@ember/object";
|
||||||
|
import { service } from "@ember/service";
|
||||||
|
import { or } from "truth-helpers";
|
||||||
|
import ConditionalLoadingSpinner from "discourse/components/conditional-loading-spinner";
|
||||||
|
import DButton from "discourse/components/d-button";
|
||||||
|
import DModal from "discourse/components/d-modal";
|
||||||
|
import concatClass from "discourse/helpers/concat-class";
|
||||||
|
import { debounce } from "discourse/lib/decorators";
|
||||||
|
import { i18n } from "discourse-i18n";
|
||||||
|
import ToggleInvitees from "../../toggle-invitees";
|
||||||
|
import User from "./user";
|
||||||
|
|
||||||
|
export default class PostEventInviteesModal extends Component {
|
||||||
|
@service store;
|
||||||
|
@service discoursePostEventApi;
|
||||||
|
|
||||||
|
@tracked filter;
|
||||||
|
@tracked isLoading = false;
|
||||||
|
@tracked type = "going";
|
||||||
|
@tracked inviteesList;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super(...arguments);
|
||||||
|
this._fetchInvitees();
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasSuggestedUsers() {
|
||||||
|
return this.inviteesList?.suggestedUsers?.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasResults() {
|
||||||
|
return this.inviteesList?.invitees?.length > 0 || this.hasSuggestedUsers;
|
||||||
|
}
|
||||||
|
|
||||||
|
get title() {
|
||||||
|
return i18n(
|
||||||
|
`discourse_post_event.invitees_modal.${
|
||||||
|
this.args.model.title || "title_invited"
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
toggleType(type) {
|
||||||
|
this.type = type;
|
||||||
|
this._fetchInvitees(this.filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
@debounce(250)
|
||||||
|
onFilterChanged(event) {
|
||||||
|
this.filter = event.target.value;
|
||||||
|
this._fetchInvitees(this.filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
async removeInvitee(invitee) {
|
||||||
|
await this.discoursePostEventApi.leaveEvent(this.args.model.event, invitee);
|
||||||
|
|
||||||
|
this.inviteesList.remove(invitee);
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
async addInvitee(user) {
|
||||||
|
const invitee = await this.discoursePostEventApi.joinEvent(
|
||||||
|
this.args.model.event,
|
||||||
|
{
|
||||||
|
status: this.type,
|
||||||
|
user_id: user.id,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
this.inviteesList.add(invitee);
|
||||||
|
}
|
||||||
|
|
||||||
|
async _fetchInvitees(filter) {
|
||||||
|
try {
|
||||||
|
this.isLoading = true;
|
||||||
|
|
||||||
|
this.inviteesList = await this.discoursePostEventApi.listEventInvitees(
|
||||||
|
this.args.model.event,
|
||||||
|
{ type: this.type, filter }
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
this.isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DModal
|
||||||
|
@title={{this.title}}
|
||||||
|
@closeModal={{@closeModal}}
|
||||||
|
class={{concatClass
|
||||||
|
(or @model.extraClass "invited")
|
||||||
|
"post-event-invitees-modal"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<:body>
|
||||||
|
<input
|
||||||
|
{{on "input" this.onFilterChanged}}
|
||||||
|
type="text"
|
||||||
|
placeholder={{i18n
|
||||||
|
"discourse_post_event.invitees_modal.filter_placeholder"
|
||||||
|
}}
|
||||||
|
class="filter"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ToggleInvitees @viewType={{this.type}} @toggle={{this.toggleType}} />
|
||||||
|
<ConditionalLoadingSpinner @condition={{this.isLoading}}>
|
||||||
|
{{#if this.hasResults}}
|
||||||
|
<ul class="invitees">
|
||||||
|
{{#each this.inviteesList.invitees as |invitee|}}
|
||||||
|
<li class="invitee">
|
||||||
|
<User @user={{invitee.user}} />
|
||||||
|
{{#if @model.event.canActOnDiscoursePostEvent}}
|
||||||
|
<DButton
|
||||||
|
class="remove-invitee"
|
||||||
|
@icon="trash-can"
|
||||||
|
@action={{fn this.removeInvitee invitee}}
|
||||||
|
title={{i18n
|
||||||
|
"discourse_post_event.invitees_modal.remove_invitee"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{{/if}}
|
||||||
|
</li>
|
||||||
|
{{/each}}
|
||||||
|
</ul>
|
||||||
|
{{#if this.hasSuggestedUsers}}
|
||||||
|
<ul class="possible-invitees">
|
||||||
|
{{#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"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
{{/each}}
|
||||||
|
</ul>
|
||||||
|
{{/if}}
|
||||||
|
{{else}}
|
||||||
|
<p class="no-users">
|
||||||
|
{{i18n "discourse_post_event.models.invitee.no_users"}}
|
||||||
|
</p>
|
||||||
|
{{/if}}
|
||||||
|
</ConditionalLoadingSpinner>
|
||||||
|
</:body>
|
||||||
|
</DModal>
|
||||||
|
</template>
|
||||||
|
}
|
|
@ -0,0 +1,16 @@
|
||||||
|
import avatar from "discourse/helpers/avatar";
|
||||||
|
import { userPath } from "discourse/lib/url";
|
||||||
|
import { formatUsername } from "discourse/lib/utilities";
|
||||||
|
|
||||||
|
const User = <template>
|
||||||
|
<a href={{userPath @user.username}} data-user-card={{@user.username}}>
|
||||||
|
<span class="user">
|
||||||
|
{{avatar @user imageSize="medium"}}
|
||||||
|
<span class="username">
|
||||||
|
{{formatUsername @user.username}}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</template>;
|
||||||
|
|
||||||
|
export default User;
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { computed } from "@ember/object";
|
||||||
|
import { classNames } from "@ember-decorators/component";
|
||||||
|
import { i18n } from "discourse-i18n";
|
||||||
|
import ComboBoxComponent from "select-kit/components/combo-box";
|
||||||
|
import {
|
||||||
|
pluginApiIdentifiers,
|
||||||
|
selectKitOptions,
|
||||||
|
} from "select-kit/components/select-kit";
|
||||||
|
import { HOLIDAY_REGIONS } from "../lib/regions";
|
||||||
|
|
||||||
|
@selectKitOptions({
|
||||||
|
filterable: true,
|
||||||
|
allowAny: false,
|
||||||
|
})
|
||||||
|
@pluginApiIdentifiers("timezone-input")
|
||||||
|
@classNames("timezone-input", "region-input")
|
||||||
|
export default class RegionInput extends ComboBoxComponent {
|
||||||
|
allowNoneRegion = false;
|
||||||
|
|
||||||
|
@computed
|
||||||
|
get content() {
|
||||||
|
const localeNames = {};
|
||||||
|
let regions = [];
|
||||||
|
|
||||||
|
JSON.parse(this.siteSettings.available_locales).forEach((locale) => {
|
||||||
|
localeNames[locale.value] = locale.name;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.allowNoneRegion === true) {
|
||||||
|
regions.push({
|
||||||
|
name: i18n("discourse_calendar.region.none"),
|
||||||
|
id: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
regions = regions.concat(
|
||||||
|
HOLIDAY_REGIONS.map((region) => ({
|
||||||
|
name: i18n(`discourse_calendar.region.names.${region}`),
|
||||||
|
id: region,
|
||||||
|
})).sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
);
|
||||||
|
return regions;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { fn } from "@ember/helper";
|
||||||
|
import { eq } from "truth-helpers";
|
||||||
|
import DButton from "discourse/components/d-button";
|
||||||
|
import concatClass from "discourse/helpers/concat-class";
|
||||||
|
|
||||||
|
const ToggleInvitees = <template>
|
||||||
|
<div class="invitees-type-filter">
|
||||||
|
<DButton
|
||||||
|
@label="discourse_post_event.models.invitee.status.going"
|
||||||
|
class={{concatClass
|
||||||
|
"btn toggle-going"
|
||||||
|
(if (eq @viewType "going") "btn-danger" "btn-default")
|
||||||
|
}}
|
||||||
|
@action={{fn @toggle "going"}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DButton
|
||||||
|
@label="discourse_post_event.models.invitee.status.interested"
|
||||||
|
class={{concatClass
|
||||||
|
"btn toggle-interested"
|
||||||
|
(if (eq @viewType "interested") "btn-danger" "btn-default")
|
||||||
|
}}
|
||||||
|
@action={{fn @toggle "interested"}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DButton
|
||||||
|
@label="discourse_post_event.models.invitee.status.not_going"
|
||||||
|
class={{concatClass
|
||||||
|
"btn toggle-not-going"
|
||||||
|
(if (eq @viewType "not_going") "btn-danger" "btn-default")
|
||||||
|
}}
|
||||||
|
@action={{fn @toggle "not_going"}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>;
|
||||||
|
|
||||||
|
export default ToggleInvitees;
|
|
@ -0,0 +1,212 @@
|
||||||
|
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 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 { isNotFullDayEvent } from "../lib/guess-best-date-format";
|
||||||
|
|
||||||
|
export default class UpcomingEventsCalendar extends Component {
|
||||||
|
@service currentUser;
|
||||||
|
@service site;
|
||||||
|
@service router;
|
||||||
|
|
||||||
|
_calendar = null;
|
||||||
|
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
left: "prev,next today",
|
||||||
|
center: "title",
|
||||||
|
right: "month,basicWeek,listNextYear",
|
||||||
|
},
|
||||||
|
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");
|
||||||
|
|
||||||
|
if (!fcContent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
const { startsAt, endsAt, post, categoryId } = event;
|
||||||
|
|
||||||
|
let backgroundColor;
|
||||||
|
|
||||||
|
if (post.topic.tags) {
|
||||||
|
const tagColorEntry = tagsColorsMap.find(
|
||||||
|
(entry) =>
|
||||||
|
entry.type === "tag" && post.topic.tags.includes(entry.slug)
|
||||||
|
);
|
||||||
|
backgroundColor = tagColorEntry?.color;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!backgroundColor) {
|
||||||
|
const categoryColorEntry = tagsColorsMap.find(
|
||||||
|
(entry) =>
|
||||||
|
entry.type === "category" && entry.slug === post.topic.category_slug
|
||||||
|
);
|
||||||
|
backgroundColor = categoryColorEntry?.color;
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryColor = Category.findById(categoryId)?.color;
|
||||||
|
if (!backgroundColor && categoryColor) {
|
||||||
|
backgroundColor = `#${categoryColor}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let classNames;
|
||||||
|
if (moment(endsAt || startsAt).isBefore(moment())) {
|
||||||
|
classNames = "fc-past-event";
|
||||||
|
}
|
||||||
|
|
||||||
|
this._calendar.addEvent({
|
||||||
|
title: formatEventName(event, this.currentUser?.user_option?.timezone),
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</template>
|
||||||
|
}
|
|
@ -0,0 +1,222 @@
|
||||||
|
import Component from "@glimmer/component";
|
||||||
|
import { tracked } from "@glimmer/tracking";
|
||||||
|
import { action } from "@ember/object";
|
||||||
|
import { LinkTo } from "@ember/routing";
|
||||||
|
import { service } from "@ember/service";
|
||||||
|
import { or } from "truth-helpers";
|
||||||
|
import ConditionalLoadingSpinner from "discourse/components/conditional-loading-spinner";
|
||||||
|
import DButton from "discourse/components/d-button";
|
||||||
|
import PluginOutlet from "discourse/components/plugin-outlet";
|
||||||
|
import { ajax } from "discourse/lib/ajax";
|
||||||
|
import { i18n } from "discourse-i18n";
|
||||||
|
import { isNotFullDayEvent } from "../lib/guess-best-date-format";
|
||||||
|
|
||||||
|
export const DEFAULT_TIME_FORMAT = "LT";
|
||||||
|
const DEFAULT_UPCOMING_DAYS = 180;
|
||||||
|
const DEFAULT_COUNT = 8;
|
||||||
|
|
||||||
|
function addToResult(date, item, result) {
|
||||||
|
const day = date.format("DD");
|
||||||
|
const monthKey = date.format("YYYY-MM");
|
||||||
|
|
||||||
|
result[monthKey] = result[monthKey] ?? {};
|
||||||
|
result[monthKey][day] = result[monthKey][day] ?? [];
|
||||||
|
result[monthKey][day].push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class UpcomingEventsList extends Component {
|
||||||
|
@service appEvents;
|
||||||
|
@service siteSettings;
|
||||||
|
@service router;
|
||||||
|
|
||||||
|
@tracked isLoading = true;
|
||||||
|
@tracked hasError = false;
|
||||||
|
@tracked eventsByMonth = {};
|
||||||
|
|
||||||
|
timeFormat = this.args.params?.timeFormat ?? DEFAULT_TIME_FORMAT;
|
||||||
|
count = this.args.params?.count ?? DEFAULT_COUNT;
|
||||||
|
upcomingDays = this.args.params?.upcomingDays ?? DEFAULT_UPCOMING_DAYS;
|
||||||
|
|
||||||
|
emptyMessage = i18n("discourse_post_event.upcoming_events_list.empty");
|
||||||
|
allDayLabel = i18n("discourse_post_event.upcoming_events_list.all_day");
|
||||||
|
errorMessage = i18n("discourse_post_event.upcoming_events_list.error");
|
||||||
|
viewAllLabel = i18n("discourse_post_event.upcoming_events_list.view_all");
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super(...arguments);
|
||||||
|
this.appEvents.on("page:changed", this, this.updateEventsList);
|
||||||
|
}
|
||||||
|
|
||||||
|
get categoryId() {
|
||||||
|
return this.router.currentRoute.attributes?.category?.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasEmptyResponse() {
|
||||||
|
return (
|
||||||
|
!this.isLoading &&
|
||||||
|
!this.hasError &&
|
||||||
|
Object.keys(this.eventsByMonth).length === 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get title() {
|
||||||
|
const categorySlug = this.router.currentRoute.attributes?.category?.slug;
|
||||||
|
const titleSetting = this.siteSettings.map_events_title;
|
||||||
|
|
||||||
|
if (titleSetting === "") {
|
||||||
|
return i18n("discourse_post_event.upcoming_events_list.title");
|
||||||
|
}
|
||||||
|
|
||||||
|
const categories = JSON.parse(titleSetting).map(
|
||||||
|
({ category_slug }) => category_slug
|
||||||
|
);
|
||||||
|
|
||||||
|
if (categories.includes(categorySlug)) {
|
||||||
|
const titleMap = JSON.parse(titleSetting);
|
||||||
|
const customTitleLookup = titleMap.find(
|
||||||
|
(o) => o.category_slug === categorySlug
|
||||||
|
);
|
||||||
|
return customTitleLookup?.custom_title;
|
||||||
|
} else {
|
||||||
|
return i18n("discourse_post_event.upcoming_events_list.title");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
async updateEventsList() {
|
||||||
|
this.isLoading = true;
|
||||||
|
this.hasError = false;
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
limit: this.count,
|
||||||
|
before: moment().add(this.upcomingDays, "days").toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.categoryId) {
|
||||||
|
data.category_id = this.categoryId;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { events } = await ajax("/discourse-post-event/events", {
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.eventsByMonth = this.groupByMonthAndDay(events);
|
||||||
|
} catch {
|
||||||
|
this.hasError = true;
|
||||||
|
} finally {
|
||||||
|
this.isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
formatTime({ starts_at, ends_at }) {
|
||||||
|
return isNotFullDayEvent(moment(starts_at), moment(ends_at))
|
||||||
|
? moment(starts_at).format(this.timeFormat)
|
||||||
|
: this.allDayLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
startsAtMonth(month, day) {
|
||||||
|
return moment(`${month}-${day}`).format("MMM");
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
startsAtDay(month, day) {
|
||||||
|
return moment(`${month}-${day}`).format("D");
|
||||||
|
}
|
||||||
|
|
||||||
|
groupByMonthAndDay(data) {
|
||||||
|
return data.reduce((result, item) => {
|
||||||
|
const startDate = moment(item.starts_at);
|
||||||
|
const endDate = item.ends_at ? moment(item.ends_at) : null;
|
||||||
|
const today = moment();
|
||||||
|
|
||||||
|
if (!endDate) {
|
||||||
|
addToResult(startDate, item, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (startDate.isSameOrBefore(endDate, "day")) {
|
||||||
|
if (startDate.isAfter(today)) {
|
||||||
|
addToResult(startDate, item, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
startDate.add(1, "day");
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="upcoming-events-list">
|
||||||
|
<h3 class="upcoming-events-list__heading">
|
||||||
|
{{this.title}}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="upcoming-events-list__container">
|
||||||
|
<ConditionalLoadingSpinner @condition={{this.isLoading}} />
|
||||||
|
|
||||||
|
{{#if this.hasEmptyResponse}}
|
||||||
|
<div class="upcoming-events-list__empty-message">
|
||||||
|
{{this.emptyMessage}}
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if this.hasError}}
|
||||||
|
<div class="upcoming-events-list__error-message">
|
||||||
|
{{this.errorMessage}}
|
||||||
|
</div>
|
||||||
|
<DButton
|
||||||
|
@action={{this.updateEventsList}}
|
||||||
|
@label="discourse_post_event.upcoming_events_list.try_again"
|
||||||
|
class="btn-link upcoming-events-list__try-again"
|
||||||
|
/>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{#unless this.isLoading}}
|
||||||
|
<PluginOutlet @name="upcoming-events-list-container">
|
||||||
|
{{#each-in this.eventsByMonth as |month monthData|}}
|
||||||
|
{{#each-in monthData as |day events|}}
|
||||||
|
{{#each events as |event|}}
|
||||||
|
<a
|
||||||
|
class="upcoming-events-list__event"
|
||||||
|
href={{event.post.url}}
|
||||||
|
>
|
||||||
|
<div class="upcoming-events-list__event-date">
|
||||||
|
<div class="month">{{this.startsAtMonth month day}}</div>
|
||||||
|
<div class="day">{{this.startsAtDay month day}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="upcoming-events-list__event-content">
|
||||||
|
<span
|
||||||
|
class="upcoming-events-list__event-name"
|
||||||
|
title={{or event.name event.post.topic.title}}
|
||||||
|
>
|
||||||
|
{{or event.name event.post.topic.title}}
|
||||||
|
</span>
|
||||||
|
{{#if this.timeFormat}}
|
||||||
|
<span class="upcoming-events-list__event-time">
|
||||||
|
{{this.formatTime event}}
|
||||||
|
</span>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{{/each}}
|
||||||
|
{{/each-in}}
|
||||||
|
{{/each-in}}
|
||||||
|
</PluginOutlet>
|
||||||
|
{{/unless}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="upcoming-events-list__footer">
|
||||||
|
<LinkTo
|
||||||
|
@route="discourse-post-event-upcoming-events"
|
||||||
|
class="upcoming-events-list__view-all"
|
||||||
|
>
|
||||||
|
{{this.viewAllLabel}}
|
||||||
|
</LinkTo>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
}
|
|
@ -0,0 +1,13 @@
|
||||||
|
import Component from "@glimmer/component";
|
||||||
|
|
||||||
|
export default class CategoryCalendar extends Component {
|
||||||
|
static shouldRender(_, ctx) {
|
||||||
|
return (
|
||||||
|
ctx.siteSettings.calendar_categories_outlet === "before-topic-list-body"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="before-topic-list-body-outlet category-calendar"></div>
|
||||||
|
</template>
|
||||||
|
}
|
|
@ -0,0 +1,48 @@
|
||||||
|
import Component, { Input } from "@ember/component";
|
||||||
|
import { tagName } from "@ember-decorators/component";
|
||||||
|
import { or } from "truth-helpers";
|
||||||
|
import { i18n } from "discourse-i18n";
|
||||||
|
|
||||||
|
@tagName("")
|
||||||
|
export default class ShowEventCategorySortingSettings extends Component {
|
||||||
|
<template>
|
||||||
|
{{#if
|
||||||
|
(or
|
||||||
|
this.siteSettings.sort_categories_by_event_start_date_enabled
|
||||||
|
this.siteSettings.disable_resorting_on_categories_enabled
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
<section>
|
||||||
|
<h3>{{i18n
|
||||||
|
"discourse_post_event.category.settings_sections.event_sorting"
|
||||||
|
}}</h3>
|
||||||
|
|
||||||
|
{{#if this.siteSettings.sort_categories_by_event_start_date_enabled}}
|
||||||
|
<section class="field show-subcategory-list-field">
|
||||||
|
<label>
|
||||||
|
<Input
|
||||||
|
@type="checkbox"
|
||||||
|
@checked={{this.category.custom_fields.sort_topics_by_event_start_date}}
|
||||||
|
/>
|
||||||
|
{{i18n
|
||||||
|
"discourse_post_event.category.sort_topics_by_event_start_date"
|
||||||
|
}}
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if this.siteSettings.disable_resorting_on_categories_enabled}}
|
||||||
|
<section class="field show-subcategory-list-field">
|
||||||
|
<label>
|
||||||
|
<Input
|
||||||
|
@type="checkbox"
|
||||||
|
@checked={{this.category.custom_fields.disable_topic_resorting}}
|
||||||
|
/>
|
||||||
|
{{i18n "discourse_post_event.category.disable_topic_resorting"}}
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
{{/if}}
|
||||||
|
</section>
|
||||||
|
{{/if}}
|
||||||
|
</template>
|
||||||
|
}
|
|
@ -0,0 +1,14 @@
|
||||||
|
import Component from "@glimmer/component";
|
||||||
|
|
||||||
|
export default class CategoryEventsCalendar extends Component {
|
||||||
|
static shouldRender(_, ctx) {
|
||||||
|
return (
|
||||||
|
ctx.siteSettings.calendar_categories_outlet ===
|
||||||
|
"discovery-list-container-top"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div id="category-events-calendar"></div>
|
||||||
|
</template>
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
import EventDate from "../../components/event-date";
|
||||||
|
|
||||||
|
const EventDateContainer = <template>
|
||||||
|
<EventDate @topic={{@outletArgs.topic}} />
|
||||||
|
</template>;
|
||||||
|
|
||||||
|
export default EventDateContainer;
|
|
@ -0,0 +1,15 @@
|
||||||
|
import Component from "@glimmer/component";
|
||||||
|
import { service } from "@ember/service";
|
||||||
|
import EventDate from "../../components/event-date";
|
||||||
|
|
||||||
|
export default class EventBadge extends Component {
|
||||||
|
@service siteSettings;
|
||||||
|
|
||||||
|
<template>
|
||||||
|
{{~#if this.siteSettings.discourse_post_event_enabled~}}
|
||||||
|
{{~#if @outletArgs.topic.event_starts_at~}}
|
||||||
|
<EventDate @topic={{@outletArgs.topic}} />
|
||||||
|
{{~/if~}}
|
||||||
|
{{~/if~}}
|
||||||
|
</template>
|
||||||
|
}
|
|
@ -0,0 +1,50 @@
|
||||||
|
import Component from "@glimmer/component";
|
||||||
|
import { action } from "@ember/object";
|
||||||
|
import { service } from "@ember/service";
|
||||||
|
import DButton from "discourse/components/d-button";
|
||||||
|
import { i18n } from "discourse-i18n";
|
||||||
|
import RegionInput from "../../components/region-input";
|
||||||
|
import { TIME_ZONE_TO_REGION } from "../../lib/regions";
|
||||||
|
|
||||||
|
export default class Region extends Component {
|
||||||
|
static shouldRender(args, component) {
|
||||||
|
return component.siteSettings.calendar_enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
@service siteSettings;
|
||||||
|
|
||||||
|
@action
|
||||||
|
onChange(value) {
|
||||||
|
this.args.outletArgs.model.set("custom_fields.holidays-region", value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
useCurrentRegion() {
|
||||||
|
this.args.outletArgs.model.set(
|
||||||
|
"custom_fields.holidays-region",
|
||||||
|
TIME_ZONE_TO_REGION[moment.tz.guess()] || "us"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="control-group region">
|
||||||
|
<label class="control-label">
|
||||||
|
{{i18n "discourse_calendar.region.title"}}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<RegionInput
|
||||||
|
@value={{@outletArgs.model.custom_fields.holidays-region}}
|
||||||
|
@allowNoneRegion={{true}}
|
||||||
|
@onChange={{this.onChange}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DButton
|
||||||
|
@icon="globe"
|
||||||
|
@label="discourse_calendar.region.use_current_region"
|
||||||
|
@action={{this.useCurrentRegion}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
}
|
|
@ -0,0 +1,28 @@
|
||||||
|
import Controller from "@ember/controller";
|
||||||
|
import { action } from "@ember/object";
|
||||||
|
import { ajax } from "discourse/lib/ajax";
|
||||||
|
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||||
|
|
||||||
|
export default class AdminPluginsCalendarController extends Controller {
|
||||||
|
selectedRegion = null;
|
||||||
|
loading = false;
|
||||||
|
|
||||||
|
@action
|
||||||
|
async getHolidays(region_code) {
|
||||||
|
if (this.loading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.set("selectedRegion", region_code);
|
||||||
|
this.set("loading", true);
|
||||||
|
|
||||||
|
return ajax(
|
||||||
|
`/admin/discourse-calendar/holiday-regions/${region_code}/holidays`
|
||||||
|
)
|
||||||
|
.then((response) => {
|
||||||
|
this.model.set("holidays", response.holidays);
|
||||||
|
})
|
||||||
|
.catch(popupAjaxError)
|
||||||
|
.finally(() => this.set("loading", false));
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
import Controller from "@ember/controller";
|
||||||
|
|
||||||
|
export default class DiscoursePostEventUpcomingEventsIndexController extends Controller {
|
||||||
|
queryParams = ["view"];
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
import Controller from "@ember/controller";
|
||||||
|
|
||||||
|
export default class DiscoursePostEventUpcomingEventsMineController extends Controller {
|
||||||
|
queryParams = ["view"];
|
||||||
|
}
|
|
@ -0,0 +1,10 @@
|
||||||
|
export default function () {
|
||||||
|
this.route(
|
||||||
|
"discourse-post-event-upcoming-events",
|
||||||
|
{ path: "/upcoming-events" },
|
||||||
|
function () {
|
||||||
|
this.route("index", { path: "/" });
|
||||||
|
this.route("mine");
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { i18n } from "discourse-i18n";
|
||||||
|
|
||||||
|
function sameTimezoneOffset(timezone1, timezone2) {
|
||||||
|
if (!timezone1 || !timezone2) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const offset1 = moment.tz(timezone1).utcOffset();
|
||||||
|
const offset2 = moment.tz(timezone2).utcOffset();
|
||||||
|
return offset1 === offset2;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatEventName(event, userTimezone) {
|
||||||
|
let output = event.name || event.post.topic.title;
|
||||||
|
|
||||||
|
if (
|
||||||
|
event.showLocalTime &&
|
||||||
|
event.timezone &&
|
||||||
|
!sameTimezoneOffset(event.timezone, userTimezone)
|
||||||
|
) {
|
||||||
|
output +=
|
||||||
|
` (${i18n("discourse_calendar.local_time")}: ` +
|
||||||
|
moment(event.startsAt).tz(event.timezone).format("H:mma") +
|
||||||
|
")";
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { htmlSafe } from "@ember/template";
|
||||||
|
import guessDateFormat from "../lib/guess-best-date-format";
|
||||||
|
|
||||||
|
export default function (date) {
|
||||||
|
date = moment.utc(date).tz(moment.tz.guess());
|
||||||
|
const format = guessDateFormat(date);
|
||||||
|
return htmlSafe(date.format(format));
|
||||||
|
}
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { withPluginApi } from "discourse/lib/plugin-api";
|
||||||
|
import DiscoursePostEventEvent from "discourse/plugins/discourse-calendar/discourse/models/discourse-post-event-event";
|
||||||
|
import PostEventBuilder from "../components/modal/post-event-builder";
|
||||||
|
|
||||||
|
function initializeEventBuilder(api) {
|
||||||
|
const currentUser = api.getCurrentUser();
|
||||||
|
const modal = api.container.lookup("service:modal");
|
||||||
|
|
||||||
|
api.addComposerToolbarPopupMenuOption({
|
||||||
|
action: (toolbarEvent) => {
|
||||||
|
const event = DiscoursePostEventEvent.create({
|
||||||
|
status: "public",
|
||||||
|
starts_at: moment(),
|
||||||
|
timezone: moment.tz.guess(),
|
||||||
|
});
|
||||||
|
|
||||||
|
modal.show(PostEventBuilder, {
|
||||||
|
model: { event, toolbarEvent },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
group: "insertions",
|
||||||
|
icon: "calendar-day",
|
||||||
|
label: "discourse_post_event.builder_modal.attach",
|
||||||
|
condition: (composer) => {
|
||||||
|
if (!currentUser || !currentUser.can_create_discourse_post_event) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const composerModel = composer.model;
|
||||||
|
return (
|
||||||
|
composerModel &&
|
||||||
|
!composerModel.replyingToTopic &&
|
||||||
|
(composerModel.topicFirstPost ||
|
||||||
|
composerModel.creatingPrivateMessage ||
|
||||||
|
(composerModel.editingPost &&
|
||||||
|
composerModel.post &&
|
||||||
|
composerModel.post.post_number === 1))
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "add-post-event-builder",
|
||||||
|
initialize(container) {
|
||||||
|
const siteSettings = container.lookup("service:site-settings");
|
||||||
|
if (siteSettings.discourse_post_event_enabled) {
|
||||||
|
withPluginApi("0.8.7", initializeEventBuilder);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { withPluginApi } from "discourse/lib/plugin-api";
|
||||||
|
import { i18n } from "discourse-i18n";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "add-upcoming-events-to-sidebar",
|
||||||
|
|
||||||
|
initialize(container) {
|
||||||
|
const siteSettings = container.lookup("service:site-settings");
|
||||||
|
if (
|
||||||
|
siteSettings.discourse_post_event_enabled &&
|
||||||
|
siteSettings.sidebar_show_upcoming_events
|
||||||
|
) {
|
||||||
|
withPluginApi("0.8.7", (api) => {
|
||||||
|
api.addCommunitySectionLink((baseSectionLink) => {
|
||||||
|
return class UpcomingEventsSectionLink extends baseSectionLink {
|
||||||
|
name = "upcoming-events";
|
||||||
|
route = "discourse-post-event-upcoming-events";
|
||||||
|
text = i18n("discourse_post_event.upcoming_events.title");
|
||||||
|
title = i18n("discourse_post_event.upcoming_events.title");
|
||||||
|
defaultPrefixValue = "calendar-day";
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { withPluginApi } from "discourse/lib/plugin-api";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "disable-sort",
|
||||||
|
|
||||||
|
initialize(container) {
|
||||||
|
withPluginApi("0.8", (api) => {
|
||||||
|
api.registerValueTransformer(
|
||||||
|
"topic-list-header-sortable-column",
|
||||||
|
({ value, context }) => {
|
||||||
|
if (!value) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const siteSettings = container.lookup("service:site-settings");
|
||||||
|
return !(
|
||||||
|
siteSettings.disable_resorting_on_categories_enabled &&
|
||||||
|
context.category?.custom_fields?.disable_topic_resorting
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
File diff suppressed because it is too large
Load diff
|
@ -0,0 +1,159 @@
|
||||||
|
import { isTesting } from "discourse/lib/environment";
|
||||||
|
import { withPluginApi } from "discourse/lib/plugin-api";
|
||||||
|
import I18n, { i18n } from "discourse-i18n";
|
||||||
|
import DiscoursePostEvent from "discourse/plugins/discourse-calendar/discourse/components/discourse-post-event";
|
||||||
|
import DiscoursePostEventEvent from "discourse/plugins/discourse-calendar/discourse/models/discourse-post-event-event";
|
||||||
|
import guessDateFormat from "../lib/guess-best-date-format";
|
||||||
|
|
||||||
|
export function buildEventPreview(eventContainer) {
|
||||||
|
eventContainer.innerHTML = "";
|
||||||
|
eventContainer.classList.add("discourse-post-event-preview");
|
||||||
|
|
||||||
|
const statusLocaleKey = `discourse_post_event.models.event.status.${
|
||||||
|
eventContainer.dataset.status || "public"
|
||||||
|
}.title`;
|
||||||
|
if (I18n.lookup(statusLocaleKey, { locale: "en" })) {
|
||||||
|
const statusContainer = document.createElement("div");
|
||||||
|
statusContainer.classList.add("event-preview-status");
|
||||||
|
statusContainer.innerText = i18n(statusLocaleKey);
|
||||||
|
eventContainer.appendChild(statusContainer);
|
||||||
|
}
|
||||||
|
|
||||||
|
const datesContainer = document.createElement("div");
|
||||||
|
datesContainer.classList.add("event-preview-dates");
|
||||||
|
|
||||||
|
const startsAt = moment.tz(
|
||||||
|
eventContainer.dataset.start,
|
||||||
|
eventContainer.dataset.timezone || "UTC"
|
||||||
|
);
|
||||||
|
|
||||||
|
const endsAt =
|
||||||
|
eventContainer.dataset.end &&
|
||||||
|
moment.tz(
|
||||||
|
eventContainer.dataset.end,
|
||||||
|
eventContainer.dataset.timezone || "UTC"
|
||||||
|
);
|
||||||
|
|
||||||
|
const format = guessDateFormat(startsAt, endsAt);
|
||||||
|
const guessedTz = isTesting() ? "UTC" : moment.tz.guess();
|
||||||
|
|
||||||
|
let datesString = `<span class='start'>${startsAt
|
||||||
|
.tz(guessedTz)
|
||||||
|
.format(format)}</span>`;
|
||||||
|
if (endsAt) {
|
||||||
|
datesString += ` → <span class='end'>${endsAt
|
||||||
|
.tz(guessedTz)
|
||||||
|
.format(format)}</span>`;
|
||||||
|
}
|
||||||
|
datesContainer.innerHTML = datesString;
|
||||||
|
|
||||||
|
eventContainer.appendChild(datesContainer);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _invalidEventPreview(eventContainer) {
|
||||||
|
eventContainer.classList.add(
|
||||||
|
"discourse-post-event-preview",
|
||||||
|
"alert",
|
||||||
|
"alert-error"
|
||||||
|
);
|
||||||
|
eventContainer.classList.remove("discourse-post-event");
|
||||||
|
eventContainer.innerText = i18n(
|
||||||
|
"discourse_post_event.preview.more_than_one_event"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _decorateEventPreview(api, cooked) {
|
||||||
|
const eventContainers = cooked.querySelectorAll(".discourse-post-event");
|
||||||
|
|
||||||
|
eventContainers.forEach((eventContainer, index) => {
|
||||||
|
if (index > 0) {
|
||||||
|
_invalidEventPreview(eventContainer);
|
||||||
|
} else {
|
||||||
|
buildEventPreview(eventContainer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initializeDiscoursePostEventDecorator(api) {
|
||||||
|
api.decorateCookedElement(
|
||||||
|
(cooked, helper) => {
|
||||||
|
if (cooked.classList.contains("d-editor-preview")) {
|
||||||
|
_decorateEventPreview(api, cooked);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (helper) {
|
||||||
|
const post = helper.getModel();
|
||||||
|
|
||||||
|
if (!post?.event) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const postEventNode = cooked.querySelector(".discourse-post-event");
|
||||||
|
|
||||||
|
if (!postEventNode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wrapper = document.createElement("div");
|
||||||
|
postEventNode.before(wrapper);
|
||||||
|
|
||||||
|
const event = DiscoursePostEventEvent.create(post.event);
|
||||||
|
|
||||||
|
helper.renderGlimmer(
|
||||||
|
wrapper,
|
||||||
|
<template><DiscoursePostEvent @event={{event}} /></template>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "discourse-post-event-decorator",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
api.replaceIcon(
|
||||||
|
"notification.discourse_post_event.notifications.invite_user_notification",
|
||||||
|
"calendar-day"
|
||||||
|
);
|
||||||
|
|
||||||
|
api.replaceIcon(
|
||||||
|
"notification.discourse_post_event.notifications.invite_user_auto_notification",
|
||||||
|
"calendar-day"
|
||||||
|
);
|
||||||
|
|
||||||
|
api.replaceIcon(
|
||||||
|
"notification.discourse_calendar.invite_user_notification",
|
||||||
|
"calendar-day"
|
||||||
|
);
|
||||||
|
|
||||||
|
api.replaceIcon(
|
||||||
|
"notification.discourse_post_event.notifications.invite_user_predefined_attendance_notification",
|
||||||
|
"calendar-day"
|
||||||
|
);
|
||||||
|
|
||||||
|
api.replaceIcon(
|
||||||
|
"notification.discourse_post_event.notifications.before_event_reminder",
|
||||||
|
"calendar-day"
|
||||||
|
);
|
||||||
|
|
||||||
|
api.replaceIcon(
|
||||||
|
"notification.discourse_post_event.notifications.after_event_reminder",
|
||||||
|
"calendar-day"
|
||||||
|
);
|
||||||
|
|
||||||
|
api.replaceIcon(
|
||||||
|
"notification.discourse_post_event.notifications.ongoing_event_reminder",
|
||||||
|
"calendar-day"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "discourse-post-event-decorator",
|
||||||
|
|
||||||
|
initialize(container) {
|
||||||
|
const siteSettings = container.lookup("service:site-settings");
|
||||||
|
if (siteSettings.discourse_post_event_enabled) {
|
||||||
|
withPluginApi("0.8.7", initializeDiscoursePostEventDecorator);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { cancel } from "@ember/runloop";
|
||||||
|
import { isTesting } from "discourse/lib/environment";
|
||||||
|
import discourseLater from "discourse/lib/later";
|
||||||
|
import eventRelativeDate from "../lib/event-relative-date";
|
||||||
|
|
||||||
|
function computeRelativeEventDates() {
|
||||||
|
document
|
||||||
|
.querySelectorAll(".event-relative-date.topic-list")
|
||||||
|
.forEach((dateContainer) => eventRelativeDate(dateContainer));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "event-future-date",
|
||||||
|
|
||||||
|
initialize() {
|
||||||
|
computeRelativeEventDates();
|
||||||
|
|
||||||
|
if (!isTesting()) {
|
||||||
|
this._tick();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
teardown() {
|
||||||
|
if (this._interval) {
|
||||||
|
cancel(this._interval);
|
||||||
|
this._interval = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_tick() {
|
||||||
|
this._interval && cancel(this._interval);
|
||||||
|
|
||||||
|
this._interval = discourseLater(() => {
|
||||||
|
computeRelativeEventDates();
|
||||||
|
this._tick();
|
||||||
|
}, 60 * 1000);
|
||||||
|
},
|
||||||
|
};
|
|
@ -0,0 +1,28 @@
|
||||||
|
/* 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 [];
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
import I18n, { i18n } from "discourse-i18n";
|
||||||
|
|
||||||
|
export function getCurrentBcp47Locale() {
|
||||||
|
return I18n.currentLocale().replace("_", "-").toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCalendarButtonsText() {
|
||||||
|
return {
|
||||||
|
today: i18n("discourse_calendar.toolbar_button.today"),
|
||||||
|
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"),
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,28 @@
|
||||||
|
// https://stackoverflow.com/a/16348977
|
||||||
|
export function stringToColor(str) {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
// eslint-disable-next-line no-bitwise
|
||||||
|
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||||
|
}
|
||||||
|
let color = [];
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
// eslint-disable-next-line no-bitwise
|
||||||
|
let value = (hash >> (i * 8)) & 0xff;
|
||||||
|
color.push(value);
|
||||||
|
}
|
||||||
|
return color;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function colorToHex(color) {
|
||||||
|
let hex = "#";
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
hex += ("00" + Math.round(color[i]).toString(16)).slice(-2);
|
||||||
|
}
|
||||||
|
return hex;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function contrastColor(color) {
|
||||||
|
const luminance = 0.2126 * color[0] + 0.7152 * color[1] + 0.0722 * color[2];
|
||||||
|
return luminance / 255 >= 0.5 ? "#000d" : "#fffd";
|
||||||
|
}
|
|
@ -0,0 +1,127 @@
|
||||||
|
const calendarRule = {
|
||||||
|
tag: "calendar",
|
||||||
|
|
||||||
|
before: function (state, info) {
|
||||||
|
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"],
|
||||||
|
["data-calendar-type", info.attrs.type || "dynamic"],
|
||||||
|
["data-calendar-default-timezone", info.attrs.defaultTimezone],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (info.attrs.defaultView) {
|
||||||
|
mainCalendarDivToken.attrs.push([
|
||||||
|
"data-calendar-default-view",
|
||||||
|
info.attrs.defaultView,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (info.attrs.weekends) {
|
||||||
|
mainCalendarDivToken.attrs.push(["data-weekends", info.attrs.weekends]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (info.attrs.showAddToCalendar) {
|
||||||
|
mainCalendarDivToken.attrs.push([
|
||||||
|
"data-calendar-show-add-to-calendar",
|
||||||
|
info.attrs.showAddToCalendar === "true",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (info.attrs.fullDay) {
|
||||||
|
mainCalendarDivToken.attrs.push([
|
||||||
|
"data-calendar-full-day",
|
||||||
|
info.attrs.fullDay === "true",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (info.attrs.hiddenDays) {
|
||||||
|
mainCalendarDivToken.attrs.push([
|
||||||
|
"data-hidden-days",
|
||||||
|
info.attrs.hiddenDays,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
after: function (state) {
|
||||||
|
state.push("div_calendar", "div", -1);
|
||||||
|
state.push("div_calendar_wrap", "div", -1);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const groupTimezoneRule = {
|
||||||
|
tag: "timezones",
|
||||||
|
|
||||||
|
before: function (state, info) {
|
||||||
|
const wrapperDivToken = state.push("div_group_timezones", "div", 1);
|
||||||
|
wrapperDivToken.attrs = [
|
||||||
|
["class", "group-timezones"],
|
||||||
|
["data-group", info.attrs.group],
|
||||||
|
["data-size", info.attrs.size || "medium"],
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
after: function (state) {
|
||||||
|
state.push("div_group_timezones", "div", -1);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
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",
|
||||||
|
"h2.discourse-calendar-title",
|
||||||
|
"div[data-calendar-type]",
|
||||||
|
"div[data-calendar-default-view]",
|
||||||
|
"div[data-calendar-default-timezone]",
|
||||||
|
"div[data-weekends]",
|
||||||
|
"div[data-hidden-days]",
|
||||||
|
"div.group-timezones",
|
||||||
|
"div[data-group]",
|
||||||
|
"div[data-size]",
|
||||||
|
]);
|
||||||
|
|
||||||
|
helper.registerOptions((opts, siteSettings) => {
|
||||||
|
opts.features["discourse-calendar-enabled"] =
|
||||||
|
!!siteSettings.calendar_enabled;
|
||||||
|
});
|
||||||
|
|
||||||
|
helper.registerPlugin((md) => {
|
||||||
|
const features = md.options.discourse.features;
|
||||||
|
if (features["discourse-calendar-enabled"]) {
|
||||||
|
md.block.bbcode.ruler.push("discourse-calendar", calendarRule);
|
||||||
|
md.block.bbcode.ruler.push(
|
||||||
|
"discourse-group-timezones",
|
||||||
|
groupTimezoneRule
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
|
@ -0,0 +1,41 @@
|
||||||
|
const rule = {
|
||||||
|
tag: "event",
|
||||||
|
|
||||||
|
wrap(token, info) {
|
||||||
|
if (!info.attrs.start) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
token.attrs = [["class", "discourse-post-event"]];
|
||||||
|
|
||||||
|
Object.keys(info.attrs).forEach((key) => {
|
||||||
|
const value = info.attrs[key];
|
||||||
|
|
||||||
|
if (typeof value !== "undefined") {
|
||||||
|
token.attrs.push([`data-${dasherize(key)}`, value]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function dasherize(input) {
|
||||||
|
return input.replace(/[A-Z]/g, function (char, index) {
|
||||||
|
return (index !== 0 ? "-" : "") + char.toLowerCase();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setup(helper) {
|
||||||
|
helper.allowList(["div.discourse-post-event"]);
|
||||||
|
|
||||||
|
helper.registerOptions((opts, siteSettings) => {
|
||||||
|
opts.features.discourse_post_event =
|
||||||
|
siteSettings.calendar_enabled &&
|
||||||
|
siteSettings.discourse_post_event_enabled;
|
||||||
|
});
|
||||||
|
|
||||||
|
helper.registerPlugin((md) =>
|
||||||
|
md.block.bbcode.ruler.push("discourse-post-event", rule)
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { i18n } from "discourse-i18n";
|
||||||
|
import guessDateFormat from "./guess-best-date-format";
|
||||||
|
|
||||||
|
function _computeCurrentEvent(container, endsAt) {
|
||||||
|
const indicator = document.createElement("div");
|
||||||
|
indicator.classList.add("indicator");
|
||||||
|
container.appendChild(indicator);
|
||||||
|
|
||||||
|
const text = document.createElement("span");
|
||||||
|
text.classList.add("text");
|
||||||
|
text.innerText = i18n("discourse_post_event.topic_title.ends_in_duration", {
|
||||||
|
duration: endsAt.from(moment()),
|
||||||
|
});
|
||||||
|
container.appendChild(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _computePastEvent(container, endsAt) {
|
||||||
|
container.innerText = endsAt.from(moment());
|
||||||
|
}
|
||||||
|
|
||||||
|
function _computeFutureEvent(container, startsAt) {
|
||||||
|
container.innerText = startsAt.from(moment());
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function eventRelativeDate(container) {
|
||||||
|
container.classList.remove("past", "current", "future");
|
||||||
|
container.innerHTML = "";
|
||||||
|
|
||||||
|
const startsAt = moment
|
||||||
|
.utc(container.dataset.starts_at)
|
||||||
|
.tz(moment.tz.guess());
|
||||||
|
const endsAt = moment.utc(container.dataset.ends_at).tz(moment.tz.guess());
|
||||||
|
|
||||||
|
const format = guessDateFormat(startsAt);
|
||||||
|
let title = startsAt.format(format);
|
||||||
|
if (endsAt) {
|
||||||
|
title += ` → ${endsAt.format(format)}`;
|
||||||
|
}
|
||||||
|
container.setAttribute("title", title);
|
||||||
|
|
||||||
|
if (startsAt.isAfter(moment()) && endsAt.isAfter(moment())) {
|
||||||
|
container.classList.add("future");
|
||||||
|
_computeFutureEvent(container, startsAt);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startsAt.isBefore(moment()) && endsAt.isAfter(moment())) {
|
||||||
|
container.classList.add("current");
|
||||||
|
_computeCurrentEvent(container, endsAt);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startsAt.isBefore(moment()) && endsAt.isBefore(moment())) {
|
||||||
|
container.classList.add("past");
|
||||||
|
_computePastEvent(container, endsAt);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,25 @@
|
||||||
|
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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,18 @@
|
||||||
|
export function isNotFullDayEvent(startsAt, endsAt) {
|
||||||
|
return (
|
||||||
|
startsAt.hours() > 0 ||
|
||||||
|
startsAt.minutes() > 0 ||
|
||||||
|
(endsAt && (moment(endsAt).hours() > 0 || moment(endsAt).minutes() > 0))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function guessDateFormat(startsAt, endsAt) {
|
||||||
|
let format;
|
||||||
|
if (!isNotFullDayEvent(startsAt, endsAt)) {
|
||||||
|
format = "LL";
|
||||||
|
} else {
|
||||||
|
format = "LLL";
|
||||||
|
}
|
||||||
|
|
||||||
|
return format;
|
||||||
|
}
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { createPopper } from "@popperjs/core";
|
||||||
|
|
||||||
|
let eventPopper;
|
||||||
|
const EVENT_POPOVER_ID = "event-popover";
|
||||||
|
|
||||||
|
export function buildPopover(jsEvent, htmlContent) {
|
||||||
|
const node = document.createElement("div");
|
||||||
|
node.setAttribute("id", EVENT_POPOVER_ID);
|
||||||
|
node.innerHTML = htmlContent;
|
||||||
|
|
||||||
|
const arrow = document.createElement("span");
|
||||||
|
arrow.dataset.popperArrow = true;
|
||||||
|
node.appendChild(arrow);
|
||||||
|
document.body.appendChild(node);
|
||||||
|
|
||||||
|
eventPopper = createPopper(
|
||||||
|
jsEvent.target,
|
||||||
|
document.getElementById(EVENT_POPOVER_ID),
|
||||||
|
{
|
||||||
|
placement: "bottom",
|
||||||
|
modifiers: [
|
||||||
|
{
|
||||||
|
name: "arrow",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "offset",
|
||||||
|
options: {
|
||||||
|
offset: [20, 10],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function destroyPopover() {
|
||||||
|
eventPopper?.destroy();
|
||||||
|
document.getElementById(EVENT_POPOVER_ID)?.remove();
|
||||||
|
}
|
|
@ -0,0 +1,136 @@
|
||||||
|
export function buildParams(startsAt, endsAt, event, siteSettings) {
|
||||||
|
const params = {};
|
||||||
|
|
||||||
|
const eventTz = event.timezone || "UTC";
|
||||||
|
|
||||||
|
params.start = moment(startsAt).tz(eventTz).format("YYYY-MM-DD HH:mm");
|
||||||
|
|
||||||
|
if (event.isClosed) {
|
||||||
|
params.closed = "true";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.status) {
|
||||||
|
params.status = event.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.name) {
|
||||||
|
params.name = event.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.location) {
|
||||||
|
params.location = event.location;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.description) {
|
||||||
|
params.description = event.description;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.url) {
|
||||||
|
params.url = event.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.timezone) {
|
||||||
|
params.timezone = event.timezone;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.recurrence) {
|
||||||
|
params.recurrence = event.recurrence;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.recurrenceUntil) {
|
||||||
|
params.recurrenceUntil = moment(event.recurrenceUntil)
|
||||||
|
.tz(eventTz)
|
||||||
|
.format("YYYY-MM-DD HH:mm");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.showLocalTime) {
|
||||||
|
params.showLocalTime = "true";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.minimal) {
|
||||||
|
params.minimal = "true";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.chatEnabled) {
|
||||||
|
params.chatEnabled = "true";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endsAt) {
|
||||||
|
params.end = moment(endsAt).tz(eventTz).format("YYYY-MM-DD HH:mm");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.status === "private") {
|
||||||
|
params.allowedGroups = (event.rawInvitees || []).join(",");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.status === "public") {
|
||||||
|
params.allowedGroups = "trust_level_0";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.reminders && event.reminders.length) {
|
||||||
|
params.reminders = event.reminders
|
||||||
|
.map((r) => {
|
||||||
|
// we create a new intermediate object to avoid changes in the UI while
|
||||||
|
// we prepare the values for request
|
||||||
|
const reminder = Object.assign({}, r);
|
||||||
|
|
||||||
|
if (reminder.period === "after") {
|
||||||
|
reminder.value = `-${Math.abs(parseInt(reminder.value, 10))}`;
|
||||||
|
}
|
||||||
|
if (reminder.period === "before") {
|
||||||
|
reminder.value = Math.abs(parseInt(`${reminder.value}`, 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${reminder.type}.${reminder.value}.${reminder.unit}`;
|
||||||
|
})
|
||||||
|
.join(",");
|
||||||
|
}
|
||||||
|
|
||||||
|
siteSettings.discourse_post_event_allowed_custom_fields
|
||||||
|
.split("|")
|
||||||
|
.filter(Boolean)
|
||||||
|
.forEach((setting) => {
|
||||||
|
const param = camelCase(setting);
|
||||||
|
if (typeof event.customFields[setting] !== "undefined") {
|
||||||
|
params[param] = event.customFields[setting];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function replaceRaw(params, raw) {
|
||||||
|
const eventRegex = /\[event (.*?)\](.*?)\[\/event\]/s;
|
||||||
|
const eventMatches = raw.match(eventRegex);
|
||||||
|
|
||||||
|
if (eventMatches && eventMatches[1]) {
|
||||||
|
const markdownParams = [];
|
||||||
|
|
||||||
|
let description = params.description;
|
||||||
|
description = description ? `${description}\n` : "";
|
||||||
|
delete params.description;
|
||||||
|
|
||||||
|
Object.keys(params).forEach((param) => {
|
||||||
|
const value = params[param];
|
||||||
|
if (value && value.length) {
|
||||||
|
markdownParams.push(`${param}="${value.replace(/"/g, "")}"`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return raw.replace(
|
||||||
|
eventRegex,
|
||||||
|
`[event ${markdownParams.join(" ")}]\n${description}[/event]`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function camelCase(input) {
|
||||||
|
return input
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/-/g, "_")
|
||||||
|
.replace(/_(.)/g, function (match, group1) {
|
||||||
|
return group1.toUpperCase();
|
||||||
|
});
|
||||||
|
}
|
|
@ -0,0 +1,481 @@
|
||||||
|
// DO NOT EDIT THIS FILE!!!
|
||||||
|
// Update it by running `rake javascript:update_constants`
|
||||||
|
|
||||||
|
export const HOLIDAY_REGIONS = [
|
||||||
|
"ae",
|
||||||
|
"ar",
|
||||||
|
"at",
|
||||||
|
"au",
|
||||||
|
"au_nsw",
|
||||||
|
"au_vic",
|
||||||
|
"au_qld",
|
||||||
|
"au_nt",
|
||||||
|
"au_act",
|
||||||
|
"au_sa",
|
||||||
|
"au_wa",
|
||||||
|
"au_tas",
|
||||||
|
"au_tas_south",
|
||||||
|
"au_qld_cairns",
|
||||||
|
"au_qld_brisbane",
|
||||||
|
"au_tas_north",
|
||||||
|
"au_vic_melbourne",
|
||||||
|
"be_fr",
|
||||||
|
"be_nl",
|
||||||
|
"br",
|
||||||
|
"br_spcapital",
|
||||||
|
"br_sp",
|
||||||
|
"bg_en",
|
||||||
|
"bg_bg",
|
||||||
|
"ca",
|
||||||
|
"ca_qc",
|
||||||
|
"ca_ab",
|
||||||
|
"ca_sk",
|
||||||
|
"ca_on",
|
||||||
|
"ca_bc",
|
||||||
|
"ca_nb",
|
||||||
|
"ca_mb",
|
||||||
|
"ca_ns",
|
||||||
|
"ca_pe",
|
||||||
|
"ca_nl",
|
||||||
|
"ca_nt",
|
||||||
|
"ca_nu",
|
||||||
|
"ca_yt",
|
||||||
|
"us",
|
||||||
|
"ch_zh",
|
||||||
|
"ch_be",
|
||||||
|
"ch_lu",
|
||||||
|
"ch_ur",
|
||||||
|
"ch_sz",
|
||||||
|
"ch_ow",
|
||||||
|
"ch_nw",
|
||||||
|
"ch_gl",
|
||||||
|
"ch_zg",
|
||||||
|
"ch_fr",
|
||||||
|
"ch_so",
|
||||||
|
"ch_bs",
|
||||||
|
"ch_bl",
|
||||||
|
"ch_sh",
|
||||||
|
"ch_ar",
|
||||||
|
"ch_ai",
|
||||||
|
"ch_sg",
|
||||||
|
"ch_gr",
|
||||||
|
"ch_ag",
|
||||||
|
"ch_tg",
|
||||||
|
"ch_ti",
|
||||||
|
"ch_vd",
|
||||||
|
"ch_ne",
|
||||||
|
"ch_ge",
|
||||||
|
"ch_ju",
|
||||||
|
"ch_vs",
|
||||||
|
"ch",
|
||||||
|
"cl",
|
||||||
|
"co",
|
||||||
|
"cr",
|
||||||
|
"cz",
|
||||||
|
"dk",
|
||||||
|
"de",
|
||||||
|
"de_bw",
|
||||||
|
"de_by",
|
||||||
|
"de_he",
|
||||||
|
"de_nw",
|
||||||
|
"de_rp",
|
||||||
|
"de_sl",
|
||||||
|
"de_sn_sorbian",
|
||||||
|
"de_th_cath",
|
||||||
|
"de_sn",
|
||||||
|
"de_st",
|
||||||
|
"de_be",
|
||||||
|
"de_by_cath",
|
||||||
|
"de_by_augsburg",
|
||||||
|
"de_bb",
|
||||||
|
"de_mv",
|
||||||
|
"de_th",
|
||||||
|
"de_hb",
|
||||||
|
"de_hh",
|
||||||
|
"de_ni",
|
||||||
|
"de_sh",
|
||||||
|
"ee",
|
||||||
|
"el",
|
||||||
|
"es_pv",
|
||||||
|
"es_na",
|
||||||
|
"es_an",
|
||||||
|
"es_ib",
|
||||||
|
"es_cm",
|
||||||
|
"es_mu",
|
||||||
|
"es_m",
|
||||||
|
"es_ar",
|
||||||
|
"es_cl",
|
||||||
|
"es_cn",
|
||||||
|
"es_lo",
|
||||||
|
"es_ga",
|
||||||
|
"es_ce",
|
||||||
|
"es_o",
|
||||||
|
"es_ex",
|
||||||
|
"es",
|
||||||
|
"es_ct",
|
||||||
|
"es_v",
|
||||||
|
"es_vc",
|
||||||
|
"fi",
|
||||||
|
"fr_a",
|
||||||
|
"fr_m",
|
||||||
|
"fr",
|
||||||
|
"gb",
|
||||||
|
"gb_eng",
|
||||||
|
"gb_wls",
|
||||||
|
"gb_eaw",
|
||||||
|
"gb_nir",
|
||||||
|
"je",
|
||||||
|
"gb_jsy",
|
||||||
|
"gg",
|
||||||
|
"gb_gsy",
|
||||||
|
"gb_sct",
|
||||||
|
"gb_con",
|
||||||
|
"im",
|
||||||
|
"gb_iom",
|
||||||
|
"ge",
|
||||||
|
"gh",
|
||||||
|
"hr",
|
||||||
|
"hk",
|
||||||
|
"hu",
|
||||||
|
"id",
|
||||||
|
"ie",
|
||||||
|
"in",
|
||||||
|
"in_mh",
|
||||||
|
"in_gj",
|
||||||
|
"in_ka",
|
||||||
|
"in_tn",
|
||||||
|
"is",
|
||||||
|
"it",
|
||||||
|
"it_ve",
|
||||||
|
"it_tv",
|
||||||
|
"it_vr",
|
||||||
|
"it_pd",
|
||||||
|
"it_fi",
|
||||||
|
"it_ge",
|
||||||
|
"it_to",
|
||||||
|
"it_rm",
|
||||||
|
"it_vi",
|
||||||
|
"it_bl",
|
||||||
|
"it_ro",
|
||||||
|
"kr",
|
||||||
|
"kz",
|
||||||
|
"li",
|
||||||
|
"lt",
|
||||||
|
"lv",
|
||||||
|
"ma",
|
||||||
|
"mt_mt",
|
||||||
|
"mt_en",
|
||||||
|
"mx",
|
||||||
|
"mx_pue",
|
||||||
|
"nl",
|
||||||
|
"lu",
|
||||||
|
"no",
|
||||||
|
"nz",
|
||||||
|
"nz_sl",
|
||||||
|
"nz_we",
|
||||||
|
"nz_ak",
|
||||||
|
"nz_nl",
|
||||||
|
"nz_ne",
|
||||||
|
"nz_ot",
|
||||||
|
"nz_ta",
|
||||||
|
"nz_sc",
|
||||||
|
"nz_hb",
|
||||||
|
"nz_mb",
|
||||||
|
"nz_ca",
|
||||||
|
"nz_ch",
|
||||||
|
"nz_wl",
|
||||||
|
"pe",
|
||||||
|
"ph",
|
||||||
|
"pl",
|
||||||
|
"pt",
|
||||||
|
"pt_li",
|
||||||
|
"pt_po",
|
||||||
|
"ro",
|
||||||
|
"rs_cyrl",
|
||||||
|
"rs_la",
|
||||||
|
"ru",
|
||||||
|
"se",
|
||||||
|
"sa",
|
||||||
|
"tn",
|
||||||
|
"tr",
|
||||||
|
"ua",
|
||||||
|
"us_fl",
|
||||||
|
"us_la",
|
||||||
|
"us_ct",
|
||||||
|
"us_de",
|
||||||
|
"us_gu",
|
||||||
|
"us_hi",
|
||||||
|
"us_in",
|
||||||
|
"us_ky",
|
||||||
|
"us_nj",
|
||||||
|
"us_nc",
|
||||||
|
"us_nd",
|
||||||
|
"us_pr",
|
||||||
|
"us_tn",
|
||||||
|
"us_ms",
|
||||||
|
"us_id",
|
||||||
|
"us_ar",
|
||||||
|
"us_tx",
|
||||||
|
"us_dc",
|
||||||
|
"us_md",
|
||||||
|
"us_va",
|
||||||
|
"us_vt",
|
||||||
|
"us_ak",
|
||||||
|
"us_ca",
|
||||||
|
"us_me",
|
||||||
|
"us_ma",
|
||||||
|
"us_al",
|
||||||
|
"us_ga",
|
||||||
|
"us_ne",
|
||||||
|
"us_mo",
|
||||||
|
"us_sc",
|
||||||
|
"us_wv",
|
||||||
|
"us_vi",
|
||||||
|
"us_ut",
|
||||||
|
"us_ri",
|
||||||
|
"us_az",
|
||||||
|
"us_co",
|
||||||
|
"us_oh",
|
||||||
|
"us_or",
|
||||||
|
"us_sd",
|
||||||
|
"us_wy",
|
||||||
|
"us_nv",
|
||||||
|
"us_mt",
|
||||||
|
"us_ny",
|
||||||
|
"us_pa",
|
||||||
|
"us_nm",
|
||||||
|
"us_ia",
|
||||||
|
"us_il",
|
||||||
|
"us_ks",
|
||||||
|
"us_mi",
|
||||||
|
"us_mn",
|
||||||
|
"us_nh",
|
||||||
|
"us_ok",
|
||||||
|
"us_wa",
|
||||||
|
"us_wi",
|
||||||
|
"za",
|
||||||
|
"ve",
|
||||||
|
"sk",
|
||||||
|
"si",
|
||||||
|
"jp",
|
||||||
|
"vi",
|
||||||
|
"sg",
|
||||||
|
"my",
|
||||||
|
"th",
|
||||||
|
"ng",
|
||||||
|
"ke",
|
||||||
|
"zw",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const TIME_ZONE_TO_REGION = {
|
||||||
|
"Africa/Accra": "gh",
|
||||||
|
"Africa/Casablanca": "ma",
|
||||||
|
"Africa/Ceuta": "es",
|
||||||
|
"Africa/Harare": "zw",
|
||||||
|
"Africa/Johannesburg": "za",
|
||||||
|
"Africa/Lagos": "ng",
|
||||||
|
"Africa/Nairobi": "ke",
|
||||||
|
"Africa/Tunis": "tn",
|
||||||
|
"America/Adak": "us",
|
||||||
|
"America/Anchorage": "us",
|
||||||
|
"America/Araguaina": "br",
|
||||||
|
"America/Argentina/Buenos_Aires": "ar",
|
||||||
|
"America/Argentina/Catamarca": "ar",
|
||||||
|
"America/Argentina/Cordoba": "ar",
|
||||||
|
"America/Argentina/Jujuy": "ar",
|
||||||
|
"America/Argentina/La_Rioja": "ar",
|
||||||
|
"America/Argentina/Mendoza": "ar",
|
||||||
|
"America/Argentina/Rio_Gallegos": "ar",
|
||||||
|
"America/Argentina/Salta": "ar",
|
||||||
|
"America/Argentina/San_Juan": "ar",
|
||||||
|
"America/Argentina/San_Luis": "ar",
|
||||||
|
"America/Argentina/Tucuman": "ar",
|
||||||
|
"America/Argentina/Ushuaia": "ar",
|
||||||
|
"America/Atikokan": "ca",
|
||||||
|
"America/Bahia": "br",
|
||||||
|
"America/Bahia_Banderas": "mx",
|
||||||
|
"America/Belem": "br",
|
||||||
|
"America/Blanc-Sablon": "ca",
|
||||||
|
"America/Boa_Vista": "br",
|
||||||
|
"America/Bogota": "co",
|
||||||
|
"America/Boise": "us",
|
||||||
|
"America/Cambridge_Bay": "ca",
|
||||||
|
"America/Campo_Grande": "br",
|
||||||
|
"America/Cancun": "mx",
|
||||||
|
"America/Caracas": "ve",
|
||||||
|
"America/Chicago": "us",
|
||||||
|
"America/Chihuahua": "mx",
|
||||||
|
"America/Ciudad_Juarez": "mx",
|
||||||
|
"America/Costa_Rica": "cr",
|
||||||
|
"America/Coyhaique": "cl",
|
||||||
|
"America/Creston": "ca",
|
||||||
|
"America/Cuiaba": "br",
|
||||||
|
"America/Dawson": "ca",
|
||||||
|
"America/Dawson_Creek": "ca",
|
||||||
|
"America/Denver": "us",
|
||||||
|
"America/Detroit": "us",
|
||||||
|
"America/Edmonton": "ca",
|
||||||
|
"America/Eirunepe": "br",
|
||||||
|
"America/Fort_Nelson": "ca",
|
||||||
|
"America/Fortaleza": "br",
|
||||||
|
"America/Glace_Bay": "ca",
|
||||||
|
"America/Goose_Bay": "ca",
|
||||||
|
"America/Halifax": "ca",
|
||||||
|
"America/Hermosillo": "mx",
|
||||||
|
"America/Indiana/Indianapolis": "us",
|
||||||
|
"America/Indiana/Knox": "us",
|
||||||
|
"America/Indiana/Marengo": "us",
|
||||||
|
"America/Indiana/Petersburg": "us",
|
||||||
|
"America/Indiana/Tell_City": "us",
|
||||||
|
"America/Indiana/Vevay": "us",
|
||||||
|
"America/Indiana/Vincennes": "us",
|
||||||
|
"America/Indiana/Winamac": "us",
|
||||||
|
"America/Inuvik": "ca",
|
||||||
|
"America/Iqaluit": "ca",
|
||||||
|
"America/Juneau": "us",
|
||||||
|
"America/Kentucky/Louisville": "us",
|
||||||
|
"America/Kentucky/Monticello": "us",
|
||||||
|
"America/Lima": "pe",
|
||||||
|
"America/Los_Angeles": "us",
|
||||||
|
"America/Maceio": "br",
|
||||||
|
"America/Manaus": "br",
|
||||||
|
"America/Matamoros": "mx",
|
||||||
|
"America/Mazatlan": "mx",
|
||||||
|
"America/Menominee": "us",
|
||||||
|
"America/Merida": "mx",
|
||||||
|
"America/Metlakatla": "us",
|
||||||
|
"America/Mexico_City": "mx",
|
||||||
|
"America/Moncton": "ca",
|
||||||
|
"America/Monterrey": "mx",
|
||||||
|
"America/New_York": "us",
|
||||||
|
"America/Nome": "us",
|
||||||
|
"America/Noronha": "br",
|
||||||
|
"America/North_Dakota/Beulah": "us",
|
||||||
|
"America/North_Dakota/Center": "us",
|
||||||
|
"America/North_Dakota/New_Salem": "us",
|
||||||
|
"America/Ojinaga": "mx",
|
||||||
|
"America/Phoenix": "us",
|
||||||
|
"America/Porto_Velho": "br",
|
||||||
|
"America/Punta_Arenas": "cl",
|
||||||
|
"America/Rankin_Inlet": "ca",
|
||||||
|
"America/Recife": "br",
|
||||||
|
"America/Regina": "ca",
|
||||||
|
"America/Resolute": "ca",
|
||||||
|
"America/Rio_Branco": "br",
|
||||||
|
"America/Santarem": "br",
|
||||||
|
"America/Santiago": "cl",
|
||||||
|
"America/Sao_Paulo": "br",
|
||||||
|
"America/Sitka": "us",
|
||||||
|
"America/St_Johns": "ca",
|
||||||
|
"America/St_Thomas": "vi",
|
||||||
|
"America/Swift_Current": "ca",
|
||||||
|
"America/Tijuana": "mx",
|
||||||
|
"America/Toronto": "ca",
|
||||||
|
"America/Vancouver": "ca",
|
||||||
|
"America/Whitehorse": "ca",
|
||||||
|
"America/Winnipeg": "ca",
|
||||||
|
"America/Yakutat": "us",
|
||||||
|
"Antarctica/Macquarie": "au",
|
||||||
|
"Asia/Almaty": "kz",
|
||||||
|
"Asia/Anadyr": "ru",
|
||||||
|
"Asia/Aqtau": "kz",
|
||||||
|
"Asia/Aqtobe": "kz",
|
||||||
|
"Asia/Atyrau": "kz",
|
||||||
|
"Asia/Bangkok": "th",
|
||||||
|
"Asia/Barnaul": "ru",
|
||||||
|
"Asia/Chita": "ru",
|
||||||
|
"Asia/Dubai": "ae",
|
||||||
|
"Asia/Hong_Kong": "hk",
|
||||||
|
"Asia/Irkutsk": "ru",
|
||||||
|
"Asia/Jakarta": "id",
|
||||||
|
"Asia/Jayapura": "id",
|
||||||
|
"Asia/Kamchatka": "ru",
|
||||||
|
"Asia/Khandyga": "ru",
|
||||||
|
"Asia/Kolkata": "in",
|
||||||
|
"Asia/Krasnoyarsk": "ru",
|
||||||
|
"Asia/Kuala_Lumpur": "my",
|
||||||
|
"Asia/Kuching": "my",
|
||||||
|
"Asia/Magadan": "ru",
|
||||||
|
"Asia/Makassar": "id",
|
||||||
|
"Asia/Manila": "ph",
|
||||||
|
"Asia/Novokuznetsk": "ru",
|
||||||
|
"Asia/Novosibirsk": "ru",
|
||||||
|
"Asia/Omsk": "ru",
|
||||||
|
"Asia/Oral": "kz",
|
||||||
|
"Asia/Pontianak": "id",
|
||||||
|
"Asia/Qostanay": "kz",
|
||||||
|
"Asia/Qyzylorda": "kz",
|
||||||
|
"Asia/Riyadh": "sa",
|
||||||
|
"Asia/Sakhalin": "ru",
|
||||||
|
"Asia/Seoul": "kr",
|
||||||
|
"Asia/Singapore": "sg",
|
||||||
|
"Asia/Srednekolymsk": "ru",
|
||||||
|
"Asia/Tbilisi": "ge",
|
||||||
|
"Asia/Tokyo": "jp",
|
||||||
|
"Asia/Tomsk": "ru",
|
||||||
|
"Asia/Ust-Nera": "ru",
|
||||||
|
"Asia/Vladivostok": "ru",
|
||||||
|
"Asia/Yakutsk": "ru",
|
||||||
|
"Asia/Yekaterinburg": "ru",
|
||||||
|
"Atlantic/Azores": "pt",
|
||||||
|
"Atlantic/Canary": "es",
|
||||||
|
"Atlantic/Madeira": "pt",
|
||||||
|
"Atlantic/Reykjavik": "is",
|
||||||
|
"Australia/Adelaide": "au",
|
||||||
|
"Australia/Brisbane": "au",
|
||||||
|
"Australia/Broken_Hill": "au",
|
||||||
|
"Australia/Darwin": "au",
|
||||||
|
"Australia/Eucla": "au",
|
||||||
|
"Australia/Hobart": "au",
|
||||||
|
"Australia/Lindeman": "au",
|
||||||
|
"Australia/Lord_Howe": "au",
|
||||||
|
"Australia/Melbourne": "au",
|
||||||
|
"Australia/Perth": "au",
|
||||||
|
"Australia/Sydney": "au",
|
||||||
|
"Europe/Amsterdam": "nl",
|
||||||
|
"Europe/Astrakhan": "ru",
|
||||||
|
"Europe/Athens": "el",
|
||||||
|
"Europe/Berlin": "de",
|
||||||
|
"Europe/Bratislava": "sk",
|
||||||
|
"Europe/Bucharest": "ro",
|
||||||
|
"Europe/Budapest": "hu",
|
||||||
|
"Europe/Busingen": "de",
|
||||||
|
"Europe/Copenhagen": "dk",
|
||||||
|
"Europe/Dublin": "ie",
|
||||||
|
"Europe/Guernsey": "gg",
|
||||||
|
"Europe/Helsinki": "fi",
|
||||||
|
"Europe/Isle_of_Man": "im",
|
||||||
|
"Europe/Istanbul": "tr",
|
||||||
|
"Europe/Jersey": "je",
|
||||||
|
"Europe/Kaliningrad": "ru",
|
||||||
|
"Europe/Kirov": "ru",
|
||||||
|
"Europe/Kyiv": "ua",
|
||||||
|
"Europe/Lisbon": "pt",
|
||||||
|
"Europe/Ljubljana": "si",
|
||||||
|
"Europe/London": "gb",
|
||||||
|
"Europe/Luxembourg": "lu",
|
||||||
|
"Europe/Madrid": "es",
|
||||||
|
"Europe/Moscow": "ru",
|
||||||
|
"Europe/Oslo": "no",
|
||||||
|
"Europe/Paris": "fr",
|
||||||
|
"Europe/Prague": "cz",
|
||||||
|
"Europe/Riga": "lv",
|
||||||
|
"Europe/Rome": "it",
|
||||||
|
"Europe/Samara": "ru",
|
||||||
|
"Europe/Saratov": "ru",
|
||||||
|
"Europe/Simferopol": "ru",
|
||||||
|
"Europe/Stockholm": "se",
|
||||||
|
"Europe/Tallinn": "ee",
|
||||||
|
"Europe/Ulyanovsk": "ru",
|
||||||
|
"Europe/Vaduz": "li",
|
||||||
|
"Europe/Vienna": "at",
|
||||||
|
"Europe/Vilnius": "lt",
|
||||||
|
"Europe/Volgograd": "ru",
|
||||||
|
"Europe/Warsaw": "pl",
|
||||||
|
"Europe/Zagreb": "hr",
|
||||||
|
"Europe/Zurich": "ch",
|
||||||
|
"Pacific/Auckland": "nz",
|
||||||
|
"Pacific/Chatham": "nz",
|
||||||
|
"Pacific/Easter": "cl",
|
||||||
|
"Pacific/Honolulu": "us",
|
||||||
|
};
|
|
@ -0,0 +1,84 @@
|
||||||
|
// https://github.com/WebDevTmas/moment-round
|
||||||
|
if (typeof moment.fn.round !== "function") {
|
||||||
|
moment.fn.round = function (precision, key, direction) {
|
||||||
|
direction = direction || "round";
|
||||||
|
let _this = this; //cache of this
|
||||||
|
let methods = {
|
||||||
|
hours: { name: "Hours", maxValue: 24 },
|
||||||
|
minutes: { name: "Minutes", maxValue: 60 },
|
||||||
|
seconds: { name: "Seconds", maxValue: 60 },
|
||||||
|
milliseconds: { name: "Milliseconds", maxValue: 1000 },
|
||||||
|
};
|
||||||
|
let keys = {
|
||||||
|
mm: methods.milliseconds.name,
|
||||||
|
milliseconds: methods.milliseconds.name,
|
||||||
|
Milliseconds: methods.milliseconds.name,
|
||||||
|
s: methods.seconds.name,
|
||||||
|
seconds: methods.seconds.name,
|
||||||
|
Seconds: methods.seconds.name,
|
||||||
|
m: methods.minutes.name,
|
||||||
|
minutes: methods.minutes.name,
|
||||||
|
Minutes: methods.minutes.name,
|
||||||
|
H: methods.hours.name,
|
||||||
|
h: methods.hours.name,
|
||||||
|
hours: methods.hours.name,
|
||||||
|
Hours: methods.hours.name,
|
||||||
|
};
|
||||||
|
let value = 0;
|
||||||
|
let rounded = false;
|
||||||
|
let subRatio = 1;
|
||||||
|
let maxValue;
|
||||||
|
|
||||||
|
// make sure key is plural
|
||||||
|
if (key.length > 1 && key !== "mm" && key.slice(-1) !== "s") {
|
||||||
|
key += "s";
|
||||||
|
}
|
||||||
|
key = keys[key].toLowerCase();
|
||||||
|
|
||||||
|
//control
|
||||||
|
if (!methods[key]) {
|
||||||
|
throw new Error(
|
||||||
|
'The value to round is not valid. Possibles ["hours", "minutes", "seconds", "milliseconds"]'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let get = "get" + methods[key].name;
|
||||||
|
let set = "set" + methods[key].name;
|
||||||
|
|
||||||
|
for (let k in methods) {
|
||||||
|
if (k === key) {
|
||||||
|
value = _this._d[get]();
|
||||||
|
maxValue = methods[k].maxValue;
|
||||||
|
rounded = true;
|
||||||
|
} else if (rounded) {
|
||||||
|
subRatio *= methods[k].maxValue;
|
||||||
|
value += _this._d["get" + methods[k].name]() / subRatio;
|
||||||
|
_this._d["set" + methods[k].name](0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
value = Math[direction](value / precision) * precision;
|
||||||
|
value = Math.min(value, maxValue);
|
||||||
|
_this._d[set](value);
|
||||||
|
|
||||||
|
return _this;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof moment.fn.ceil !== "function") {
|
||||||
|
moment.fn.ceil = function (precision, key) {
|
||||||
|
return this.round(precision, key, "ceil");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof moment.fn.floor !== "function") {
|
||||||
|
moment.fn.floor = function (precision, key) {
|
||||||
|
return this.round(precision, key, "floor");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const STEP = 15;
|
||||||
|
|
||||||
|
export default function roundTime(date) {
|
||||||
|
return date.round(STEP, "minutes");
|
||||||
|
}
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { tracked } from "@glimmer/tracking";
|
||||||
|
|
||||||
|
export default class DiscoursePostEventEventStats {
|
||||||
|
static create(args = {}) {
|
||||||
|
return new DiscoursePostEventEventStats(args);
|
||||||
|
}
|
||||||
|
|
||||||
|
@tracked going = 0;
|
||||||
|
@tracked interested = 0;
|
||||||
|
@tracked invited = 0;
|
||||||
|
@tracked notGoing = 0;
|
||||||
|
|
||||||
|
constructor(args = {}) {
|
||||||
|
this.going = args.going;
|
||||||
|
this.invited = args.invited;
|
||||||
|
this.interested = args.interested;
|
||||||
|
this.notGoing = args.not_going;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,204 @@
|
||||||
|
import { tracked } from "@glimmer/tracking";
|
||||||
|
import EmberObject from "@ember/object";
|
||||||
|
import { TrackedArray } from "@ember-compat/tracked-built-ins";
|
||||||
|
import { bind } from "discourse/lib/decorators";
|
||||||
|
import { optionalRequire } from "discourse/lib/utilities";
|
||||||
|
import User from "discourse/models/user";
|
||||||
|
import DiscoursePostEventEventStats from "./discourse-post-event-event-stats";
|
||||||
|
import DiscoursePostEventInvitee from "./discourse-post-event-invitee";
|
||||||
|
|
||||||
|
const ChatChannel = optionalRequire(
|
||||||
|
"discourse/plugins/chat/discourse/models/chat-channel"
|
||||||
|
);
|
||||||
|
|
||||||
|
const DEFAULT_REMINDER = {
|
||||||
|
type: "notification",
|
||||||
|
value: 15,
|
||||||
|
unit: "minutes",
|
||||||
|
period: "before",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default class DiscoursePostEventEvent {
|
||||||
|
static create(args = {}) {
|
||||||
|
return new DiscoursePostEventEvent(args);
|
||||||
|
}
|
||||||
|
|
||||||
|
@tracked title;
|
||||||
|
@tracked name;
|
||||||
|
@tracked categoryId;
|
||||||
|
@tracked startsAt;
|
||||||
|
@tracked endsAt;
|
||||||
|
@tracked rawInvitees;
|
||||||
|
@tracked location;
|
||||||
|
@tracked url;
|
||||||
|
@tracked description;
|
||||||
|
@tracked timezone;
|
||||||
|
@tracked showLocalTime;
|
||||||
|
@tracked status;
|
||||||
|
@tracked post;
|
||||||
|
@tracked minimal;
|
||||||
|
@tracked chatEnabled;
|
||||||
|
@tracked canUpdateAttendance;
|
||||||
|
@tracked canActOnDiscoursePostEvent;
|
||||||
|
@tracked shouldDisplayInvitees;
|
||||||
|
@tracked isClosed;
|
||||||
|
@tracked isExpired;
|
||||||
|
@tracked isStandalone;
|
||||||
|
@tracked recurrenceUntil;
|
||||||
|
@tracked recurrence;
|
||||||
|
@tracked recurrenceRule;
|
||||||
|
@tracked customFields;
|
||||||
|
@tracked channel;
|
||||||
|
|
||||||
|
@tracked _watchingInvitee;
|
||||||
|
@tracked _sampleInvitees;
|
||||||
|
@tracked _stats;
|
||||||
|
@tracked _creator;
|
||||||
|
@tracked _reminders;
|
||||||
|
|
||||||
|
constructor(args = {}) {
|
||||||
|
this.id = args.id;
|
||||||
|
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;
|
||||||
|
this.sampleInvitees = args.sample_invitees || [];
|
||||||
|
this.location = args.location;
|
||||||
|
this.url = args.url;
|
||||||
|
this.description = args.description;
|
||||||
|
this.timezone = args.timezone;
|
||||||
|
this.showLocalTime = args.show_local_time;
|
||||||
|
this.status = args.status;
|
||||||
|
this.creator = args.creator;
|
||||||
|
this.post = args.post;
|
||||||
|
this.isClosed = args.is_closed;
|
||||||
|
this.isExpired = args.is_expired;
|
||||||
|
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;
|
||||||
|
this.canActOnDiscoursePostEvent = args.can_act_on_discourse_post_event;
|
||||||
|
this.shouldDisplayInvitees = args.should_display_invitees;
|
||||||
|
this.watchingInvitee = args.watching_invitee;
|
||||||
|
this.stats = args.stats;
|
||||||
|
this.reminders = args.reminders;
|
||||||
|
this.customFields = EmberObject.create(args.custom_fields || {});
|
||||||
|
if (args.channel && ChatChannel) {
|
||||||
|
this.channel = ChatChannel.create(args.channel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get watchingInvitee() {
|
||||||
|
return this._watchingInvitee;
|
||||||
|
}
|
||||||
|
|
||||||
|
set watchingInvitee(invitee) {
|
||||||
|
this._watchingInvitee = invitee
|
||||||
|
? DiscoursePostEventInvitee.create(invitee)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
get sampleInvitees() {
|
||||||
|
return this._sampleInvitees;
|
||||||
|
}
|
||||||
|
|
||||||
|
set sampleInvitees(invitees = []) {
|
||||||
|
this._sampleInvitees = new TrackedArray(
|
||||||
|
invitees.map((i) => DiscoursePostEventInvitee.create(i))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get stats() {
|
||||||
|
return this._stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
set stats(stats) {
|
||||||
|
this._stats = this.#initStatsModel(stats);
|
||||||
|
}
|
||||||
|
|
||||||
|
get reminders() {
|
||||||
|
return this._reminders;
|
||||||
|
}
|
||||||
|
|
||||||
|
set reminders(reminders = []) {
|
||||||
|
this._reminders = new TrackedArray(reminders);
|
||||||
|
}
|
||||||
|
|
||||||
|
get creator() {
|
||||||
|
return this._creator;
|
||||||
|
}
|
||||||
|
|
||||||
|
set creator(user) {
|
||||||
|
this._creator = this.#initUserModel(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
get isPublic() {
|
||||||
|
return this.status === "public";
|
||||||
|
}
|
||||||
|
|
||||||
|
get isPrivate() {
|
||||||
|
return this.status === "private";
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFromEvent(event) {
|
||||||
|
this.name = event.name;
|
||||||
|
this.startsAt = event.startsAt;
|
||||||
|
this.endsAt = event.endsAt;
|
||||||
|
this.location = event.location;
|
||||||
|
this.url = event.url;
|
||||||
|
this.timezone = event.timezone;
|
||||||
|
this.showLocalTime = event.showLocalTime;
|
||||||
|
this.description = event.description;
|
||||||
|
this.status = event.status;
|
||||||
|
this.creator = event.creator;
|
||||||
|
this.isClosed = event.isClosed;
|
||||||
|
this.isExpired = event.isExpired;
|
||||||
|
this.isStandalone = event.isStandalone;
|
||||||
|
this.minimal = event.minimal;
|
||||||
|
this.chatEnabled = event.chatEnabled;
|
||||||
|
this.recurrenceRule = event.recurrenceRule;
|
||||||
|
this.recurrence = event.recurrence;
|
||||||
|
this.recurrenceUntil = event.recurrenceUntil;
|
||||||
|
this.canUpdateAttendance = event.canUpdateAttendance;
|
||||||
|
this.canActOnDiscoursePostEvent = event.canActOnDiscoursePostEvent;
|
||||||
|
this.shouldDisplayInvitees = event.shouldDisplayInvitees;
|
||||||
|
this.stats = event.stats;
|
||||||
|
this.sampleInvitees = event.sampleInvitees || [];
|
||||||
|
this.reminders = event.reminders;
|
||||||
|
}
|
||||||
|
|
||||||
|
@bind
|
||||||
|
removeReminder(reminder) {
|
||||||
|
const index = this.reminders.findIndex((r) => r.id === reminder.id);
|
||||||
|
if (index > -1) {
|
||||||
|
this.reminders.splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@bind
|
||||||
|
addReminder(reminder) {
|
||||||
|
reminder ??= { ...DEFAULT_REMINDER };
|
||||||
|
this.reminders.push(reminder);
|
||||||
|
}
|
||||||
|
|
||||||
|
#initUserModel(user) {
|
||||||
|
if (!user || user instanceof User) {
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
return User.create(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
#initStatsModel(stats) {
|
||||||
|
if (!stats || stats instanceof DiscoursePostEventEventStats) {
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
return DiscoursePostEventEventStats.create(stats);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { tracked } from "@glimmer/tracking";
|
||||||
|
import User from "discourse/models/user";
|
||||||
|
|
||||||
|
export default class DiscoursePostEventInvitee {
|
||||||
|
static create(args = {}) {
|
||||||
|
return new DiscoursePostEventInvitee(args);
|
||||||
|
}
|
||||||
|
|
||||||
|
@tracked status;
|
||||||
|
|
||||||
|
constructor(args = {}) {
|
||||||
|
this.id = args.id;
|
||||||
|
this.post_id = args.post_id;
|
||||||
|
this.status = args.status;
|
||||||
|
this.user = this.#initUserModel(args.user);
|
||||||
|
}
|
||||||
|
|
||||||
|
#initUserModel(user) {
|
||||||
|
if (!user || user instanceof User) {
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
return User.create(user);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { tracked } from "@glimmer/tracking";
|
||||||
|
import { TrackedArray } from "@ember-compat/tracked-built-ins";
|
||||||
|
import User from "discourse/models/user";
|
||||||
|
import DiscoursePostEventInvitee from "./discourse-post-event-invitee";
|
||||||
|
|
||||||
|
export default class DiscoursePostEventInvitees {
|
||||||
|
static create(args = {}) {
|
||||||
|
return new DiscoursePostEventInvitees(args);
|
||||||
|
}
|
||||||
|
|
||||||
|
@tracked _invitees;
|
||||||
|
@tracked _suggestedUsers;
|
||||||
|
|
||||||
|
constructor(args = {}) {
|
||||||
|
this.invitees = args.invitees || [];
|
||||||
|
this.suggestedUsers = args.meta?.suggested_users || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
get invitees() {
|
||||||
|
return this._invitees;
|
||||||
|
}
|
||||||
|
|
||||||
|
set invitees(invitees = []) {
|
||||||
|
this._invitees = new TrackedArray(
|
||||||
|
invitees.map((i) => DiscoursePostEventInvitee.create(i))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get suggestedUsers() {
|
||||||
|
return this._suggestedUsers;
|
||||||
|
}
|
||||||
|
|
||||||
|
set suggestedUsers(suggestedUsers = []) {
|
||||||
|
this._suggestedUsers = new TrackedArray(
|
||||||
|
suggestedUsers.map((su) => User.create(su))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
add(invitee) {
|
||||||
|
this.invitees.push(invitee);
|
||||||
|
|
||||||
|
this.suggestedUsers = this.suggestedUsers.filter(
|
||||||
|
(su) => su.id !== invitee.user.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(invitee) {
|
||||||
|
this.invitees = this.invitees.filter((i) => i.user.id !== invitee.user.id);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
import RestModel from "discourse/models/rest";
|
||||||
|
|
||||||
|
export default class DiscoursePostEventReminder extends RestModel {
|
||||||
|
init() {
|
||||||
|
super.init(...arguments);
|
||||||
|
|
||||||
|
this.__type = "discourse-post-event-reminder";
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,105 @@
|
||||||
|
import { camelize } from "@ember/string";
|
||||||
|
import { withPluginApi } from "discourse/lib/plugin-api";
|
||||||
|
import { buildEventPreview } from "../initializers/discourse-post-event-decorator";
|
||||||
|
|
||||||
|
const EVENT_ATTRIBUTES = {
|
||||||
|
name: { default: null },
|
||||||
|
start: { default: null },
|
||||||
|
end: { default: null },
|
||||||
|
reminders: { default: null },
|
||||||
|
minimal: { default: null },
|
||||||
|
closed: { default: null },
|
||||||
|
status: { default: "public" },
|
||||||
|
timezone: { default: "UTC" },
|
||||||
|
showLocalTime: { default: null },
|
||||||
|
allowedGroups: { default: null },
|
||||||
|
recurrence: { default: null },
|
||||||
|
recurrenceUntil: { default: null },
|
||||||
|
chatEnabled: { default: null },
|
||||||
|
chatChannelId: { default: null },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** @type {RichEditorExtension} */
|
||||||
|
const extension = {
|
||||||
|
nodeSpec: {
|
||||||
|
event: {
|
||||||
|
attrs: EVENT_ATTRIBUTES,
|
||||||
|
group: "block",
|
||||||
|
defining: true,
|
||||||
|
isolating: true,
|
||||||
|
draggable: true,
|
||||||
|
parseDOM: [
|
||||||
|
{
|
||||||
|
tag: "div.discourse-post-event",
|
||||||
|
getAttrs(dom) {
|
||||||
|
return { ...dom.dataset };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
toDOM(node) {
|
||||||
|
const element = document.createElement("div");
|
||||||
|
element.classList.add("discourse-post-event");
|
||||||
|
for (const [key, value] of Object.entries(node.attrs)) {
|
||||||
|
if (value !== null) {
|
||||||
|
element.dataset[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildEventPreview(element);
|
||||||
|
|
||||||
|
return element;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
parse: {
|
||||||
|
wrap_bbcode(state, token) {
|
||||||
|
if (token.tag === "div") {
|
||||||
|
if (token.nesting === -1 && state.top().type.name === "event") {
|
||||||
|
state.closeNode();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
token.nesting === 1 &&
|
||||||
|
token.attrGet("class") === "discourse-post-event"
|
||||||
|
) {
|
||||||
|
const attrs = Object.fromEntries(
|
||||||
|
token.attrs
|
||||||
|
.filter(([key]) => key.startsWith("data-"))
|
||||||
|
.map(([key, value]) => [camelize(key.slice(5)), value])
|
||||||
|
);
|
||||||
|
|
||||||
|
state.openNode(state.schema.nodes.event, attrs);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
serializeNode: {
|
||||||
|
event(state, node) {
|
||||||
|
let bbcode = "[event";
|
||||||
|
|
||||||
|
Object.entries(node.attrs).forEach(([key, value]) => {
|
||||||
|
if (value !== null) {
|
||||||
|
bbcode += ` ${key}="${value}"`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
bbcode += "]\n[/event]\n";
|
||||||
|
|
||||||
|
state.write(bbcode);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
initialize() {
|
||||||
|
withPluginApi("2.1.1", (api) => {
|
||||||
|
api.registerRichEditorExtension(extension);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { withPluginApi } from "discourse/lib/plugin-api";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
before: "freeze-valid-transformers",
|
||||||
|
initialize() {
|
||||||
|
withPluginApi("1.33.0", (api) => {
|
||||||
|
api.addValueTransformerName(
|
||||||
|
"discourse-calendar-event-more-menu-should-show-participants"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
|
@ -0,0 +1,19 @@
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,22 @@
|
||||||
|
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 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);
|
||||||
|
}
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue