FEATURE: implements initial support for post events (#24)
This commit is contained in:
parent
0f922cdcb8
commit
988b066ab5
|
|
@ -0,0 +1,45 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module DiscourseCalendar
|
||||
class InviteesController < ::ApplicationController
|
||||
before_action :ensure_logged_in
|
||||
|
||||
def index
|
||||
post_event_invitees = PostEvent.find(params['post-event-id']).invitees
|
||||
|
||||
if params[:filter]
|
||||
post_event_invitees = post_event_invitees.joins(:user).where("users.username LIKE '%#{params[:filter]}%'")
|
||||
end
|
||||
|
||||
render json: ActiveModel::ArraySerializer.new(post_event_invitees.limit(10), each_serializer: InviteeSerializer).as_json
|
||||
end
|
||||
|
||||
def update
|
||||
invitee = Invitee.find(params[:id])
|
||||
guardian.ensure_can_act_on_invitee!(invitee)
|
||||
status = Invitee.statuses[invitee_params[:status].to_sym]
|
||||
invitee.update_attendance(status: status)
|
||||
invitee.post_event.publish_update!
|
||||
render json: InviteeSerializer.new(invitee)
|
||||
end
|
||||
|
||||
def create
|
||||
status = Invitee.statuses[invitee_params[:status].to_sym]
|
||||
post_event = PostEvent.find(invitee_params[:post_id])
|
||||
guardian.ensure_can_act_on_post_event!(post_event)
|
||||
invitee = Invitee.create!(
|
||||
status: status,
|
||||
post_id: invitee_params[:post_id],
|
||||
user_id: current_user.id,
|
||||
)
|
||||
invitee.post_event.publish_update!
|
||||
render json: InviteeSerializer.new(invitee)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def invitee_params
|
||||
params.require(:invitee).permit(:status, :post_id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module DiscourseCalendar
|
||||
class PostEventsController < ::ApplicationController
|
||||
before_action :ensure_logged_in
|
||||
|
||||
def index
|
||||
post_events = PostEvent.visible.where("starts_at > ?", Time.now).limit(10)
|
||||
render json: ActiveModel::ArraySerializer.new(
|
||||
post_events,
|
||||
each_serializer: PostEventSerializer,
|
||||
scope: guardian).as_json
|
||||
end
|
||||
|
||||
def show
|
||||
post_event = DiscourseCalendar::PostEvent.find(params[:id])
|
||||
guardian.ensure_can_see!(post_event.post)
|
||||
serializer = PostEventSerializer.new(post_event, scope: guardian)
|
||||
render_json_dump(serializer)
|
||||
end
|
||||
|
||||
def destroy
|
||||
post_event = DiscourseCalendar::PostEvent.find(params[:id])
|
||||
guardian.ensure_can_act_on_post_event!(post_event)
|
||||
post_event.publish_update!
|
||||
post_event.destroy
|
||||
render json: success_json
|
||||
end
|
||||
|
||||
def update
|
||||
DistributedMutex.synchronize("discourse-calendar[post-event-invitee-update]") do
|
||||
post_event = DiscourseCalendar::PostEvent.find(params[:id])
|
||||
guardian.ensure_can_edit!(post_event.post)
|
||||
guardian.ensure_can_act_on_post_event!(post_event)
|
||||
post_event.enforce_utc!(post_event_params)
|
||||
|
||||
case post_event_params[:status].to_i
|
||||
when PostEvent.statuses[:private]
|
||||
raw_invitees = Array(post_event_params[:raw_invitees])
|
||||
post_event.update!(post_event_params.merge(raw_invitees: raw_invitees))
|
||||
post_event.enforce_raw_invitees!
|
||||
when PostEvent.statuses[:public]
|
||||
post_event.update!(post_event_params.merge(raw_invitees: []))
|
||||
when PostEvent.statuses[:standalone]
|
||||
post_event.update!(post_event_params.merge(raw_invitees: []))
|
||||
post_event.invitees.destroy_all
|
||||
end
|
||||
|
||||
post_event.publish_update!
|
||||
serializer = PostEventSerializer.new(post_event, scope: guardian)
|
||||
render_json_dump(serializer)
|
||||
end
|
||||
end
|
||||
|
||||
def create
|
||||
post_event = DiscourseCalendar::PostEvent.new(post_event_params)
|
||||
guardian.ensure_can_edit!(post_event.post)
|
||||
guardian.ensure_can_create_post_event!(post_event)
|
||||
post_event.enforce_utc!(post_event_params)
|
||||
|
||||
case post_event_params[:status].to_i
|
||||
when PostEvent.statuses[:private]
|
||||
raw_invitees = Array(post_event_params[:raw_invitees])
|
||||
post_event.update!(raw_invitees: raw_invitees)
|
||||
post_event.fill_invitees!
|
||||
post_event.notify_invitees!
|
||||
when PostEvent.statuses[:public], PostEvent.statuses[:standalone]
|
||||
post_event.update!(post_event_params.merge(raw_invitees: []))
|
||||
end
|
||||
|
||||
post_event.publish_update!
|
||||
serializer = PostEventSerializer.new(post_event, scope: guardian)
|
||||
render_json_dump(serializer)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def post_event_params
|
||||
params
|
||||
.require(:post_event)
|
||||
.permit(
|
||||
:id,
|
||||
:name,
|
||||
:starts_at,
|
||||
:ends_at,
|
||||
:status,
|
||||
:display_invitees,
|
||||
raw_invitees: []
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module DiscourseCalendar
|
||||
class UpcomingEventsController < ::ApplicationController
|
||||
before_action :ensure_logged_in
|
||||
|
||||
def index
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module DiscourseCalendar
|
||||
class Invitee < ActiveRecord::Base
|
||||
self.table_name = 'discourse_calendar_invitees'
|
||||
|
||||
belongs_to :post_event, foreign_key: :post_id
|
||||
belongs_to :user
|
||||
|
||||
scope :with_status, ->(status) {
|
||||
where(status: Invitee.statuses[status])
|
||||
}
|
||||
|
||||
def self.statuses
|
||||
@statuses ||= Enum.new(going: 0, interested: 1, not_going: 2)
|
||||
end
|
||||
|
||||
def update_attendance(params)
|
||||
self.update!(params)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module DiscourseCalendar
|
||||
class PostEvent < ActiveRecord::Base
|
||||
self.table_name = 'discourse_calendar_post_events'
|
||||
|
||||
def self.attributes_protected_by_default
|
||||
super - ['id']
|
||||
end
|
||||
|
||||
has_many :invitees, foreign_key: :post_id, dependent: :delete_all
|
||||
belongs_to :post, foreign_key: :id
|
||||
|
||||
scope :visible, -> { where(deleted_at: nil) }
|
||||
|
||||
validates :name,
|
||||
length: { in: 5..30 },
|
||||
unless: -> (post_event) { post_event.name.blank? }
|
||||
|
||||
validate :raw_invitees_length
|
||||
def raw_invitees_length
|
||||
if self.raw_invitees && self.raw_invitees.length > 10
|
||||
errors.add(:base, I18n.t("discourse_calendar.post_event.errors.raw_invitees_length", count: 10))
|
||||
end
|
||||
end
|
||||
|
||||
validate :ends_before_start
|
||||
def ends_before_start
|
||||
if self.starts_at && self.ends_at && self.starts_at >= self.ends_at
|
||||
errors.add(:base, I18n.t("discourse_calendar.post_event.errors.ends_at_before_starts_at"))
|
||||
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
|
||||
|
||||
self.invitees.insert_all!(attrs)
|
||||
end
|
||||
|
||||
def notify_invitees!
|
||||
self.invitees.where(notified: false).each do |invitee|
|
||||
invitee.user.notifications.create!(
|
||||
notification_type: Notification.types[:custom],
|
||||
topic_id: self.post.topic_id,
|
||||
post_number: self.post.post_number,
|
||||
data: {
|
||||
topic_title: self.post.topic.title,
|
||||
display_username: self.post.user.username,
|
||||
message: 'discourse_calendar.invite_user_notification'
|
||||
}.to_json
|
||||
)
|
||||
invitee.update!(notified: true)
|
||||
end
|
||||
end
|
||||
|
||||
def self.statuses
|
||||
@statuses ||= Enum.new(standalone: 0, public: 1, private: 2)
|
||||
end
|
||||
|
||||
def self.display_invitees_options
|
||||
@display_invitees_options ||= Enum.new(everyone: 0, invitees_only: 1, none: 2)
|
||||
end
|
||||
|
||||
def most_likely_going(current_user, limit = SiteSetting.displayed_invitees_limit)
|
||||
most_likely = []
|
||||
|
||||
if self.can_user_update_attendance(current_user)
|
||||
most_likely << Invitee.find_or_initialize_by(
|
||||
user_id: current_user.id,
|
||||
post_id: self.id
|
||||
)
|
||||
end
|
||||
|
||||
most_likely << Invitee.new(
|
||||
user_id: self.post.user_id,
|
||||
status: Invitee.statuses[:going],
|
||||
post_id: self.id
|
||||
)
|
||||
|
||||
most_likely + self.invitees
|
||||
.order([:status, :user_id])
|
||||
.where.not(user_id: current_user.id)
|
||||
.limit(limit - most_likely.count)
|
||||
end
|
||||
|
||||
def publish_update!
|
||||
self.post.publish_message!("/post-events/#{self.post.topic_id}", id: self.id)
|
||||
end
|
||||
|
||||
def destroy_extraneous_invitees!
|
||||
self.invitees.where.not(user_id: fetch_users.select(:id)).delete_all
|
||||
end
|
||||
|
||||
def fill_invitees!
|
||||
invited_users_ids = fetch_users.pluck(:id) - self.invitees.pluck(:user_id)
|
||||
if invited_users_ids.present?
|
||||
self.create_invitees(invited_users_ids.map { |user_id|
|
||||
{ user_id: user_id }
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_users
|
||||
@fetched_users ||= User.where(
|
||||
id: GroupUser.where(
|
||||
group_id: Group.where(name: self.raw_invitees).select(:id)
|
||||
).select(:user_id)
|
||||
).or(User.where(username: self.raw_invitees))
|
||||
end
|
||||
|
||||
def enforce_raw_invitees!
|
||||
self.destroy_extraneous_invitees!
|
||||
self.fill_invitees!
|
||||
self.notify_invitees!
|
||||
end
|
||||
|
||||
def enforce_utc!(params)
|
||||
if params['starts_at'].present?
|
||||
params['starts_at'] = Time.parse(params['starts_at']).utc
|
||||
end
|
||||
if params['ends_at'].present?
|
||||
params['ends_at'] = Time.parse(params['ends_at']).utc
|
||||
end
|
||||
end
|
||||
|
||||
def can_user_update_attendance(user)
|
||||
self.post.user != user &&
|
||||
self.status == PostEvent.statuses[:public] ||
|
||||
(
|
||||
self.status == PostEvent.statuses[:private] &&
|
||||
self.invitees.exists?(user_id: user.id)
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class ::Guardian
|
||||
module CanActOnPostEvent
|
||||
def can_act_on_post_event?(post_event)
|
||||
@user.staff? || @user.admin? || @user.id == post_event.post.user_id
|
||||
end
|
||||
end
|
||||
prepend CanActOnPostEvent
|
||||
|
||||
module CanActOnInvitee
|
||||
def can_act_on_invitee?(invitee)
|
||||
@user.staff? || @user.admin? || @user.id == invitee.user_id
|
||||
end
|
||||
end
|
||||
prepend CanActOnInvitee
|
||||
|
||||
module CanCreatePostEvent
|
||||
def can_create_post_event?(post_event)
|
||||
@user.staff? || @user.admin?
|
||||
end
|
||||
end
|
||||
prepend CanCreatePostEvent
|
||||
|
||||
module CanJoinPostEvent
|
||||
def can_join_post_event?(post_event)
|
||||
post_event.status === DiscourseCalendar::PostEvent.statuses[:public] || (
|
||||
post_event.status === DiscourseCalendar::PostEvent.statuses[:private]
|
||||
post_event.invitees.find_by(user_id: @user.id)
|
||||
)
|
||||
end
|
||||
end
|
||||
prepend CanJoinPostEvent
|
||||
end
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module DiscourseCalendar
|
||||
class InviteeSerializer < ApplicationSerializer
|
||||
attributes :id, :status, :user
|
||||
|
||||
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
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module DiscourseCalendar
|
||||
class PostEventSerializer < ApplicationSerializer
|
||||
attributes :id
|
||||
attributes :creator
|
||||
attributes :sample_invitees
|
||||
attributes :watching_invitee
|
||||
attributes :starts_at
|
||||
attributes :ends_at
|
||||
attributes :stats
|
||||
attributes :status
|
||||
attributes :raw_invitees
|
||||
attributes :display_invitees
|
||||
attributes :post
|
||||
attributes :should_display_invitees
|
||||
attributes :name
|
||||
attributes :can_act_on_post_event
|
||||
attributes :can_update_attendance
|
||||
|
||||
def can_act_on_post_event
|
||||
scope.can_act_on_post_event?(object)
|
||||
end
|
||||
|
||||
def status
|
||||
PostEvent.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 should_display_invitees
|
||||
display_invitees?
|
||||
end
|
||||
|
||||
def can_update_attendance
|
||||
object.can_user_update_attendance(scope.current_user)
|
||||
end
|
||||
|
||||
def display_invitees
|
||||
PostEvent.display_invitees_options[object.display_invitees]
|
||||
end
|
||||
|
||||
def creator
|
||||
BasicUserSerializer.new(object.post.user, embed: :objects, root: false)
|
||||
end
|
||||
|
||||
def include_stats?
|
||||
display_invitees?
|
||||
end
|
||||
|
||||
def stats
|
||||
counts = object.invitees.group(:status).count
|
||||
|
||||
# event creator is always going so we add one
|
||||
going = (counts[Invitee.statuses[:going]] || 0) + 1
|
||||
interested = counts[Invitee.statuses[:interested]] || 0
|
||||
not_going = counts[Invitee.statuses[:not_going]] || 0
|
||||
unanswered = counts[nil] || 0
|
||||
|
||||
{
|
||||
going: going,
|
||||
interested: interested,
|
||||
not_going: not_going,
|
||||
invited: going + interested + not_going + unanswered
|
||||
}
|
||||
end
|
||||
|
||||
def watching_invitee
|
||||
if scope.current_user === object.post.user
|
||||
watching_invitee = Invitee.new(
|
||||
user_id: object.post.user.id,
|
||||
status: Invitee.statuses[:going],
|
||||
post_id: object.id
|
||||
)
|
||||
else
|
||||
watching_invitee = Invitee.find_by(
|
||||
user_id: scope.current_user.id,
|
||||
post_id: object.id
|
||||
)
|
||||
end
|
||||
|
||||
if watching_invitee
|
||||
InviteeSerializer.new(watching_invitee, root: false)
|
||||
end
|
||||
end
|
||||
|
||||
def include_sample_invitees?
|
||||
display_invitees?
|
||||
end
|
||||
|
||||
def sample_invitees
|
||||
invitees = object.most_likely_going(scope.current_user)
|
||||
ActiveModel::ArraySerializer.new(invitees, each_serializer: InviteeSerializer)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def display_invitees?
|
||||
object.status != PostEvent.statuses[:standalone] &&
|
||||
(
|
||||
object.display_invitees == PostEvent.display_invitees_options[:everyone] ||
|
||||
(
|
||||
object.display_invitees == PostEvent.display_invitees_options[:invitees_only] &&
|
||||
object.invitees.exists?(user_id: scope.current_user.id)
|
||||
) ||
|
||||
(
|
||||
object.display_invitees == PostEvent.display_invitees_options[:none] &&
|
||||
object.post.user == scope.current_user
|
||||
)
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1 @@
|
|||
UPCOMING
|
||||
|
|
@ -0,0 +1 @@
|
|||
UPCOMING
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import RestAdapter from "discourse/adapters/rest";
|
||||
|
||||
export default RestAdapter.extend({
|
||||
basePath() {
|
||||
return "/discourse-calendar/";
|
||||
},
|
||||
|
||||
pathFor() {
|
||||
return this._super(...arguments).replace("_", "-");
|
||||
}
|
||||
});
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import DiscourseCalendarAdapter from "./discourse-calendar-adapter";
|
||||
|
||||
export default DiscourseCalendarAdapter.extend();
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import DiscourseCalendarAdapter from "./discourse-calendar-adapter";
|
||||
|
||||
export default DiscourseCalendarAdapter.extend();
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import Component from "@ember/component";
|
||||
|
||||
export default Component.extend({
|
||||
enabled: true,
|
||||
class: null
|
||||
});
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
import ModalFunctionality from "discourse/mixins/modal-functionality";
|
||||
import Controller from "@ember/controller";
|
||||
import { action, computed } from "@ember/object";
|
||||
import { equal } from "@ember/object/computed";
|
||||
import { extractError } from "discourse/lib/ajax-error";
|
||||
|
||||
export default Controller.extend(ModalFunctionality, {
|
||||
modalTitle: computed("model.isNew", {
|
||||
get() {
|
||||
return this.model.isNew ? "create_event_title" : "update_event_title";
|
||||
}
|
||||
}),
|
||||
|
||||
allowsInvitees: equal("model.status", "private"),
|
||||
|
||||
@action
|
||||
setRawInvitees(_, newInvitees) {
|
||||
this.set("model.raw_invitees", newInvitees);
|
||||
},
|
||||
|
||||
startsAt: computed("model.starts_at", {
|
||||
get() {
|
||||
return this.model.starts_at;
|
||||
}
|
||||
}),
|
||||
|
||||
endsAt: computed("model.ends_at", {
|
||||
get() {
|
||||
return this.model.ends_at;
|
||||
}
|
||||
}),
|
||||
|
||||
standaloneEvent: equal("model.status", "standalone"),
|
||||
publicEvent: equal("model.status", "public"),
|
||||
privateEvent: equal("model.status", "private"),
|
||||
|
||||
inviteesOptions: computed("model.status", function() {
|
||||
const options = [];
|
||||
|
||||
if (!this.standaloneEvent) {
|
||||
options.push({
|
||||
label: I18n.t("event.display_invitees.everyone"),
|
||||
value: "everyone"
|
||||
});
|
||||
|
||||
if (this.privateEvent) {
|
||||
options.push({
|
||||
label: I18n.t("event.display_invitees.invitees_only"),
|
||||
value: "invitees_only"
|
||||
});
|
||||
}
|
||||
|
||||
options.push({
|
||||
label: I18n.t("event.display_invitees.none"),
|
||||
value: "none"
|
||||
});
|
||||
}
|
||||
|
||||
return options;
|
||||
}),
|
||||
|
||||
@action
|
||||
onChangeDates(changes) {
|
||||
this.model.setProperties({
|
||||
starts_at: moment(changes.from)
|
||||
.utc()
|
||||
.toISOString(),
|
||||
ends_at: changes.to
|
||||
? moment(changes.to)
|
||||
.utc()
|
||||
.toISOString()
|
||||
: null
|
||||
});
|
||||
},
|
||||
|
||||
@action
|
||||
destroyPostEvent() {
|
||||
bootbox.confirm(
|
||||
I18n.t("event.ui_builder.confirm_delete"),
|
||||
I18n.t("no_value"),
|
||||
I18n.t("yes_value"),
|
||||
confirmed => {
|
||||
if (confirmed) {
|
||||
this.model.destroyRecord().then(() => this.send("closeModal"));
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
@action
|
||||
createEvent() {
|
||||
this.model
|
||||
.save()
|
||||
.then(() => this.send("closeModal"))
|
||||
.catch(e => this.flash(extractError(e), "error"));
|
||||
},
|
||||
|
||||
@action
|
||||
updateEvent() {
|
||||
this.model
|
||||
.save()
|
||||
.then(() => this.send("closeModal"))
|
||||
.catch(e => this.flash(extractError(e), "error"));
|
||||
}
|
||||
});
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import ModalFunctionality from "discourse/mixins/modal-functionality";
|
||||
import Controller from "@ember/controller";
|
||||
import { debounce } from "@ember/runloop";
|
||||
import { action } from "@ember/object";
|
||||
|
||||
export default Controller.extend(ModalFunctionality, {
|
||||
invitees: null,
|
||||
filter: null,
|
||||
isLoading: false,
|
||||
|
||||
onShow() {
|
||||
this._fetchInvitees();
|
||||
},
|
||||
|
||||
@action
|
||||
onFilterChanged(filter) {
|
||||
debounce(this, this._fetchInvitees, filter, 250);
|
||||
},
|
||||
|
||||
_fetchInvitees(filter) {
|
||||
this.set("isLoading", true);
|
||||
|
||||
this.store
|
||||
.findAll("invitee", { "post-event-id": this.model.id, filter })
|
||||
.then(invitees => this.set("invitees", invitees))
|
||||
.finally(() => this.set("isLoading", false));
|
||||
}
|
||||
});
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import Controller from "@ember/controller";
|
||||
|
||||
export default Controller.extend({
|
||||
loadPostEvents(params) {
|
||||
this.store.findAll("post-event", params).then(postEvents => {
|
||||
this.set("postEvents", postEvents);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import EmberObject from "@ember/object";
|
||||
|
||||
export default EmberObject.extend({
|
||||
init(params = {}) {
|
||||
this.title = params.title;
|
||||
this.startsAt = moment(params.startsAt);
|
||||
this.endsAt = params.endsAt ? moment(params.endsAt) : null;
|
||||
},
|
||||
|
||||
generateLink() {
|
||||
const title = encodeURIComponent(this.title);
|
||||
let dates = [this._formatDate(this.startsAt)];
|
||||
if (this.endsAt) {
|
||||
dates.push(this._formatDate(this.endsAt));
|
||||
dates = `dates=${dates.join("/")}`;
|
||||
} else {
|
||||
dates = `date=${dates.join("")}`;
|
||||
}
|
||||
|
||||
return `https://www.google.com/calendar/event?action=TEMPLATE&text=${title}&${dates}`;
|
||||
},
|
||||
|
||||
_formatDate(date) {
|
||||
return date.toISOString().replace(/-|:|\.\d\d\d/g, "");
|
||||
}
|
||||
});
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import RestModel from "discourse/models/rest";
|
||||
|
||||
export default RestModel.extend({
|
||||
init() {
|
||||
this._super(...arguments);
|
||||
|
||||
this.__type = "invitee";
|
||||
}
|
||||
});
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import RestModel from "discourse/models/rest";
|
||||
// import { ajax } from "discourse/lib/ajax";
|
||||
|
||||
// const BASE_URL = "/discourse-calendar/post-events";
|
||||
|
||||
const ATTRIBUTES = {
|
||||
id: {},
|
||||
name: {},
|
||||
starts_at: {},
|
||||
ends_at: {},
|
||||
raw_invitees: {},
|
||||
display_invitees: {
|
||||
transform(value) {
|
||||
return DISPLAY_INVITEES_OPTIONS[value];
|
||||
}
|
||||
},
|
||||
status: {
|
||||
transform(value) {
|
||||
return STATUSES[value];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const DISPLAY_INVITEES_OPTIONS = {
|
||||
everyone: 0,
|
||||
invitees_only: 1,
|
||||
none: 2
|
||||
};
|
||||
|
||||
const STATUSES = {
|
||||
standalone: 0,
|
||||
public: 1,
|
||||
private: 2
|
||||
};
|
||||
|
||||
const PostEvent = RestModel.extend({
|
||||
init() {
|
||||
this._super(...arguments);
|
||||
|
||||
this.__type = "post-event";
|
||||
},
|
||||
|
||||
updateProperties() {
|
||||
const attributesKeys = Object.keys(ATTRIBUTES);
|
||||
return this.getProperties(attributesKeys);
|
||||
},
|
||||
|
||||
createProperties() {
|
||||
const attributesKeys = Object.keys(ATTRIBUTES);
|
||||
return this.getProperties(attributesKeys);
|
||||
},
|
||||
|
||||
_transformProps(props) {
|
||||
const attributesKeys = Object.keys(ATTRIBUTES);
|
||||
attributesKeys.forEach(key => {
|
||||
const attribute = ATTRIBUTES[key];
|
||||
if (attribute.transform) {
|
||||
props[key] = attribute.transform(props[key]);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
beforeUpdate(props) {
|
||||
this._transformProps(props);
|
||||
},
|
||||
|
||||
beforeCreate(props) {
|
||||
this._transformProps(props);
|
||||
}
|
||||
});
|
||||
|
||||
export default PostEvent;
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import Route from "@ember/routing/route";
|
||||
|
||||
export default Route.extend({
|
||||
queryParams: {
|
||||
invited: { refreshModel: true, replace: true }
|
||||
},
|
||||
|
||||
model(params) {
|
||||
return params;
|
||||
},
|
||||
|
||||
setupController(controller, params) {
|
||||
controller.loadPostEvents(params);
|
||||
}
|
||||
});
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import Route from "@ember/routing/route";
|
||||
|
||||
export default Route.extend({});
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
{{#if enabled}}
|
||||
<div class="event-field {{class}}">
|
||||
<div class="event-field-label">
|
||||
<span class="label">{{i18n label}}</span>
|
||||
</div>
|
||||
<div class="event-field-control">
|
||||
{{yield}}
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
{{#d-modal-body
|
||||
title=(concat "event.ui_builder." modalTitle)
|
||||
class="event-ui-builder"
|
||||
}}
|
||||
{{#conditional-loading-section isLoading=model.isSaving}}
|
||||
<form>
|
||||
{{#event-field class="name" label="event.ui_builder.name.label"}}
|
||||
{{input
|
||||
value=(readonly model.name)
|
||||
placeholderKey="event.ui_builder.name.placeholder"
|
||||
input=(action (mut model.name) value="target.value")
|
||||
}}
|
||||
{{/event-field}}
|
||||
|
||||
{{date-time-input-range
|
||||
from=startsAt
|
||||
to=endsAt
|
||||
onChange=(action "onChangeDates")
|
||||
}}
|
||||
{{#event-field label="event.ui_builder.status.label"}}
|
||||
<label class="radio-label">
|
||||
{{radio-button
|
||||
name="status"
|
||||
value="standalone"
|
||||
selection=model.status
|
||||
onChange=(action (mut model.status))
|
||||
}}
|
||||
<span class="message">
|
||||
<span class="title">{{i18n "event.post_event_status.standalone.title"}}</span>
|
||||
<span class="description">{{i18n "event.post_event_status.standalone.description"}}</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="radio-label">
|
||||
{{radio-button
|
||||
name="status"
|
||||
value="public"
|
||||
selection=model.status
|
||||
onChange=(action (mut model.status))
|
||||
}}
|
||||
<span class="message">
|
||||
<span class="title">{{i18n "event.post_event_status.public.title"}}</span>
|
||||
<span class="description">{{i18n "event.post_event_status.public.description"}}</span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="radio-label">
|
||||
{{radio-button
|
||||
name="status"
|
||||
value="private"
|
||||
selection=model.status
|
||||
onChange=(action (mut model.status))
|
||||
}}
|
||||
<span class="message">
|
||||
<span class="title">{{i18n "event.post_event_status.private.title"}}</span>
|
||||
<span class="description">{{i18n "event.post_event_status.private.description"}}</span>
|
||||
</span>
|
||||
</label>
|
||||
{{/event-field}}
|
||||
|
||||
{{#event-field enabled=allowsInvitees label="event.ui_builder.invitees.label"}}
|
||||
{{user-selector
|
||||
single=false
|
||||
onChangeCallback=(action "setRawInvitees")
|
||||
fullWidthWrap=true
|
||||
allowAny=false
|
||||
includeMessageableGroups=true
|
||||
placeholderKey="composer.users_placeholder"
|
||||
tabindex="1"
|
||||
usernames=model.raw_invitees
|
||||
hasGroups=true
|
||||
autocomplete="discourse"
|
||||
excludeCurrentUser=true
|
||||
}}
|
||||
{{/event-field}}
|
||||
|
||||
{{#if inviteesOptions.length}}
|
||||
{{#event-field label="event.ui_builder.display_invitees.label"}}
|
||||
{{#each inviteesOptions as |option|}}
|
||||
<label class="radio-label">
|
||||
{{radio-button
|
||||
name="display_invitees"
|
||||
value=option.value
|
||||
selection=model.display_invitees
|
||||
onChange=(action (mut model.display_invitees))
|
||||
}}
|
||||
<span class="message">
|
||||
{{option.label}}
|
||||
</span>
|
||||
</label>
|
||||
{{/each}}
|
||||
{{/event-field}}
|
||||
{{/if}}
|
||||
</form>
|
||||
{{/conditional-loading-section}}
|
||||
{{/d-modal-body}}
|
||||
|
||||
|
||||
<div class="modal-footer">
|
||||
{{#if model.isNew}}
|
||||
{{d-button
|
||||
type="button"
|
||||
class="btn-primary"
|
||||
label="event.ui_builder.create"
|
||||
icon="calendar-day"
|
||||
action=(action "createEvent")
|
||||
}}
|
||||
{{else}}
|
||||
{{d-button
|
||||
type="button"
|
||||
class="btn-primary"
|
||||
label="event.ui_builder.update"
|
||||
icon="calendar-day"
|
||||
action=(action "updateEvent")
|
||||
}}
|
||||
{{/if}}
|
||||
|
||||
{{d-button
|
||||
icon="trash-alt"
|
||||
class="btn-danger"
|
||||
action="destroyPostEvent"
|
||||
}}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
{{#d-modal-body
|
||||
title="event.post-event-invitees-modal.title"
|
||||
}}
|
||||
{{input
|
||||
value=(readonly filter)
|
||||
input=(action "onFilterChanged" value="target.value")
|
||||
class="filter"
|
||||
placeholderKey="event.post-event-invitees-modal.filter_placeholder"
|
||||
}}
|
||||
|
||||
{{#conditional-loading-spinner condition=isLoading}}
|
||||
<ul class="invitees">
|
||||
{{#each invitees as |invitee|}}
|
||||
<li class="invitee">
|
||||
<span class="user">
|
||||
{{avatar invitee.user imageSize="medium"}}
|
||||
{{format-username invitee.user.username}}
|
||||
</span>
|
||||
{{#if invitee.status}}
|
||||
<span class="status {{invitee.status}}">
|
||||
{{i18n (concat "event.invitee_status." invitee.status)}}
|
||||
</span>
|
||||
{{/if}}
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
{{/conditional-loading-spinner}}
|
||||
{{/d-modal-body}}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
<table class="table upcoming-events-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>id</th>
|
||||
<th>creator</th>
|
||||
<th>status</th>
|
||||
<th>starts at</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#each postEvents as |postEvent|}}
|
||||
<tr>
|
||||
<td>
|
||||
<a href={{postEvent.post.url}}>
|
||||
{{format-post-event-name postEvent}}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
{{avatar postEvent.creator imageSize="tiny"}}
|
||||
{{format-username postEvent.creator.username}}
|
||||
</td>
|
||||
<td>
|
||||
{{postEvent.status}}
|
||||
</td>
|
||||
<td>
|
||||
{{format-future-date postEvent.starts_at}}
|
||||
</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
</tbody>
|
||||
</table>
|
||||
|
|
@ -0,0 +1 @@
|
|||
{{outlet}}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
export default function() {
|
||||
this.route("upcoming-events", { path: "/upcoming-events" }, function() {
|
||||
this.route("index", { path: "/" });
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import hbs from "discourse/widgets/hbs-compiler";
|
||||
import { createWidget } from "discourse/widgets/widget";
|
||||
|
||||
export default createWidget("post-event-dates", {
|
||||
tagName: "section.post-event-dates",
|
||||
|
||||
template: hbs`
|
||||
{{d-icon "clock"}}
|
||||
<span class="date">{{{attrs.localDates}}}</span>
|
||||
`
|
||||
});
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import { h } from "virtual-dom";
|
||||
import { avatarImg } from "discourse/widgets/post";
|
||||
import { createWidget } from "discourse/widgets/widget";
|
||||
import { formatUsername } from "discourse/lib/utilities";
|
||||
|
||||
export default createWidget("post-event-creator", {
|
||||
tagName: "span.post-event-creator",
|
||||
|
||||
html(attrs) {
|
||||
const { name, username, avatar_template } = attrs.user;
|
||||
|
||||
return h(
|
||||
"a",
|
||||
{
|
||||
attributes: {
|
||||
class: "topic-invitee-avatar",
|
||||
"data-user-card": username
|
||||
}
|
||||
},
|
||||
[
|
||||
avatarImg("tiny", {
|
||||
template: avatar_template,
|
||||
username: name || formatUsername(username)
|
||||
}),
|
||||
h(
|
||||
"span",
|
||||
{ attributes: { class: "username" } },
|
||||
name || formatUsername(username)
|
||||
)
|
||||
]
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import { h } from "virtual-dom";
|
||||
import { avatarImg } from "discourse/widgets/post";
|
||||
import { createWidget } from "discourse/widgets/widget";
|
||||
import { formatUsername } from "discourse/lib/utilities";
|
||||
|
||||
export default createWidget("post-event-invitee", {
|
||||
tagName: "li.post-event-invitee",
|
||||
|
||||
buildClasses(attrs) {
|
||||
return [
|
||||
Ember.isPresent(attrs.invitee.status)
|
||||
? `status-${attrs.invitee.status}`
|
||||
: `unanswered`
|
||||
];
|
||||
},
|
||||
|
||||
html(attrs) {
|
||||
const { name, username, avatar_template } = attrs.invitee.user;
|
||||
|
||||
let statusIcon;
|
||||
switch (attrs.invitee.status) {
|
||||
case "going":
|
||||
statusIcon = "fa-check";
|
||||
break;
|
||||
case "interested":
|
||||
statusIcon = "fa-question";
|
||||
break;
|
||||
case "not_going":
|
||||
statusIcon = "fa-times";
|
||||
break;
|
||||
}
|
||||
|
||||
const avatarContent = [
|
||||
avatarImg("large", {
|
||||
template: avatar_template,
|
||||
username: name || formatUsername(username)
|
||||
})
|
||||
];
|
||||
|
||||
if (statusIcon) {
|
||||
avatarContent.push(
|
||||
this.attach("avatar-flair", {
|
||||
primary_group_name: `status-${attrs.invitee.status}`,
|
||||
primary_group_flair_url: statusIcon
|
||||
})
|
||||
);
|
||||
}
|
||||
return h(
|
||||
"a",
|
||||
{
|
||||
attributes: {
|
||||
class: "topic-invitee-avatar",
|
||||
"data-user-card": username
|
||||
}
|
||||
},
|
||||
avatarContent
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import hbs from "discourse/widgets/hbs-compiler";
|
||||
import { createWidget } from "discourse/widgets/widget";
|
||||
|
||||
export default createWidget("post-event-invitees", {
|
||||
tagName: "section.post-event-invitees",
|
||||
|
||||
transform(attrs) {
|
||||
return {
|
||||
showAll: attrs.postEvent.stats && attrs.postEvent.stats.invited > 10
|
||||
};
|
||||
},
|
||||
|
||||
template: hbs`
|
||||
<div class="header">
|
||||
<div class="post-event-invitees-status">
|
||||
<span>{{attrs.postEvent.stats.going}} Going -</span>
|
||||
<span>{{attrs.postEvent.stats.interested}} Interested -</span>
|
||||
<span>{{attrs.postEvent.stats.not_going}} Not going -</span>
|
||||
<span class="invited">on {{attrs.postEvent.stats.invited}} users invited</span>
|
||||
</div>
|
||||
|
||||
{{#if transformed.showAll}}
|
||||
{{attach
|
||||
widget="button"
|
||||
attrs=(hash
|
||||
className="show-all btn-small"
|
||||
label="event.post_ui.show_all"
|
||||
action="showAllInvitees"
|
||||
actionParam=attrs.postEvent.id
|
||||
)
|
||||
}}
|
||||
{{/if}}
|
||||
</div>
|
||||
<ul class="post-event-invitees-avatars">
|
||||
{{#each attrs.postEvent.sample_invitees as |invitee|}}
|
||||
{{attach
|
||||
widget="post-event-invitee"
|
||||
attrs=(hash invitee=invitee)
|
||||
}}
|
||||
{{/each}}
|
||||
</ul>
|
||||
`
|
||||
});
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import { h } from "virtual-dom";
|
||||
import { createWidget } from "discourse/widgets/widget";
|
||||
|
||||
export default createWidget("post-event-status", {
|
||||
tagName: "select.post-event-status",
|
||||
|
||||
change(event) {
|
||||
this.sendWidgetAction("changeWatchingInviteeStatus", event.target.value);
|
||||
},
|
||||
|
||||
buildClasses(attrs) {
|
||||
if (attrs.watchingInvitee) {
|
||||
return `status-${attrs.watchingInvitee.status}`;
|
||||
}
|
||||
},
|
||||
|
||||
html(attrs) {
|
||||
const statuses = [
|
||||
{ value: null, name: I18n.t("event.invitee_status.unknown") },
|
||||
{ value: "going", name: I18n.t("event.invitee_status.going") },
|
||||
{ value: "interested", name: I18n.t("event.invitee_status.interested") },
|
||||
{ value: "not_going", name: I18n.t("event.invitee_status.not_going") }
|
||||
];
|
||||
|
||||
const value = attrs.watchingInvitee ? attrs.watchingInvitee.status : null;
|
||||
|
||||
return statuses.map(status =>
|
||||
h(
|
||||
"option",
|
||||
{
|
||||
value: status.value,
|
||||
class: `status-${status.value}`,
|
||||
selected: status.value === value
|
||||
},
|
||||
status.name
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
import EmberObject from "@ember/object";
|
||||
import showModal from "discourse/lib/show-modal";
|
||||
import hbs from "discourse/widgets/hbs-compiler";
|
||||
import { createWidget } from "discourse/widgets/widget";
|
||||
import GoogleCalendar from "discourse/plugins/discourse-calendar/discourse/lib/google-calendar";
|
||||
import { routeAction } from "discourse/helpers/route-action";
|
||||
import { iconNode } from "discourse-common/lib/icon-library";
|
||||
|
||||
export default createWidget("post-event", {
|
||||
tagName: "div.post-event",
|
||||
|
||||
buildKey: attrs => `post-event-${attrs.id}`,
|
||||
|
||||
buildAttributes(attrs) {
|
||||
return { style: `height:${attrs.widgetHeight}px` };
|
||||
},
|
||||
|
||||
buildClasses() {
|
||||
if (this.state.postEvent) {
|
||||
return ["has-post-event"];
|
||||
}
|
||||
},
|
||||
|
||||
showAllInvitees(postId) {
|
||||
this.store.find("post-event", postId).then(postEvent => {
|
||||
showModal("post-event-invitees", {
|
||||
model: postEvent
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
editPostEvent(postId) {
|
||||
this.store.find("post-event", postId).then(postEvent => {
|
||||
showModal("event-ui-builder", {
|
||||
model: postEvent,
|
||||
modalClass: "event-ui-builder-modal"
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
changeWatchingInviteeStatus(status) {
|
||||
if (this.state.postEvent.watching_invitee) {
|
||||
this.store.update("invitee", this.state.postEvent.watching_invitee.id, {
|
||||
status
|
||||
});
|
||||
} else {
|
||||
this.store
|
||||
.createRecord("invitee")
|
||||
.save({ post_id: this.state.postEvent.id, status });
|
||||
}
|
||||
},
|
||||
|
||||
defaultState(attrs) {
|
||||
return {
|
||||
postEvent: attrs.postEvent
|
||||
};
|
||||
},
|
||||
|
||||
sendPMToCreator() {
|
||||
const router = this.register.lookup("service:router")._router;
|
||||
routeAction(
|
||||
"composePrivateMessage",
|
||||
router,
|
||||
EmberObject.create(this.state.postEvent.creator),
|
||||
EmberObject.create(this.state.postEvent.post)
|
||||
).call();
|
||||
},
|
||||
|
||||
addToGoogleCalendar() {
|
||||
const link = GoogleCalendar.create({
|
||||
title: this.state.postEvent.name || this.state.postEvent.post.topic.title,
|
||||
startsAt: this.state.postEvent.starts_at,
|
||||
endsAt: this.state.postEvent.ends_at
|
||||
}).generateLink();
|
||||
|
||||
window.open(link, "_blank");
|
||||
},
|
||||
|
||||
transform() {
|
||||
const postEvent = this.state.postEvent;
|
||||
|
||||
let statusIcon = "times";
|
||||
if (postEvent.status === "private") {
|
||||
statusIcon = "lock";
|
||||
}
|
||||
if (postEvent.status === "public") {
|
||||
statusIcon = "unlock";
|
||||
}
|
||||
|
||||
return {
|
||||
postEventStatusLabel: I18n.t(
|
||||
`event.post_event_status.${postEvent.status}.title`
|
||||
),
|
||||
postEventStatusDescription: I18n.t(
|
||||
`event.post_event_status.${postEvent.status}.description`
|
||||
),
|
||||
startsAtMonth: moment(postEvent.starts_at).format("MMM"),
|
||||
startsAtDay: moment(postEvent.starts_at).format("D"),
|
||||
postEventName: postEvent.name || postEvent.post.topic.title,
|
||||
statusClass: `status ${postEvent.status}`,
|
||||
statusIcon: iconNode(statusIcon)
|
||||
};
|
||||
},
|
||||
|
||||
template: hbs`
|
||||
{{#if state.postEvent}}
|
||||
<header class="post-event-header">
|
||||
<div class="post-event-date">
|
||||
<div class="month">{{transformed.startsAtMonth}}</div>
|
||||
<div class="day">{{transformed.startsAtDay}}</div>
|
||||
</div>
|
||||
<div class="post-event-info">
|
||||
<div class="status-and-name">
|
||||
<span class={{transformed.statusClass}} title={{transformed.postEventStatusDescription}}>
|
||||
{{transformed.statusIcon}}
|
||||
<span>{{transformed.postEventStatusLabel}}</span>
|
||||
</span>
|
||||
<span class="name">
|
||||
{{transformed.postEventName}}
|
||||
</span>
|
||||
</div>
|
||||
<span class="creators">
|
||||
Created by {{attach widget="post-event-creator" attrs=(hash user=state.postEvent.creator)}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{{#if state.postEvent.can_act_on_post_event}}
|
||||
<div class="actions">
|
||||
{{attach
|
||||
widget="button"
|
||||
attrs=(hash
|
||||
className="btn-small"
|
||||
icon="pencil-alt"
|
||||
action="editPostEvent"
|
||||
actionParam=state.postEvent.id
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</header>
|
||||
|
||||
{{#if state.postEvent.can_update_attendance}}
|
||||
<section class="post-event-actions">
|
||||
{{attach
|
||||
widget="post-event-status"
|
||||
attrs=(hash
|
||||
watchingInvitee=this.state.postEvent.watching_invitee
|
||||
)
|
||||
}}
|
||||
</section>
|
||||
{{/if}}
|
||||
|
||||
<hr />
|
||||
|
||||
{{attach widget="post-event-dates" attrs=(hash localDates=attrs.localDates postEvent=state.postEvent)}}
|
||||
|
||||
{{#if state.postEvent.should_display_invitees}}
|
||||
<hr />
|
||||
{{attach widget="post-event-invitees" attrs=(hash postEvent=state.postEvent)}}
|
||||
{{/if}}
|
||||
|
||||
<footer class="post-event-footer">
|
||||
{{attach
|
||||
widget="button"
|
||||
attrs=(hash
|
||||
className="btn-small"
|
||||
icon="calendar-day"
|
||||
label="event.post_ui.add_to_calendar"
|
||||
action="addToGoogleCalendar"
|
||||
)
|
||||
}}
|
||||
{{attach
|
||||
widget="button"
|
||||
attrs=(hash
|
||||
className="btn-small"
|
||||
icon="envelope"
|
||||
label="event.post_ui.send_pm_to_creator"
|
||||
action="sendPMToCreator"
|
||||
)
|
||||
}}
|
||||
</footer>
|
||||
{{/if}}
|
||||
`
|
||||
});
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { htmlHelper } from "discourse-common/lib/helpers";
|
||||
|
||||
export default htmlHelper(postEvent => {
|
||||
return moment(postEvent.starts_at).format("LLL");
|
||||
});
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { htmlHelper } from "discourse-common/lib/helpers";
|
||||
|
||||
export default htmlHelper(postEvent => {
|
||||
return postEvent.name || postEvent.post.topic.title;
|
||||
});
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
import { withPluginApi } from "discourse/lib/plugin-api";
|
||||
import showModal from "discourse/lib/show-modal";
|
||||
import { Promise } from "rsvp";
|
||||
|
||||
function initializeEventUIBuilder(api) {
|
||||
api.decorateWidget("hamburger-menu:generalLinks", () => {
|
||||
return {
|
||||
icon: "calendar-day",
|
||||
route: "upcoming-events",
|
||||
label: "upcoming_events.title"
|
||||
};
|
||||
});
|
||||
|
||||
api.attachWidgetAction("post", "showEventUIBuilder", function(postId) {
|
||||
return new Promise(resolve => {
|
||||
if (postId) {
|
||||
this.store
|
||||
.find("post-event", postId)
|
||||
.then(resolve)
|
||||
.catch(() => {
|
||||
const postEvent = this.store.createRecord("post-event");
|
||||
postEvent.setProperties({
|
||||
id: postId,
|
||||
status: "public",
|
||||
display_invitees: "everyone"
|
||||
});
|
||||
resolve(postEvent);
|
||||
});
|
||||
} else if (this.model) {
|
||||
resolve(this.model);
|
||||
}
|
||||
}).then(model => {
|
||||
showModal("event-ui-builder", {
|
||||
model,
|
||||
modalClass: "event-ui-builder-modal"
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
api.decorateWidget("post-admin-menu:after", dec => {
|
||||
return dec.attach("post-admin-menu-button", {
|
||||
icon: "calendar-day",
|
||||
label: "event.ui_builder.attach",
|
||||
action: "showEventUIBuilder",
|
||||
actionParam: dec.attrs.id
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
name: "add-event-ui-builder",
|
||||
|
||||
initialize() {
|
||||
withPluginApi("0.8.7", initializeEventUIBuilder);
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
import { cookAsync } from "discourse/lib/text";
|
||||
import WidgetGlue from "discourse/widgets/glue";
|
||||
import { getRegister } from "discourse-common/lib/get-owner";
|
||||
import { withPluginApi } from "discourse/lib/plugin-api";
|
||||
import { schedule } from "@ember/runloop";
|
||||
|
||||
function _decoratePostEvent(api, cooked, post) {
|
||||
_attachWidget(api, cooked, post);
|
||||
}
|
||||
|
||||
let _glued = [];
|
||||
|
||||
function cleanUp() {
|
||||
_glued.forEach(g => g.cleanUp());
|
||||
_glued = [];
|
||||
}
|
||||
|
||||
function _attachWidget(api, cooked, postEvent) {
|
||||
const existing = cooked.querySelector(".post-event");
|
||||
|
||||
if (postEvent) {
|
||||
let widgetHeight = 170;
|
||||
if (postEvent.should_display_invitees) {
|
||||
widgetHeight += 125;
|
||||
}
|
||||
|
||||
if (postEvent.can_update_attendance) {
|
||||
widgetHeight += 65;
|
||||
}
|
||||
|
||||
const postEventContainer = existing || document.createElement("div");
|
||||
postEventContainer.classList.add("post-event");
|
||||
postEventContainer.classList.add("is-loading");
|
||||
postEventContainer.style.height = `${widgetHeight}px`;
|
||||
postEventContainer.innerHTML = '<div class="spinner medium"></div>';
|
||||
cooked.prepend(postEventContainer);
|
||||
|
||||
const dates = [];
|
||||
let format;
|
||||
|
||||
const startsAt = moment(postEvent.starts_at);
|
||||
if (
|
||||
startsAt.hours() > 0 ||
|
||||
startsAt.minutes() > 0 ||
|
||||
(postEvent.ends_at &&
|
||||
(moment(postEvent.ends_at).hours() > 0 ||
|
||||
moment(postEvent.ends_at).minutes() > 0))
|
||||
) {
|
||||
format = "LLL";
|
||||
} else {
|
||||
format = "LL";
|
||||
}
|
||||
|
||||
dates.push(
|
||||
`[date=${moment
|
||||
.utc(postEvent.starts_at)
|
||||
.format("YYYY-MM-DD")} time=${moment
|
||||
.utc(postEvent.starts_at)
|
||||
.format("HH:mm")} format=${format}]`
|
||||
);
|
||||
|
||||
if (postEvent.ends_at) {
|
||||
const endsAt = moment.utc(postEvent.ends_at);
|
||||
dates.push(
|
||||
`[date=${endsAt.format("YYYY-MM-DD")} time=${endsAt.format(
|
||||
"HH:mm"
|
||||
)} format=${format}]`
|
||||
);
|
||||
}
|
||||
|
||||
cookAsync(dates.join(" → ")).then(result => {
|
||||
const glue = new WidgetGlue("post-event", getRegister(api), {
|
||||
postEvent,
|
||||
widgetHeight,
|
||||
localDates: $(result.string).html()
|
||||
});
|
||||
|
||||
glue.appendTo(postEventContainer);
|
||||
_glued.push(glue);
|
||||
|
||||
schedule("afterRender", () => {
|
||||
$(
|
||||
".discourse-local-date",
|
||||
$(`[data-post-id="${postEvent.id}"]`)
|
||||
).applyLocalDates();
|
||||
});
|
||||
});
|
||||
} else {
|
||||
existing && existing.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function initializePostEventDecorator(api) {
|
||||
api.cleanupStream(cleanUp);
|
||||
|
||||
api.decorateCooked(($cooked, helper) => {
|
||||
if (helper) {
|
||||
const post = helper.getModel();
|
||||
if (post.post_event) {
|
||||
_decoratePostEvent(api, $cooked[0], post.post_event);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
api.replaceIcon(
|
||||
"notification.discourse_calendar.invite_user_notification",
|
||||
"calendar-day"
|
||||
);
|
||||
|
||||
api.modifyClass("controller:topic", {
|
||||
subscribe() {
|
||||
this._super(...arguments);
|
||||
this.messageBus.subscribe("/post-events/" + this.get("model.id"), msg => {
|
||||
const postNode = document.querySelector(
|
||||
`.onscreen-post[data-post-id="${msg.id}"] .cooked`
|
||||
);
|
||||
|
||||
if (postNode) {
|
||||
this.store
|
||||
.find("post-event", msg.id)
|
||||
.then(postEvent => _decoratePostEvent(api, postNode, postEvent))
|
||||
.catch(() => _decoratePostEvent(api, postNode));
|
||||
}
|
||||
});
|
||||
},
|
||||
unsubscribe() {
|
||||
this.messageBus.unsubscribe("/post-events/*");
|
||||
this._super(...arguments);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
name: "post-event-decorator",
|
||||
|
||||
initialize() {
|
||||
withPluginApi("0.8.7", initializePostEventDecorator);
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,354 @@
|
|||
.event-ui-builder-modal {
|
||||
.modal-inner-container {
|
||||
min-width: 50vw;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
min-height: 200px;
|
||||
|
||||
.d-date-time-input-range {
|
||||
width: auto;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.event-field {
|
||||
display: flex;
|
||||
margin: 1em 0;
|
||||
flex-direction: column;
|
||||
|
||||
&.name {
|
||||
input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.event-field-label {
|
||||
display: flex;
|
||||
min-height: 1px;
|
||||
padding-top: 0;
|
||||
top: 0;
|
||||
vertical-align: middle;
|
||||
align-items: center;
|
||||
|
||||
.label {
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
}
|
||||
|
||||
.event-field-control {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
|
||||
.radio-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 1em;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
input[type="radio"] {
|
||||
width: auto;
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
|
||||
.message {
|
||||
margin: 0 0 0 1em;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.description {
|
||||
color: $primary-medium;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ac-wrap {
|
||||
max-width: 450px;
|
||||
}
|
||||
|
||||
input {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.post-event {
|
||||
border: 1px solid $primary-low;
|
||||
display: flex;
|
||||
|
||||
&.is-loading {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&.has-post-event {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.post-event-footer {
|
||||
padding: 0.5em;
|
||||
background: $primary-very-low;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.post-event-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 1em;
|
||||
|
||||
.actions {
|
||||
margin-bottom: auto;
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.post-event-date {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: auto;
|
||||
margin-right: 1em;
|
||||
|
||||
.month {
|
||||
text-align: center;
|
||||
color: red;
|
||||
font-size: $font-down-1;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.day {
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
font-size: $font-up-2;
|
||||
}
|
||||
}
|
||||
|
||||
.post-event-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.status-and-name {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-bottom: 0.25em;
|
||||
|
||||
.name {
|
||||
font-weight: 700;
|
||||
margin-left: 0.25em;
|
||||
@include ellipsis;
|
||||
max-width: 45vw;
|
||||
}
|
||||
|
||||
.status {
|
||||
text-transform: lowercase;
|
||||
padding: 0.25em 0.5em;
|
||||
font-size: $font-down-1;
|
||||
border-radius: 3px;
|
||||
background-color: $primary-very-low;
|
||||
color: $primary-medium;
|
||||
flex-wrap: no-wrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.d-icon {
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.creators {
|
||||
color: $primary-medium;
|
||||
font-size: $font-down-1;
|
||||
}
|
||||
}
|
||||
|
||||
.post-event-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1em;
|
||||
|
||||
.post-event-status {
|
||||
&.status-going {
|
||||
color: $success;
|
||||
}
|
||||
|
||||
&.status-not_going {
|
||||
color: $danger;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.post-event-creator {
|
||||
.username {
|
||||
margin-left: 0.25em;
|
||||
}
|
||||
}
|
||||
|
||||
.post-event-invitees {
|
||||
padding: 1em;
|
||||
overflow-y: auto;
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1em;
|
||||
|
||||
.show-all {
|
||||
margin-left: 0.5em;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
.post-event-invitees-status {
|
||||
font-weight: 700;
|
||||
|
||||
.invited {
|
||||
font-weight: 500;
|
||||
color: $primary-medium;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.post-event-invitees-avatars {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.post-event-invitee {
|
||||
list-style: none;
|
||||
margin-right: 0.5em;
|
||||
margin-bottom: 0.5em;
|
||||
|
||||
&.unanswered {
|
||||
opacity: 0.25;
|
||||
}
|
||||
}
|
||||
|
||||
.topic-invitee-avatar {
|
||||
position: relative;
|
||||
display: flex;
|
||||
|
||||
.avatar-flair {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: $secondary;
|
||||
border-radius: 50%;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: $primary-medium;
|
||||
border: 1px solid $primary-low;
|
||||
|
||||
&.avatar-flair-status-going {
|
||||
color: $success;
|
||||
}
|
||||
|
||||
&.avatar-flair-status-not_going {
|
||||
color: $danger;
|
||||
}
|
||||
|
||||
.d-icon {
|
||||
font-size: $font-down-3;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hr {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.post-event-dates {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 1em;
|
||||
|
||||
.d-icon {
|
||||
color: $primary-medium;
|
||||
}
|
||||
|
||||
.date {
|
||||
color: $primary-high;
|
||||
margin-left: 1em;
|
||||
|
||||
.discourse-local-date {
|
||||
.d-icon {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.separator {
|
||||
color: $primary-high;
|
||||
margin: 0 0.5em;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.post-event-invitees-modal {
|
||||
.filter {
|
||||
width: 100%;
|
||||
}
|
||||
.invitees {
|
||||
display: flex;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
flex-direction: column;
|
||||
.invitee {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
padding: 0.5em;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid $primary-low;
|
||||
|
||||
&:last-child {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.status {
|
||||
margin-left: 1em;
|
||||
|
||||
&.going {
|
||||
color: $success;
|
||||
}
|
||||
|
||||
&.not_going {
|
||||
color: $danger;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.upcoming-events-table {
|
||||
width: 100%;
|
||||
|
||||
tbody {
|
||||
tr td {
|
||||
padding: 0.5em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
en:
|
||||
js:
|
||||
discourse_calendar:
|
||||
invite_user_notification: "%{username} invited you to: %{description}"
|
||||
on_holiday: "On Holiday"
|
||||
holiday: "Holiday"
|
||||
add_to_calendar: "Add to calendar"
|
||||
|
|
@ -8,6 +9,51 @@ en:
|
|||
title: "Timezone"
|
||||
instructions: "Your current timezone is %{timezone}."
|
||||
none: "Select a timezone..."
|
||||
event:
|
||||
display_invitees:
|
||||
everyone: "To everyone"
|
||||
invitees_only: "To invited users only"
|
||||
none: "Do not display invited users"
|
||||
invitee_status:
|
||||
unknown: "Undecided?"
|
||||
going: "✓ Going"
|
||||
not_going: "× Not Going"
|
||||
interested: "? Interested"
|
||||
post_event_status:
|
||||
standalone:
|
||||
title: Standalone
|
||||
description: "A standalone event can't be joined."
|
||||
public:
|
||||
title: Public
|
||||
description: "A public event can be joined by anyone."
|
||||
private:
|
||||
title: Private
|
||||
description: "A private event can only be joined by invited users."
|
||||
post_ui:
|
||||
show_all: show all
|
||||
add_to_calendar: add to calendar
|
||||
send_pm_to_creator: contact
|
||||
post-event-invitees-modal:
|
||||
title: "List of invited users"
|
||||
filter_placeholder: "Filter invited users"
|
||||
ui_builder:
|
||||
create_event_title: Create Event
|
||||
update_event_title: Update Event
|
||||
confirm_delete: Are you sure you want to delete this event?
|
||||
create: Create
|
||||
update: Save
|
||||
attach: Create event
|
||||
name:
|
||||
label: Event name
|
||||
placeholder: Optional, defaults to topic title
|
||||
invitees:
|
||||
label: Invited users/groups
|
||||
status:
|
||||
label: Status
|
||||
display_invitees:
|
||||
label: Display invited users
|
||||
upcoming_events:
|
||||
title: Upcoming events
|
||||
group_timezones:
|
||||
search: "Search..."
|
||||
group_availability: "%{group} availability"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
en:
|
||||
site_settings:
|
||||
calendar_enabled: "Enable the discourse-calendar plugin. This will add support for a [calendar][/calendar] tag in the first post of a topic."
|
||||
events_enabled: "Enables users to create events on a topic."
|
||||
holiday_calendar_topic_id: "Topic ID of staffs holiday / absence calendar."
|
||||
delete_expired_event_posts_after: "Posts with expired events will be automatically deleted after (n) hours. Set to -1 to disable deletion."
|
||||
all_day_event_start_time: "Events that do not have a start time specified will start at this time. Format is HH:mm. For 6:00 am, enter 06:00"
|
||||
|
|
@ -13,7 +14,12 @@ en:
|
|||
working_day_end_hour: "End time of the working day hours."
|
||||
close_to_working_day_hours_extension: "Set extension time in working day hours to highlight the timezones."
|
||||
discourse_calendar:
|
||||
invite_user_notification: "%{username} invited you to: %{description}"
|
||||
calendar_must_be_in_first_post: "Calendar tag can only be used in first post of a topic."
|
||||
more_than_one_calendar: "You can’t have more than one calendar in a post."
|
||||
more_than_two_dates: "A post of a calendar topic can’t contain more than two dates."
|
||||
event_expired: "Event expired"
|
||||
post_event:
|
||||
errors:
|
||||
raw_invitees_length: "An event is limited to %{count} users/groups"
|
||||
ends_at_before_starts_at: "An event can't end before it starts"
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@ plugins:
|
|||
calendar_enabled:
|
||||
default: false
|
||||
client: true
|
||||
events_enabled:
|
||||
default: true
|
||||
client: true
|
||||
holiday_calendar_topic_id:
|
||||
default: ""
|
||||
client: true
|
||||
|
|
@ -40,3 +43,7 @@ plugins:
|
|||
close_to_working_day_hours_extension:
|
||||
default: 2
|
||||
client: true
|
||||
displayed_invitees_limit:
|
||||
default: 10
|
||||
client: false
|
||||
max: 25
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class CreatePostEventsTable < ActiveRecord::Migration[5.2]
|
||||
def up
|
||||
create_table :discourse_calendar_post_events, id: false do |t|
|
||||
t.bigint :id, null: false, primary_key: true
|
||||
t.integer :status, default: 0, null: false
|
||||
t.integer :display_invitees, default: 0, null: false
|
||||
t.datetime :starts_at, null: false, default: -> { 'CURRENT_TIMESTAMP' }
|
||||
t.datetime :ends_at
|
||||
t.datetime :deleted_at
|
||||
t.string :raw_invitees, array: true
|
||||
t.string :name
|
||||
end
|
||||
end
|
||||
|
||||
def down
|
||||
drop_table :discourse_calendar_post_events
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class CreateInviteesTable < ActiveRecord::Migration[5.2]
|
||||
def up
|
||||
create_table :discourse_calendar_invitees do |t|
|
||||
t.integer :post_id, null: false
|
||||
t.integer :user_id, null: false
|
||||
t.integer :status
|
||||
t.timestamps null: false
|
||||
t.boolean :notified, null: false, default: false
|
||||
end
|
||||
|
||||
add_index :discourse_calendar_invitees, [:post_id, :user_id], unique: true
|
||||
end
|
||||
|
||||
def down
|
||||
drop_table :discourse_calendar_invitees
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,357 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class TimeSniffer
|
||||
Interval = Struct.new(:from, :to)
|
||||
Event = Struct.new(:at)
|
||||
|
||||
Context = Struct.new(:at, :timezone, :date_order)
|
||||
|
||||
class SniffedTime
|
||||
attr_reader :year
|
||||
attr_reader :month
|
||||
attr_reader :day
|
||||
attr_reader :hours
|
||||
attr_reader :minutes
|
||||
attr_reader :seconds
|
||||
attr_reader :zone
|
||||
|
||||
def initialize(year:, month:, day:, hours: 0, minutes: 0, seconds: 0, zone:)
|
||||
@year = year
|
||||
@month = month
|
||||
@day = day
|
||||
@hours = hours
|
||||
@minutes = minutes
|
||||
@seconds = seconds
|
||||
@zone = zone
|
||||
end
|
||||
|
||||
def self.from_datetime(obj, zone)
|
||||
new(
|
||||
year: obj.year,
|
||||
month: obj.month,
|
||||
day: obj.day,
|
||||
hours: obj.hour,
|
||||
minutes: obj.minute,
|
||||
seconds: obj.second,
|
||||
zone: zone
|
||||
)
|
||||
end
|
||||
|
||||
def to_time
|
||||
Time.use_zone(self.zone) do
|
||||
Time.zone.parse("#{self.year}-#{self.month}-#{self.day} #{self.hours}:#{self.minutes}:#{self.seconds}")
|
||||
end
|
||||
end
|
||||
|
||||
def with(**args)
|
||||
SniffedTime.new(**to_hash.merge(args))
|
||||
end
|
||||
|
||||
def to_hash
|
||||
{
|
||||
year: self.year,
|
||||
month: self.month,
|
||||
day: self.day,
|
||||
hours: self.hours,
|
||||
minutes: self.minutes,
|
||||
seconds: self.seconds,
|
||||
zone: self.zone,
|
||||
}
|
||||
end
|
||||
|
||||
def ==(other)
|
||||
return false unless other.kind_of?(SniffedTime)
|
||||
return false if @year != other.year
|
||||
return false if @month != other.month
|
||||
return false if @day != other.day
|
||||
return false if @hours != other.hours
|
||||
return false if @minutes != other.minutes
|
||||
return false if @seconds != other.seconds
|
||||
return false if @zone != other.zone
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
class << self
|
||||
def matchers
|
||||
@matchers ||= {}
|
||||
end
|
||||
|
||||
def matcher(name, regex, &blk)
|
||||
matchers[name] = {
|
||||
regex: regex,
|
||||
blk: blk,
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
class Parser
|
||||
UTC_REGEX = / ?(Z|UTC)/
|
||||
|
||||
def initialize(input, context)
|
||||
@input = input
|
||||
@context = context
|
||||
@offset = 0
|
||||
end
|
||||
|
||||
def parse_timezone
|
||||
m = input_from_offset.match(UTC_REGEX)
|
||||
if m && m.offset(0)[0] == 0
|
||||
self.offset += m.offset(0)[1]
|
||||
"UTC"
|
||||
end
|
||||
end
|
||||
|
||||
def parse_space
|
||||
if input[offset] == ' '
|
||||
self.offset += 1
|
||||
true
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
def parse_time(relative_to, immediate:)
|
||||
time, start_offset, stop_offset = peek_time(relative_to)
|
||||
if time && (!immediate || start_offset == 0)
|
||||
self.offset += stop_offset
|
||||
time
|
||||
end
|
||||
end
|
||||
|
||||
def parse_date
|
||||
date_match = DATE_REGEX.match(input_from_offset)
|
||||
if date_match
|
||||
day, month =
|
||||
case @context.date_order
|
||||
when :us
|
||||
[date_match[2], date_match[1]]
|
||||
when :sane
|
||||
[date_match[1], date_match[2]]
|
||||
end
|
||||
|
||||
year = date_match[3]
|
||||
year =
|
||||
case year.size
|
||||
when 2
|
||||
century = @context.at.year - (@context.at.year % 100)
|
||||
last_century = century - 100
|
||||
|
||||
choices = [
|
||||
century + year.to_i,
|
||||
last_century + year.to_i,
|
||||
]
|
||||
|
||||
choices.sort_by { |x|
|
||||
(@context.at.year - x).abs
|
||||
}[0]
|
||||
when 4
|
||||
year.to_i
|
||||
end
|
||||
|
||||
result =
|
||||
SniffedTime.new(
|
||||
year: year,
|
||||
month: month.to_i,
|
||||
day: day.to_i,
|
||||
zone: @context.timezone,
|
||||
)
|
||||
|
||||
self.offset += date_match.offset(0)[1]
|
||||
result
|
||||
end
|
||||
end
|
||||
|
||||
def parse_time_with_timezone(relative_to, immediate:)
|
||||
result = parse_time(relative_to, immediate: immediate)
|
||||
if result
|
||||
zone = parse_timezone
|
||||
|
||||
if zone
|
||||
result = result.with(zone: zone)
|
||||
end
|
||||
|
||||
result
|
||||
end
|
||||
end
|
||||
|
||||
def parse_date_time(relative_to)
|
||||
date = parse_date
|
||||
if date
|
||||
if parse_space
|
||||
datetime = parse_time_with_timezone(date, immediate: true)
|
||||
if datetime
|
||||
[false, datetime]
|
||||
else
|
||||
[true, date]
|
||||
end
|
||||
else
|
||||
[true, date]
|
||||
end
|
||||
elsif relative_to
|
||||
datetime = parse_time_with_timezone(relative_to, immediate: false)
|
||||
if datetime
|
||||
[false, datetime]
|
||||
else
|
||||
[true, nil]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def parse_range
|
||||
if x = parse_date_time(nil)
|
||||
from_is_date, from = x
|
||||
to_is_date, to = parse_date_time(from)
|
||||
|
||||
if to
|
||||
if to_is_date
|
||||
Interval.new(from.to_time, to.to_time + 1.day)
|
||||
else
|
||||
Interval.new(from.to_time, to.to_time)
|
||||
end
|
||||
else
|
||||
if from_is_date
|
||||
Interval.new(from.to_time, from.to_time + 1.day)
|
||||
else
|
||||
Event.new(from.to_time)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def input_from_offset
|
||||
self.input[self.offset..-1]
|
||||
end
|
||||
|
||||
def peek_time(relative_to)
|
||||
m = self.input_from_offset.match(TIME_REGEX)
|
||||
if m
|
||||
parsed =
|
||||
relative_to.with(
|
||||
hours: m[1].to_i,
|
||||
minutes: m[2].to_i,
|
||||
seconds: 0,
|
||||
zone: @context.timezone,
|
||||
)
|
||||
|
||||
[parsed, *m.offset(0)]
|
||||
end
|
||||
end
|
||||
|
||||
attr_reader :input
|
||||
attr_accessor :offset
|
||||
end
|
||||
|
||||
matcher(:yesterday, /yesterday/) do |m|
|
||||
today = at.to_date
|
||||
yesterday = today - 1
|
||||
|
||||
Interval.new(
|
||||
SniffedTime
|
||||
.from_datetime(yesterday.to_datetime, timezone)
|
||||
.to_time,
|
||||
SniffedTime
|
||||
.from_datetime(today.to_datetime, timezone)
|
||||
.to_time,
|
||||
)
|
||||
end
|
||||
|
||||
matcher(:tomorrow, /tomorrow/i) do |_|
|
||||
tomorrow = at.to_date + 1
|
||||
the_day_after_tomorrow = tomorrow + 1
|
||||
|
||||
Interval.new(
|
||||
SniffedTime
|
||||
.from_datetime(tomorrow.to_datetime, timezone)
|
||||
.to_time,
|
||||
SniffedTime
|
||||
.from_datetime(the_day_after_tomorrow.to_datetime, timezone)
|
||||
.to_time,
|
||||
)
|
||||
end
|
||||
|
||||
TIME_REGEX = /(\d{1,2}):(\d{2})/
|
||||
|
||||
matcher(:time, TIME_REGEX) do |m|
|
||||
times = input.scan(TIME_REGEX).to_a
|
||||
from, to = times[0..2]
|
||||
if to
|
||||
Interval.new(
|
||||
SniffedTime.new(
|
||||
year: at.year,
|
||||
month: at.month,
|
||||
day: at.day,
|
||||
hours: from[0].to_i,
|
||||
minutes: from[1].to_i,
|
||||
seconds: 0,
|
||||
zone: timezone,
|
||||
).to_time,
|
||||
SniffedTime.new(
|
||||
year: at.year,
|
||||
month: at.month,
|
||||
day: at.day,
|
||||
hours: to[0].to_i,
|
||||
minutes: to[1].to_i,
|
||||
seconds: 0,
|
||||
zone: timezone,
|
||||
).to_time,
|
||||
)
|
||||
else
|
||||
Event.new(
|
||||
SniffedTime.new(
|
||||
year: at.year,
|
||||
month: at.month,
|
||||
day: at.day,
|
||||
hours: from[0].to_i,
|
||||
minutes: from[1].to_i,
|
||||
seconds: 0,
|
||||
zone: timezone,
|
||||
).to_time
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
DATE_SEPARATOR = /[-\/]/
|
||||
DATE_REGEX = /(\d{1,2})#{DATE_SEPARATOR}(\d{1,2})#{DATE_SEPARATOR}(\d{2,4})/
|
||||
|
||||
matcher(:date, DATE_REGEX) do |m|
|
||||
Parser.new(input, @context).parse_range
|
||||
end
|
||||
|
||||
def initialize(input, at: DateTime.now, timezone:, date_order:, matchers:, raise_errors: false)
|
||||
@input = input
|
||||
@at = at
|
||||
@timezone = timezone
|
||||
@date_order = date_order
|
||||
@context = Context.new(@at, @timezone, @date_order)
|
||||
@matchers = matchers
|
||||
@raise_errors = raise_errors
|
||||
end
|
||||
|
||||
def sniff
|
||||
@matchers.each do |matcher_name|
|
||||
matcher = self.class.matchers[matcher_name]
|
||||
regex, blk = matcher.values_at(:regex, :blk)
|
||||
|
||||
match = regex.match(@input)
|
||||
if match
|
||||
begin
|
||||
result = instance_exec(match, &blk)
|
||||
rescue Exception => e
|
||||
raise if @raise_errors
|
||||
else
|
||||
return result if result
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :input
|
||||
attr_reader :at
|
||||
attr_reader :timezone
|
||||
attr_reader :date_order
|
||||
end
|
||||
121
plugin.rb
121
plugin.rb
|
|
@ -3,7 +3,7 @@
|
|||
# name: discourse-calendar
|
||||
# about: Display a calendar in the first post of a topic
|
||||
# version: 0.2
|
||||
# author: Joffrey Jaffeux
|
||||
# author: Daniel Waterworth, Joffrey Jaffeux
|
||||
# url: https://github.com/discourse/discourse-calendar
|
||||
|
||||
gem "holidays", "8.0.0", require: false
|
||||
|
|
@ -14,10 +14,15 @@ enabled_site_setting :calendar_enabled
|
|||
|
||||
register_asset "stylesheets/vendor/fullcalendar.min.css"
|
||||
register_asset "stylesheets/common/discourse-calendar.scss"
|
||||
register_asset "stylesheets/common/post-event.scss"
|
||||
register_asset "stylesheets/mobile/discourse-calendar.scss", :mobile
|
||||
register_asset "stylesheets/desktop/discourse-calendar.scss", :desktop
|
||||
register_svg_icon "fas fa-calendar-day"
|
||||
register_svg_icon "fas fa-question"
|
||||
register_svg_icon "fas fa-clock"
|
||||
|
||||
after_initialize do
|
||||
|
||||
module ::DiscourseCalendar
|
||||
PLUGIN_NAME ||= "discourse-calendar"
|
||||
|
||||
|
|
@ -43,18 +48,32 @@ after_initialize do
|
|||
def self.users_on_holiday=(usernames)
|
||||
PluginStore.set(PLUGIN_NAME, USERS_ON_HOLIDAY_KEY, usernames)
|
||||
end
|
||||
|
||||
class Engine < ::Rails::Engine
|
||||
engine_name PLUGIN_NAME
|
||||
isolate_namespace DiscourseCalendar
|
||||
end
|
||||
end
|
||||
|
||||
[
|
||||
"../app/models/calendar_event.rb",
|
||||
"../app/models/guardian.rb",
|
||||
"../app/serializers/user_timezone_serializer.rb",
|
||||
"../app/controllers/discourse_calendar/invitees_controller.rb",
|
||||
"../app/controllers/discourse_calendar/post_events_controller.rb",
|
||||
"../app/controllers/discourse_calendar/upcoming_events_controller.rb",
|
||||
"../app/models/discourse_calendar/post_event.rb",
|
||||
"../app/models/discourse_calendar/invitee.rb",
|
||||
"../app/serializers/discourse_calendar/invitee_serializer.rb",
|
||||
"../app/serializers/discourse_calendar/post_event_serializer.rb",
|
||||
"../jobs/scheduled/create_holiday_events.rb",
|
||||
"../jobs/scheduled/destroy_past_events.rb",
|
||||
"../jobs/scheduled/update_holiday_usernames.rb",
|
||||
"../lib/calendar_validator.rb",
|
||||
"../lib/calendar.rb",
|
||||
"../lib/event_validator.rb",
|
||||
"../lib/group_timezones.rb"
|
||||
"../lib/group_timezones.rb",
|
||||
"../lib/time_sniffer.rb",
|
||||
].each { |path| load File.expand_path(path, __FILE__) }
|
||||
|
||||
register_post_custom_field_type(DiscourseCalendar::CALENDAR_CUSTOM_FIELD, :string)
|
||||
|
|
@ -198,4 +217,102 @@ after_initialize do
|
|||
add_to_serializer(:site, :include_users_on_holiday?) do
|
||||
scope.is_staff?
|
||||
end
|
||||
|
||||
require 'post'
|
||||
class ::Post
|
||||
has_one :post_event,
|
||||
dependent: :destroy,
|
||||
class_name: 'DiscourseCalendar::PostEvent',
|
||||
foreign_key: :id
|
||||
end
|
||||
|
||||
add_to_serializer(:post, :post_event) do
|
||||
DiscourseCalendar::PostEventSerializer.new(object.post_event, scope: scope, root: false)
|
||||
end
|
||||
|
||||
reloadable_patch do |plugin|
|
||||
add_to_serializer(:post, :include_post_event?) do
|
||||
plugin.enabled?
|
||||
end
|
||||
end
|
||||
|
||||
Discourse::Application.routes.append do
|
||||
mount ::DiscourseCalendar::Engine, at: '/'
|
||||
end
|
||||
|
||||
DiscourseCalendar::Engine.routes.draw do
|
||||
get '/discourse-calendar/post-events/:id' => 'post_events#show'
|
||||
delete '/discourse-calendar/post-events/:id' => 'post_events#destroy'
|
||||
get '/discourse-calendar/post-events' => 'post_events#index'
|
||||
post '/discourse-calendar/post-events' => 'post_events#create'
|
||||
put '/discourse-calendar/post-events/:id' => 'post_events#update'
|
||||
put '/discourse-calendar/invitees/:id' => 'invitees#update'
|
||||
post '/discourse-calendar/invitees' => 'invitees#create'
|
||||
get '/discourse-calendar/invitees' => 'invitees#index'
|
||||
get '/upcoming-events' => 'upcoming_events#index'
|
||||
end
|
||||
|
||||
DiscourseEvent.on(:post_destroyed) do |post|
|
||||
if post.post_event
|
||||
post.post_event.update!(deleted_at: Time.now)
|
||||
end
|
||||
end
|
||||
|
||||
DiscourseEvent.on(:post_recovered) do |post|
|
||||
if post.post_event
|
||||
post.post_event.update!(deleted_at: nil)
|
||||
end
|
||||
end
|
||||
|
||||
DiscourseEvent.on(:post_edited) do |post, topic_changed|
|
||||
if post.post_event && post.is_first_post? && post.topic && topic_changed && post.topic != Archetype.private_message
|
||||
time_range = extract_time_range(post.topic, post.user)
|
||||
|
||||
case time_range
|
||||
when TimeSniffer::Interval
|
||||
post.post_event.update!(
|
||||
starts_at: time_range.from.to_time.utc,
|
||||
ends_at: time_range.to.to_time.utc,
|
||||
)
|
||||
when TimeSniffer::Event
|
||||
post.post_event.update!(
|
||||
starts_at: time_range.at.to_time.utc
|
||||
)
|
||||
end
|
||||
|
||||
post.post_event.publish_update!
|
||||
end
|
||||
end
|
||||
|
||||
def extract_time_range(topic, user)
|
||||
TimeSniffer.new(
|
||||
topic.title,
|
||||
at: topic.created_at,
|
||||
timezone: user.user_option.timezone || 'UTC',
|
||||
date_order: :sane,
|
||||
matchers: [:tomorrow, :date, :time],
|
||||
).sniff
|
||||
end
|
||||
|
||||
DiscourseEvent.on(:topic_created) do |topic, args, user|
|
||||
if topic.archetype != Archetype.private_message
|
||||
time_range = extract_time_range(topic, user)
|
||||
|
||||
case time_range
|
||||
when TimeSniffer::Interval
|
||||
DiscourseCalendar::PostEvent.create!(
|
||||
id: topic.first_post.id,
|
||||
starts_at: time_range.from.to_time.utc,
|
||||
ends_at: time_range.to.to_time.utc,
|
||||
status: DiscourseCalendar::PostEvent.statuses[:standalone]
|
||||
)
|
||||
when TimeSniffer::Event
|
||||
DiscourseCalendar::PostEvent.create!(
|
||||
id: topic.first_post.id,
|
||||
starts_at: time_range.at.to_time.utc,
|
||||
status: DiscourseCalendar::PostEvent.statuses[:standalone]
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require "rails_helper"
|
||||
require_relative '../fabricators/post_event_fabricator'
|
||||
|
||||
describe Post do
|
||||
PostEvent ||= DiscourseCalendar::PostEvent
|
||||
|
||||
fab!(:user) { Fabricate(:user) }
|
||||
fab!(:topic) { Fabricate(:topic, user: user) }
|
||||
fab!(:post1) { Fabricate(:post, topic: topic) }
|
||||
fab!(:post_event) { Fabricate(:post_event, post: post1) }
|
||||
|
||||
before do
|
||||
freeze_time
|
||||
SiteSetting.queue_jobs = false
|
||||
end
|
||||
|
||||
context 'when a post with an event is destroyed' do
|
||||
it 'sets deleted_at on the post_event' do
|
||||
expect(post_event.deleted_at).to be_nil
|
||||
|
||||
PostDestroyer.new(user, post_event.post).destroy
|
||||
post_event.reload
|
||||
|
||||
expect(post_event.deleted_at).to eq(Time.now)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a post with an event is recovered' do
|
||||
it 'nullifies deleted_at on the post_event' do
|
||||
PostDestroyer.new(user, post_event.post).destroy
|
||||
post_event.reload
|
||||
|
||||
expect(post_event.deleted_at).to eq(Time.now)
|
||||
|
||||
PostDestroyer.new(user, post_event.post).recover
|
||||
post_event.reload
|
||||
|
||||
expect(post_event.deleted_at).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require "rails_helper"
|
||||
|
||||
describe Topic do
|
||||
PostEvent ||= DiscourseCalendar::PostEvent
|
||||
|
||||
before do
|
||||
freeze_time
|
||||
SiteSetting.queue_jobs = false
|
||||
end
|
||||
|
||||
fab!(:user) { Fabricate(:user) }
|
||||
|
||||
context 'when a topic is created' do
|
||||
context 'with a date' do
|
||||
it 'creates a post event' do
|
||||
post_with_date = PostCreator.create!(
|
||||
user,
|
||||
title: 'Let’s buy a boat with me tomorrow',
|
||||
raw: 'The boat market is quite active lately.'
|
||||
)
|
||||
|
||||
post_event = PostEvent.find(post_with_date.id)
|
||||
expect(post_event).to be_present
|
||||
expect(post_event.starts_at).to eq(post_with_date.topic.created_at.tomorrow.beginning_of_day)
|
||||
expect(post_event.status).to eq(PostEvent.statuses[:standalone])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
Fabricator(:post_event, from: 'DiscourseCalendar::PostEvent') do
|
||||
post { |attrs| attrs[:post] }
|
||||
|
||||
id { |attrs| attrs[:post].id }
|
||||
|
||||
status { |attrs|
|
||||
attrs[:status] ?
|
||||
DiscourseCalendar::PostEvent.statuses[attrs[:status]] :
|
||||
DiscourseCalendar::PostEvent.statuses[:public]
|
||||
}
|
||||
starts_at { |attrs| attrs[:starts_at] || 1.day.from_now.iso8601 }
|
||||
ends_at { |attrs| attrs[:ends_at] }
|
||||
end
|
||||
|
|
@ -0,0 +1,211 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require "rails_helper"
|
||||
|
||||
describe TimeSniffer do
|
||||
before do
|
||||
freeze_time DateTime.parse('2020-04-24 14:10')
|
||||
end
|
||||
|
||||
let(:default_context) {
|
||||
{
|
||||
at: DateTime.parse('2020-1-20 00:00:00'),
|
||||
timezone: 'EST',
|
||||
date_order: :sane,
|
||||
matchers: [:tomorrow, :date, :time],
|
||||
raise_errors: true,
|
||||
}
|
||||
}
|
||||
|
||||
define_method(:expect_parsed_as_interval) do |str, from:, to:, context: default_context|
|
||||
Time.use_zone(context[:timezone]) do
|
||||
expect(TimeSniffer.new(str, **context).sniff).to(
|
||||
eq(TimeSniffer::Interval.new(Time.zone.parse(from), Time.zone.parse(to)))
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
define_method(:expect_parsed_as_event) do |str, at, context: default_context|
|
||||
Time.use_zone(context[:timezone]) do
|
||||
expect(TimeSniffer.new(str, **context).sniff).to(
|
||||
eq(TimeSniffer::Event.new(Time.zone.parse(at)))
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
define_method(:expect_parsed_as_nil) do |str, context: default_context|
|
||||
expect(TimeSniffer.new(str, **context).sniff).to(
|
||||
eq(nil)
|
||||
)
|
||||
end
|
||||
|
||||
it "should support tomorrow with a timezone" do
|
||||
expect_parsed_as_interval(
|
||||
"tomorrow",
|
||||
from: "2020-1-21 EST",
|
||||
to: "2020-1-22 EST",
|
||||
)
|
||||
end
|
||||
|
||||
it "should support Tomorrow" do
|
||||
expect_parsed_as_interval(
|
||||
"Tomorrow",
|
||||
from: "2020-1-21",
|
||||
to: "2020-1-22",
|
||||
)
|
||||
end
|
||||
|
||||
it "should support 14:00" do
|
||||
expect_parsed_as_event("14:00", "2020-1-20 14:00 EST")
|
||||
end
|
||||
|
||||
it "should support 14:24" do
|
||||
expect_parsed_as_event("14:24", "2020-1-20 14:24 EST")
|
||||
end
|
||||
|
||||
it "should support 15:00 with emojis" do
|
||||
expect_parsed_as_event("😊😊😊😊15:00😊😊😊😊", "2020-1-20 15:00 EST")
|
||||
end
|
||||
|
||||
it "should support 14:00 - 15:00" do
|
||||
expect_parsed_as_interval(
|
||||
"14:00 - 15:00",
|
||||
from: "2020-1-20 14:00 EST",
|
||||
to: "2020-1-20 15:00 EST",
|
||||
)
|
||||
end
|
||||
|
||||
it "should support too many times" do
|
||||
expect_parsed_as_interval(
|
||||
"14:00 - 15:00 asotuhosthu 16:00",
|
||||
from: "2020-1-20 14:00 EST",
|
||||
to: "2020-1-20 15:00 EST",
|
||||
)
|
||||
end
|
||||
|
||||
it "should support too many times" do
|
||||
expect_parsed_as_interval(
|
||||
"14:00 - 15:00 asotuhosthu 16:00",
|
||||
from: "2020-1-20 14:00 EST",
|
||||
to: "2020-1-20 15:00 EST",
|
||||
)
|
||||
end
|
||||
|
||||
it "should support a date" do
|
||||
expect_parsed_as_interval(
|
||||
"31/3/25",
|
||||
from: "2025-3-31 00:00 EST",
|
||||
to: "2025-4-1 00:00 EST",
|
||||
)
|
||||
end
|
||||
|
||||
it "should support a date in the past century" do
|
||||
expect_parsed_as_interval(
|
||||
"31/3/75",
|
||||
from: "1975-3-31 00:00 EST",
|
||||
to: "1975-4-1 00:00 EST",
|
||||
)
|
||||
end
|
||||
|
||||
it "should support a date with a year with 4 digits" do
|
||||
expect_parsed_as_interval(
|
||||
"31/3/2021",
|
||||
from: "2021-3-31 00:00 EST",
|
||||
to: "2021-4-1 00:00 EST",
|
||||
)
|
||||
end
|
||||
|
||||
it "should support a date with hyphens" do
|
||||
expect_parsed_as_interval(
|
||||
"31-3-25",
|
||||
from: "2025-3-31 00:00 EST",
|
||||
to: "2025-4-1 00:00 EST",
|
||||
)
|
||||
end
|
||||
|
||||
it "should support a date with a time" do
|
||||
expect_parsed_as_event(
|
||||
"31-3-25 08:00",
|
||||
"2025-3-31 08:00 EST",
|
||||
)
|
||||
end
|
||||
|
||||
it "should support a date with a time with non-zero minutes" do
|
||||
expect_parsed_as_event(
|
||||
"31-3-25 08:45",
|
||||
"2025-3-31 08:45 EST",
|
||||
)
|
||||
end
|
||||
|
||||
it "should support a date with a time and a timezone" do
|
||||
expect_parsed_as_event(
|
||||
"31-3-25 08:00 UTC",
|
||||
"2025-3-31 08:00:00 UTC",
|
||||
context: default_context.merge(timezone: 'EST'),
|
||||
)
|
||||
end
|
||||
|
||||
it "should support a date with a time and a timezone" do
|
||||
expect_parsed_as_event(
|
||||
"31-3-25 08:00UTC",
|
||||
"2025-3-31 08:00:00 UTC",
|
||||
context: default_context.merge(timezone: 'EST'),
|
||||
)
|
||||
end
|
||||
|
||||
it "should support a date with a time and a timezone" do
|
||||
expect_parsed_as_event(
|
||||
"31-3-25 08:00Z",
|
||||
"2025-3-31 08:00:00 UTC",
|
||||
context: default_context.merge(timezone: 'EST'),
|
||||
)
|
||||
end
|
||||
|
||||
it "should support a date range" do
|
||||
expect_parsed_as_interval(
|
||||
"25/2/21 - 10/3/22",
|
||||
from: "2021-2-25 00:00 EST",
|
||||
to: "2022-3-11 00:00 EST",
|
||||
)
|
||||
end
|
||||
|
||||
it "should support a date range" do
|
||||
expect_parsed_as_interval(
|
||||
"25/2/21 - 10/3/22 14:00",
|
||||
from: "2021-2-25 00:00 EST",
|
||||
to: "2022-3-10 14:00 EST",
|
||||
)
|
||||
end
|
||||
|
||||
it "should support a date range with two times" do
|
||||
expect_parsed_as_interval(
|
||||
"25/2/21 9:00 - 10/3/22 14:00",
|
||||
from: "2021-2-25 09:00 EST",
|
||||
to: "2022-3-10 14:00 EST",
|
||||
)
|
||||
end
|
||||
|
||||
it "should support a date range with two times where the second is relative to the first" do
|
||||
expect_parsed_as_interval(
|
||||
"25/2/21 9:00 - 14:00",
|
||||
from: "2021-2-25 09:00 EST",
|
||||
to: "2021-2-25 14:00 EST",
|
||||
)
|
||||
end
|
||||
|
||||
it "should correctly handle timezones in future" do
|
||||
expect_parsed_as_event(
|
||||
"24/06/2020 14:23",
|
||||
"2020-06-24 14:23 CEST",
|
||||
context: default_context.merge(timezone: 'Europe/Paris'),
|
||||
)
|
||||
end
|
||||
|
||||
it "should not find a time in a random number" do
|
||||
expect_parsed_as_nil("1500")
|
||||
end
|
||||
|
||||
it "should not find a time in random numbers and an emoji" do
|
||||
expect_parsed_as_nil("15😊00")
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
require_relative '../fabricators/post_event_fabricator'
|
||||
|
||||
module DiscourseCalendar
|
||||
describe InviteesController do
|
||||
fab!(:user) { Fabricate(:user, admin: true) }
|
||||
fab!(:topic) { Fabricate(:topic, user: user) }
|
||||
fab!(:post1) { Fabricate(:post, user: user, topic: topic) }
|
||||
|
||||
before do
|
||||
SiteSetting.queue_jobs = false
|
||||
sign_in(user)
|
||||
end
|
||||
|
||||
context 'when a post event exists' do
|
||||
context 'when an invitee exists' do
|
||||
fab!(:invitee1) { Fabricate(:user) }
|
||||
fab!(:post_event) {
|
||||
pe = Fabricate(:post_event, post: post1)
|
||||
pe.create_invitees([{
|
||||
user_id: invitee1.id,
|
||||
status: Invitee.statuses[:going]
|
||||
}])
|
||||
pe
|
||||
}
|
||||
|
||||
it 'updates its status' do
|
||||
invitee = post_event.invitees.first
|
||||
|
||||
expect(invitee.status).to eq(0)
|
||||
|
||||
put "/discourse-calendar/invitees/#{invitee.id}.json", params: {
|
||||
invitee: {
|
||||
status: "interested"
|
||||
}
|
||||
}
|
||||
|
||||
invitee.reload
|
||||
|
||||
expect(invitee.status).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when an invitee doesn’t exist' do
|
||||
fab!(:post_event) { Fabricate(:post_event, post: post1) }
|
||||
|
||||
it 'creates an invitee' do
|
||||
post "/discourse-calendar/invitees.json", params: {
|
||||
invitee: {
|
||||
user_id: user.id,
|
||||
post_id: post_event.id,
|
||||
status: "not_going",
|
||||
}
|
||||
}
|
||||
|
||||
expect(Invitee).to exist(
|
||||
post_id: post_event.id,
|
||||
user_id: user.id,
|
||||
status: 2,
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,328 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require "rails_helper"
|
||||
require_relative '../fabricators/post_event_fabricator'
|
||||
|
||||
module DiscourseCalendar
|
||||
describe PostEventsController do
|
||||
fab!(:user) { Fabricate(:user, admin: true) }
|
||||
fab!(:topic) { Fabricate(:topic, user: user) }
|
||||
fab!(:post1) { Fabricate(:post, user: user, topic: topic) }
|
||||
fab!(:invitee1) { Fabricate(:user) }
|
||||
fab!(:invitee2) { Fabricate(:user) }
|
||||
|
||||
before do
|
||||
SiteSetting.queue_jobs = false
|
||||
SiteSetting.displayed_invitees_limit = 3
|
||||
end
|
||||
|
||||
context 'when a post exists' do
|
||||
fab!(:invitee3) { Fabricate(:user) }
|
||||
fab!(:invitee4) { Fabricate(:user) }
|
||||
fab!(:invitee5) { Fabricate(:user) }
|
||||
fab!(:group) {
|
||||
Fabricate(:group).tap do |g|
|
||||
g.add(invitee2)
|
||||
g.add(invitee3)
|
||||
g.save!
|
||||
end
|
||||
}
|
||||
|
||||
before do
|
||||
sign_in(user)
|
||||
end
|
||||
|
||||
it 'creates a post event' do
|
||||
post '/discourse-calendar/post-events.json', params: {
|
||||
post_event: {
|
||||
id: post1.id
|
||||
}
|
||||
}
|
||||
|
||||
expect(response.status).to eq(200)
|
||||
json = ::JSON.parse(response.body)
|
||||
expect(json['post_event']['id']).to eq(post1.id)
|
||||
expect(PostEvent).to exist(id: post1.id)
|
||||
end
|
||||
|
||||
it 'accepts user and group invitees' do
|
||||
invitees = [invitee1.username, group.name]
|
||||
|
||||
post '/discourse-calendar/post-events.json', params: {
|
||||
post_event: {
|
||||
id: post1.id,
|
||||
raw_invitees: invitees,
|
||||
status: PostEvent.statuses[:private],
|
||||
display_invitees: PostEvent.display_invitees_options[:everyone]
|
||||
}
|
||||
}
|
||||
|
||||
expect(response.status).to eq(200)
|
||||
json = ::JSON.parse(response.body)
|
||||
sample_invitees = json['post_event']['sample_invitees']
|
||||
expect(sample_invitees.map { |i| i['user']['id'] }).to match_array([user.id, invitee1.id, group.group_users.first.user.id])
|
||||
raw_invitees = json['post_event']['raw_invitees']
|
||||
expect(raw_invitees).to match_array(invitees)
|
||||
end
|
||||
|
||||
it 'accepts one user invitee' do
|
||||
post '/discourse-calendar/post-events.json', params: {
|
||||
post_event: {
|
||||
id: post1.id,
|
||||
status: PostEvent.statuses[:private],
|
||||
raw_invitees: [invitee1.username],
|
||||
}
|
||||
}
|
||||
|
||||
expect(response.status).to eq(200)
|
||||
json = ::JSON.parse(response.body)
|
||||
sample_invitees = json['post_event']['sample_invitees']
|
||||
expect(sample_invitees[0]['user']['username']).to eq(user.username)
|
||||
expect(sample_invitees[1]['user']['username']).to eq(invitee1.username)
|
||||
end
|
||||
|
||||
it 'accepts one group invitee' do
|
||||
post '/discourse-calendar/post-events.json', params: {
|
||||
post_event: {
|
||||
id: post1.id,
|
||||
status: PostEvent.statuses[:private],
|
||||
raw_invitees: [group.name],
|
||||
}
|
||||
}
|
||||
|
||||
expect(response.status).to eq(200)
|
||||
json = ::JSON.parse(response.body)
|
||||
sample_invitees = json['post_event']['sample_invitees']
|
||||
expect(sample_invitees.map { |i| i['user']['username'] }).to match_array([user.username] + group.group_users.map(&:user).map(&:username))
|
||||
end
|
||||
|
||||
it 'accepts no invitee' do
|
||||
post '/discourse-calendar/post-events.json', params: {
|
||||
post_event: {
|
||||
id: post1.id,
|
||||
raw_invitees: [],
|
||||
status: PostEvent.statuses[:private],
|
||||
display_invitees: PostEvent.display_invitees_options[:everyone]
|
||||
}
|
||||
}
|
||||
|
||||
expect(response.status).to eq(200)
|
||||
json = ::JSON.parse(response.body)
|
||||
sample_invitees = json['post_event']['sample_invitees']
|
||||
expect(sample_invitees.count).to eq(1)
|
||||
expect(sample_invitees[0]['user']['username']).to eq(user.username)
|
||||
end
|
||||
|
||||
it 'limits displayed invitees' do
|
||||
post '/discourse-calendar/post-events.json', params: {
|
||||
post_event: {
|
||||
id: post1.id,
|
||||
status: PostEvent.statuses[:private],
|
||||
raw_invitees: [
|
||||
invitee1.username,
|
||||
invitee2.username,
|
||||
invitee3.username,
|
||||
invitee4.username,
|
||||
invitee5.username,
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
expect(response.status).to eq(200)
|
||||
json = ::JSON.parse(response.body)
|
||||
sample_invitees = json['post_event']['sample_invitees']
|
||||
expect(sample_invitees.map { |i| i['user']['username'] }).to match_array([user.username, invitee1.username, invitee2.username])
|
||||
end
|
||||
|
||||
context 'when a post_event exists' do
|
||||
fab!(:post_event) { Fabricate(:post_event, post: post1) }
|
||||
|
||||
context 'when we update the post_event' do
|
||||
context 'when status changes from standalone to private' do
|
||||
it 'changes the status, raw_invitees and invitees' do
|
||||
post_event.update!(status: PostEvent.statuses[:standalone])
|
||||
|
||||
put "/discourse-calendar/post-events/#{post_event.id}.json", params: {
|
||||
post_event: {
|
||||
status: PostEvent.statuses[:private].to_s,
|
||||
raw_invitees: [invitee1.username]
|
||||
}
|
||||
}
|
||||
|
||||
post_event.reload
|
||||
|
||||
expect(post_event.status).to eq(PostEvent.statuses[:private])
|
||||
expect(post_event.raw_invitees).to eq([invitee1.username])
|
||||
expect(post_event.invitees.pluck(:user_id)).to match_array([invitee1.id])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when status changes from standalone to public' do
|
||||
it 'changes the status' do
|
||||
post_event.update!(status: PostEvent.statuses[:standalone])
|
||||
|
||||
put "/discourse-calendar/post-events/#{post_event.id}.json", params: {
|
||||
post_event: {
|
||||
status: PostEvent.statuses[:public].to_s
|
||||
}
|
||||
}
|
||||
|
||||
post_event.reload
|
||||
|
||||
expect(post_event.status).to eq(PostEvent.statuses[:public])
|
||||
expect(post_event.raw_invitees).to eq([])
|
||||
expect(post_event.invitees).to eq([])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when status changes from private to standalone' do
|
||||
it 'changes the status' do
|
||||
post_event.update!(
|
||||
status: PostEvent.statuses[:private],
|
||||
raw_invitees: [invitee1.username]
|
||||
)
|
||||
post_event.fill_invitees!
|
||||
|
||||
post_event.reload
|
||||
|
||||
expect(post_event.invitees.pluck(:user_id)).to eq([invitee1.id])
|
||||
expect(post_event.raw_invitees).to eq([invitee1.username])
|
||||
|
||||
put "/discourse-calendar/post-events/#{post_event.id}.json", params: {
|
||||
post_event: {
|
||||
status: PostEvent.statuses[:standalone].to_s
|
||||
}
|
||||
}
|
||||
|
||||
post_event.reload
|
||||
|
||||
expect(post_event.status).to eq(PostEvent.statuses[:standalone])
|
||||
expect(post_event.raw_invitees).to eq([])
|
||||
expect(post_event.invitees).to eq([])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when status changes from private to public' do
|
||||
it 'changes the status, removes raw_invitees and keeps invitees' do
|
||||
post_event.update!(
|
||||
status: PostEvent.statuses[:private],
|
||||
raw_invitees: [invitee1.username]
|
||||
)
|
||||
post_event.fill_invitees!
|
||||
|
||||
post_event.reload
|
||||
|
||||
expect(post_event.invitees.pluck(:user_id)).to eq([invitee1.id])
|
||||
expect(post_event.raw_invitees).to eq([invitee1.username])
|
||||
|
||||
put "/discourse-calendar/post-events/#{post_event.id}.json", params: {
|
||||
post_event: {
|
||||
status: PostEvent.statuses[:public].to_s
|
||||
}
|
||||
}
|
||||
|
||||
post_event.reload
|
||||
|
||||
expect(post_event.status).to eq(PostEvent.statuses[:public])
|
||||
expect(post_event.raw_invitees).to eq([])
|
||||
expect(post_event.invitees.pluck(:user_id)).to eq([invitee1.id])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when status changes from public to private' do
|
||||
it 'changes the status, removes raw_invitees and keeps invitees' do
|
||||
post_event.update!(status: PostEvent.statuses[:public])
|
||||
post_event.create_invitees([
|
||||
{ user_id: invitee1.id },
|
||||
{ user_id: invitee2.id },
|
||||
])
|
||||
post_event.reload
|
||||
|
||||
expect(post_event.invitees.pluck(:user_id)).to match_array([invitee1.id, invitee2.id])
|
||||
expect(post_event.raw_invitees).to eq(nil)
|
||||
|
||||
put "/discourse-calendar/post-events/#{post_event.id}.json", params: {
|
||||
post_event: {
|
||||
status: PostEvent.statuses[:private].to_s,
|
||||
raw_invitees: [invitee1.username]
|
||||
}
|
||||
}
|
||||
|
||||
post_event.reload
|
||||
|
||||
expect(post_event.status).to eq(PostEvent.statuses[:private])
|
||||
expect(post_event.raw_invitees).to eq([invitee1.username])
|
||||
expect(post_event.invitees.pluck(:user_id)).to eq([invitee1.id])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when status changes from public to standalone' do
|
||||
it 'changes the status, removes invitees' do
|
||||
post_event.update!(
|
||||
status: PostEvent.statuses[:public]
|
||||
)
|
||||
post_event.create_invitees([ { user_id: invitee1.id } ])
|
||||
post_event.reload
|
||||
|
||||
expect(post_event.invitees.pluck(:user_id)).to eq([invitee1.id])
|
||||
expect(post_event.raw_invitees).to eq(nil)
|
||||
|
||||
put "/discourse-calendar/post-events/#{post_event.id}.json", params: {
|
||||
post_event: {
|
||||
status: PostEvent.statuses[:standalone].to_s
|
||||
}
|
||||
}
|
||||
|
||||
post_event.reload
|
||||
|
||||
expect(post_event.status).to eq(PostEvent.statuses[:standalone])
|
||||
expect(post_event.raw_invitees).to eq([])
|
||||
expect(post_event.invitees).to eq([])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'acting user has created the post_event' do
|
||||
it 'destroys a post_event' do
|
||||
expect(post_event.persisted?).to be(true)
|
||||
|
||||
messages = MessageBus.track_publish do
|
||||
delete "/discourse-calendar/post-events/#{post_event.id}.json"
|
||||
end
|
||||
expect(messages.count).to eq(1)
|
||||
message = messages.first
|
||||
expect(message.channel).to eq("/post-events/#{post_event.post.topic_id}")
|
||||
expect(message.data[:id]).to eq(post_event.id)
|
||||
expect(response.status).to eq(200)
|
||||
expect(PostEvent).to_not exist(id: post_event.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'acting user has not created the post_event' do
|
||||
fab!(:lurker) { Fabricate(:user) }
|
||||
|
||||
before do
|
||||
sign_in(lurker)
|
||||
end
|
||||
|
||||
it 'doesn’t destroy the post_event' do
|
||||
expect(post_event.persisted?).to be(true)
|
||||
delete "/discourse-calendar/post-events/#{post_event.id}.json"
|
||||
expect(response.status).to eq(403)
|
||||
expect(PostEvent).to exist(id: post_event.id)
|
||||
end
|
||||
|
||||
it 'doesn’t update the post_event' do
|
||||
put "/discourse-calendar/post-events/#{post_event.id}.json", params: {
|
||||
post_event: {
|
||||
status: PostEvent.statuses[:public],
|
||||
}
|
||||
}
|
||||
|
||||
expect(response.status).to eq(403)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Reference in New Issue