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: | en: | ||||||
|   js: |   js: | ||||||
|     discourse_calendar: |     discourse_calendar: | ||||||
|  |       invite_user_notification: "%{username} invited you to: %{description}" | ||||||
|       on_holiday: "On Holiday" |       on_holiday: "On Holiday" | ||||||
|       holiday: "Holiday" |       holiday: "Holiday" | ||||||
|       add_to_calendar: "Add to calendar" |       add_to_calendar: "Add to calendar" | ||||||
|  | @ -8,6 +9,51 @@ en: | ||||||
|         title: "Timezone" |         title: "Timezone" | ||||||
|         instructions: "Your current timezone is %{timezone}." |         instructions: "Your current timezone is %{timezone}." | ||||||
|         none: "Select a 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: |     group_timezones: | ||||||
|       search: "Search..." |       search: "Search..." | ||||||
|       group_availability: "%{group} availability" |       group_availability: "%{group} availability" | ||||||
|  |  | ||||||
|  | @ -1,6 +1,7 @@ | ||||||
| en: | en: | ||||||
|   site_settings: |   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." |     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." |     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." |     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" |     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." |     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." |     close_to_working_day_hours_extension: "Set extension time in working day hours to highlight the timezones." | ||||||
|   discourse_calendar: |   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." |     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_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." |     more_than_two_dates: "A post of a calendar topic can’t contain more than two dates." | ||||||
|     event_expired: "Event expired" |     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: |   calendar_enabled: | ||||||
|     default: false |     default: false | ||||||
|     client: true |     client: true | ||||||
|  |   events_enabled: | ||||||
|  |     default: true | ||||||
|  |     client: true | ||||||
|   holiday_calendar_topic_id: |   holiday_calendar_topic_id: | ||||||
|     default: "" |     default: "" | ||||||
|     client: true |     client: true | ||||||
|  | @ -40,3 +43,7 @@ plugins: | ||||||
|   close_to_working_day_hours_extension: |   close_to_working_day_hours_extension: | ||||||
|     default: 2 |     default: 2 | ||||||
|     client: true |     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 | # name: discourse-calendar | ||||||
| # about: Display a calendar in the first post of a topic | # about: Display a calendar in the first post of a topic | ||||||
| # version: 0.2 | # version: 0.2 | ||||||
| # author: Joffrey Jaffeux | # author: Daniel Waterworth, Joffrey Jaffeux | ||||||
| # url: https://github.com/discourse/discourse-calendar | # url: https://github.com/discourse/discourse-calendar | ||||||
| 
 | 
 | ||||||
| gem "holidays", "8.0.0", require: false | 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/vendor/fullcalendar.min.css" | ||||||
| register_asset "stylesheets/common/discourse-calendar.scss" | 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/mobile/discourse-calendar.scss", :mobile | ||||||
| register_asset "stylesheets/desktop/discourse-calendar.scss", :desktop | 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 | after_initialize do | ||||||
|  | 
 | ||||||
|   module ::DiscourseCalendar |   module ::DiscourseCalendar | ||||||
|     PLUGIN_NAME ||= "discourse-calendar" |     PLUGIN_NAME ||= "discourse-calendar" | ||||||
| 
 | 
 | ||||||
|  | @ -43,18 +48,32 @@ after_initialize do | ||||||
|     def self.users_on_holiday=(usernames) |     def self.users_on_holiday=(usernames) | ||||||
|       PluginStore.set(PLUGIN_NAME, USERS_ON_HOLIDAY_KEY, usernames) |       PluginStore.set(PLUGIN_NAME, USERS_ON_HOLIDAY_KEY, usernames) | ||||||
|     end |     end | ||||||
|  | 
 | ||||||
|  |     class Engine < ::Rails::Engine | ||||||
|  |       engine_name PLUGIN_NAME | ||||||
|  |       isolate_namespace DiscourseCalendar | ||||||
|  |     end | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   [ |   [ | ||||||
|     "../app/models/calendar_event.rb", |     "../app/models/calendar_event.rb", | ||||||
|  |     "../app/models/guardian.rb", | ||||||
|     "../app/serializers/user_timezone_serializer.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/create_holiday_events.rb", | ||||||
|     "../jobs/scheduled/destroy_past_events.rb", |     "../jobs/scheduled/destroy_past_events.rb", | ||||||
|     "../jobs/scheduled/update_holiday_usernames.rb", |     "../jobs/scheduled/update_holiday_usernames.rb", | ||||||
|     "../lib/calendar_validator.rb", |     "../lib/calendar_validator.rb", | ||||||
|     "../lib/calendar.rb", |     "../lib/calendar.rb", | ||||||
|     "../lib/event_validator.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__) } |   ].each { |path| load File.expand_path(path, __FILE__) } | ||||||
| 
 | 
 | ||||||
|   register_post_custom_field_type(DiscourseCalendar::CALENDAR_CUSTOM_FIELD, :string) |   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 |   add_to_serializer(:site, :include_users_on_holiday?) do | ||||||
|     scope.is_staff? |     scope.is_staff? | ||||||
|   end |   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 | 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