diff --git a/app/controllers/discourse_calendar/invitees_controller.rb b/app/controllers/discourse_calendar/invitees_controller.rb new file mode 100644 index 00000000..d6f1d2ba --- /dev/null +++ b/app/controllers/discourse_calendar/invitees_controller.rb @@ -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 diff --git a/app/controllers/discourse_calendar/post_events_controller.rb b/app/controllers/discourse_calendar/post_events_controller.rb new file mode 100644 index 00000000..52891cd1 --- /dev/null +++ b/app/controllers/discourse_calendar/post_events_controller.rb @@ -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 diff --git a/app/controllers/discourse_calendar/upcoming_events_controller.rb b/app/controllers/discourse_calendar/upcoming_events_controller.rb new file mode 100644 index 00000000..f2f15024 --- /dev/null +++ b/app/controllers/discourse_calendar/upcoming_events_controller.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module DiscourseCalendar + class UpcomingEventsController < ::ApplicationController + before_action :ensure_logged_in + + def index + end + end +end diff --git a/app/models/discourse_calendar/invitee.rb b/app/models/discourse_calendar/invitee.rb new file mode 100644 index 00000000..1f321ede --- /dev/null +++ b/app/models/discourse_calendar/invitee.rb @@ -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 diff --git a/app/models/discourse_calendar/post_event.rb b/app/models/discourse_calendar/post_event.rb new file mode 100644 index 00000000..98173524 --- /dev/null +++ b/app/models/discourse_calendar/post_event.rb @@ -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 diff --git a/app/models/guardian.rb b/app/models/guardian.rb new file mode 100644 index 00000000..38a368c0 --- /dev/null +++ b/app/models/guardian.rb @@ -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 diff --git a/app/serializers/discourse_calendar/invitee_serializer.rb b/app/serializers/discourse_calendar/invitee_serializer.rb new file mode 100644 index 00000000..62a28faf --- /dev/null +++ b/app/serializers/discourse_calendar/invitee_serializer.rb @@ -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 diff --git a/app/serializers/discourse_calendar/post_event_serializer.rb b/app/serializers/discourse_calendar/post_event_serializer.rb new file mode 100644 index 00000000..0beccc72 --- /dev/null +++ b/app/serializers/discourse_calendar/post_event_serializer.rb @@ -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 diff --git a/app/views/upcoming-events/index.hbs b/app/views/upcoming-events/index.hbs new file mode 100644 index 00000000..a789c9b5 --- /dev/null +++ b/app/views/upcoming-events/index.hbs @@ -0,0 +1 @@ +UPCOMING diff --git a/app/views/upcoming_events/index.hbs b/app/views/upcoming_events/index.hbs new file mode 100644 index 00000000..a789c9b5 --- /dev/null +++ b/app/views/upcoming_events/index.hbs @@ -0,0 +1 @@ +UPCOMING diff --git a/assets/javascripts/discourse/adapters/discourse-calendar-adapter.js.es6 b/assets/javascripts/discourse/adapters/discourse-calendar-adapter.js.es6 new file mode 100644 index 00000000..0a631f49 --- /dev/null +++ b/assets/javascripts/discourse/adapters/discourse-calendar-adapter.js.es6 @@ -0,0 +1,11 @@ +import RestAdapter from "discourse/adapters/rest"; + +export default RestAdapter.extend({ + basePath() { + return "/discourse-calendar/"; + }, + + pathFor() { + return this._super(...arguments).replace("_", "-"); + } +}); diff --git a/assets/javascripts/discourse/adapters/invitee.js.es6 b/assets/javascripts/discourse/adapters/invitee.js.es6 new file mode 100644 index 00000000..5333c991 --- /dev/null +++ b/assets/javascripts/discourse/adapters/invitee.js.es6 @@ -0,0 +1,3 @@ +import DiscourseCalendarAdapter from "./discourse-calendar-adapter"; + +export default DiscourseCalendarAdapter.extend(); diff --git a/assets/javascripts/discourse/adapters/post-event.js.es6 b/assets/javascripts/discourse/adapters/post-event.js.es6 new file mode 100644 index 00000000..5333c991 --- /dev/null +++ b/assets/javascripts/discourse/adapters/post-event.js.es6 @@ -0,0 +1,3 @@ +import DiscourseCalendarAdapter from "./discourse-calendar-adapter"; + +export default DiscourseCalendarAdapter.extend(); diff --git a/assets/javascripts/discourse/components/event-field.js.es6 b/assets/javascripts/discourse/components/event-field.js.es6 new file mode 100644 index 00000000..8cc86e65 --- /dev/null +++ b/assets/javascripts/discourse/components/event-field.js.es6 @@ -0,0 +1,6 @@ +import Component from "@ember/component"; + +export default Component.extend({ + enabled: true, + class: null +}); diff --git a/assets/javascripts/discourse/controllers/event-ui-builder.js.es6 b/assets/javascripts/discourse/controllers/event-ui-builder.js.es6 new file mode 100644 index 00000000..c0295ca1 --- /dev/null +++ b/assets/javascripts/discourse/controllers/event-ui-builder.js.es6 @@ -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")); + } +}); diff --git a/assets/javascripts/discourse/controllers/post-event-invitees.js.es6 b/assets/javascripts/discourse/controllers/post-event-invitees.js.es6 new file mode 100644 index 00000000..7ee57aad --- /dev/null +++ b/assets/javascripts/discourse/controllers/post-event-invitees.js.es6 @@ -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)); + } +}); diff --git a/assets/javascripts/discourse/controllers/upcoming-events-index.js.es6 b/assets/javascripts/discourse/controllers/upcoming-events-index.js.es6 new file mode 100644 index 00000000..c93fcb89 --- /dev/null +++ b/assets/javascripts/discourse/controllers/upcoming-events-index.js.es6 @@ -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); + }); + } +}); diff --git a/assets/javascripts/discourse/lib/google-calendar.js.es6 b/assets/javascripts/discourse/lib/google-calendar.js.es6 new file mode 100644 index 00000000..8ffad550 --- /dev/null +++ b/assets/javascripts/discourse/lib/google-calendar.js.es6 @@ -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, ""); + } +}); diff --git a/assets/javascripts/discourse/models/invitee.js.es6 b/assets/javascripts/discourse/models/invitee.js.es6 new file mode 100644 index 00000000..d087ac72 --- /dev/null +++ b/assets/javascripts/discourse/models/invitee.js.es6 @@ -0,0 +1,9 @@ +import RestModel from "discourse/models/rest"; + +export default RestModel.extend({ + init() { + this._super(...arguments); + + this.__type = "invitee"; + } +}); diff --git a/assets/javascripts/discourse/models/post-event.js.es6 b/assets/javascripts/discourse/models/post-event.js.es6 new file mode 100644 index 00000000..9e04f1df --- /dev/null +++ b/assets/javascripts/discourse/models/post-event.js.es6 @@ -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; diff --git a/assets/javascripts/discourse/routes/upcoming-events-index.js.es6 b/assets/javascripts/discourse/routes/upcoming-events-index.js.es6 new file mode 100644 index 00000000..60b04e9f --- /dev/null +++ b/assets/javascripts/discourse/routes/upcoming-events-index.js.es6 @@ -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); + } +}); diff --git a/assets/javascripts/discourse/routes/upcoming-events.js.es6 b/assets/javascripts/discourse/routes/upcoming-events.js.es6 new file mode 100644 index 00000000..f7f5da26 --- /dev/null +++ b/assets/javascripts/discourse/routes/upcoming-events.js.es6 @@ -0,0 +1,3 @@ +import Route from "@ember/routing/route"; + +export default Route.extend({}); diff --git a/assets/javascripts/discourse/templates/components/event-field.hbs b/assets/javascripts/discourse/templates/components/event-field.hbs new file mode 100644 index 00000000..7be8e11a --- /dev/null +++ b/assets/javascripts/discourse/templates/components/event-field.hbs @@ -0,0 +1,10 @@ +{{#if enabled}} +
+
+ {{i18n label}} +
+
+ {{yield}} +
+
+{{/if}} diff --git a/assets/javascripts/discourse/templates/modal/event-ui-builder.hbs b/assets/javascripts/discourse/templates/modal/event-ui-builder.hbs new file mode 100644 index 00000000..b39a1681 --- /dev/null +++ b/assets/javascripts/discourse/templates/modal/event-ui-builder.hbs @@ -0,0 +1,122 @@ +{{#d-modal-body + title=(concat "event.ui_builder." modalTitle) + class="event-ui-builder" +}} + {{#conditional-loading-section isLoading=model.isSaving}} +
+ {{#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"}} + + + + + {{/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|}} + + {{/each}} + {{/event-field}} + {{/if}} +
+ {{/conditional-loading-section}} +{{/d-modal-body}} + + + diff --git a/assets/javascripts/discourse/templates/modal/post-event-invitees.hbs b/assets/javascripts/discourse/templates/modal/post-event-invitees.hbs new file mode 100644 index 00000000..c303ee69 --- /dev/null +++ b/assets/javascripts/discourse/templates/modal/post-event-invitees.hbs @@ -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}} + + {{/conditional-loading-spinner}} +{{/d-modal-body}} diff --git a/assets/javascripts/discourse/templates/upcoming-events-index.hbs b/assets/javascripts/discourse/templates/upcoming-events-index.hbs new file mode 100644 index 00000000..a535a029 --- /dev/null +++ b/assets/javascripts/discourse/templates/upcoming-events-index.hbs @@ -0,0 +1,31 @@ + + + + + + + + + + + {{#each postEvents as |postEvent|}} + + + + + + + {{/each}} + +
idcreatorstatusstarts at
+ + {{format-post-event-name postEvent}} + + + {{avatar postEvent.creator imageSize="tiny"}} + {{format-username postEvent.creator.username}} + + {{postEvent.status}} + + {{format-future-date postEvent.starts_at}} +
diff --git a/assets/javascripts/discourse/templates/upcoming-events.hbs b/assets/javascripts/discourse/templates/upcoming-events.hbs new file mode 100644 index 00000000..c24cd689 --- /dev/null +++ b/assets/javascripts/discourse/templates/upcoming-events.hbs @@ -0,0 +1 @@ +{{outlet}} diff --git a/assets/javascripts/discourse/upcoming-events-route-map.js.es6 b/assets/javascripts/discourse/upcoming-events-route-map.js.es6 new file mode 100644 index 00000000..4c4cd652 --- /dev/null +++ b/assets/javascripts/discourse/upcoming-events-route-map.js.es6 @@ -0,0 +1,5 @@ +export default function() { + this.route("upcoming-events", { path: "/upcoming-events" }, function() { + this.route("index", { path: "/" }); + }); +} diff --git a/assets/javascripts/widgets/discourse-group-timezone-new-day.js.es6 b/assets/javascripts/discourse/widgets/discourse-group-timezone-new-day.js.es6 similarity index 100% rename from assets/javascripts/widgets/discourse-group-timezone-new-day.js.es6 rename to assets/javascripts/discourse/widgets/discourse-group-timezone-new-day.js.es6 diff --git a/assets/javascripts/widgets/discourse-group-timezone.js.es6 b/assets/javascripts/discourse/widgets/discourse-group-timezone.js.es6 similarity index 100% rename from assets/javascripts/widgets/discourse-group-timezone.js.es6 rename to assets/javascripts/discourse/widgets/discourse-group-timezone.js.es6 diff --git a/assets/javascripts/widgets/discourse-group-timezones-filter.js.es6 b/assets/javascripts/discourse/widgets/discourse-group-timezones-filter.js.es6 similarity index 100% rename from assets/javascripts/widgets/discourse-group-timezones-filter.js.es6 rename to assets/javascripts/discourse/widgets/discourse-group-timezones-filter.js.es6 diff --git a/assets/javascripts/widgets/discourse-group-timezones-header.js.es6 b/assets/javascripts/discourse/widgets/discourse-group-timezones-header.js.es6 similarity index 100% rename from assets/javascripts/widgets/discourse-group-timezones-header.js.es6 rename to assets/javascripts/discourse/widgets/discourse-group-timezones-header.js.es6 diff --git a/assets/javascripts/widgets/discourse-group-timezones-member.js.es6 b/assets/javascripts/discourse/widgets/discourse-group-timezones-member.js.es6 similarity index 100% rename from assets/javascripts/widgets/discourse-group-timezones-member.js.es6 rename to assets/javascripts/discourse/widgets/discourse-group-timezones-member.js.es6 diff --git a/assets/javascripts/widgets/discourse-group-timezones-reset.js.es6 b/assets/javascripts/discourse/widgets/discourse-group-timezones-reset.js.es6 similarity index 100% rename from assets/javascripts/widgets/discourse-group-timezones-reset.js.es6 rename to assets/javascripts/discourse/widgets/discourse-group-timezones-reset.js.es6 diff --git a/assets/javascripts/widgets/discourse-group-timezones-slider.js.es6 b/assets/javascripts/discourse/widgets/discourse-group-timezones-slider.js.es6 similarity index 100% rename from assets/javascripts/widgets/discourse-group-timezones-slider.js.es6 rename to assets/javascripts/discourse/widgets/discourse-group-timezones-slider.js.es6 diff --git a/assets/javascripts/widgets/discourse-group-timezones-time-traveler.js.es6 b/assets/javascripts/discourse/widgets/discourse-group-timezones-time-traveler.js.es6 similarity index 100% rename from assets/javascripts/widgets/discourse-group-timezones-time-traveler.js.es6 rename to assets/javascripts/discourse/widgets/discourse-group-timezones-time-traveler.js.es6 diff --git a/assets/javascripts/widgets/discourse-group-timezones.js.es6 b/assets/javascripts/discourse/widgets/discourse-group-timezones.js.es6 similarity index 100% rename from assets/javascripts/widgets/discourse-group-timezones.js.es6 rename to assets/javascripts/discourse/widgets/discourse-group-timezones.js.es6 diff --git a/assets/javascripts/discourse/widgets/post-event-dates.js.es6 b/assets/javascripts/discourse/widgets/post-event-dates.js.es6 new file mode 100644 index 00000000..b741d293 --- /dev/null +++ b/assets/javascripts/discourse/widgets/post-event-dates.js.es6 @@ -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"}} + {{{attrs.localDates}}} + ` +}); diff --git a/assets/javascripts/discourse/widgets/post-event-host.js.es6 b/assets/javascripts/discourse/widgets/post-event-host.js.es6 new file mode 100644 index 00000000..05cb3d30 --- /dev/null +++ b/assets/javascripts/discourse/widgets/post-event-host.js.es6 @@ -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) + ) + ] + ); + } +}); diff --git a/assets/javascripts/discourse/widgets/post-event-invitee.js.es6 b/assets/javascripts/discourse/widgets/post-event-invitee.js.es6 new file mode 100644 index 00000000..c224afd3 --- /dev/null +++ b/assets/javascripts/discourse/widgets/post-event-invitee.js.es6 @@ -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 + ); + } +}); diff --git a/assets/javascripts/discourse/widgets/post-event-invitees.js.es6 b/assets/javascripts/discourse/widgets/post-event-invitees.js.es6 new file mode 100644 index 00000000..96f6b162 --- /dev/null +++ b/assets/javascripts/discourse/widgets/post-event-invitees.js.es6 @@ -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` +
+
+ {{attrs.postEvent.stats.going}} Going - + {{attrs.postEvent.stats.interested}} Interested - + {{attrs.postEvent.stats.not_going}} Not going - + on {{attrs.postEvent.stats.invited}} users invited +
+ + {{#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}} +
+ + ` +}); diff --git a/assets/javascripts/discourse/widgets/post-event-status.js.es6 b/assets/javascripts/discourse/widgets/post-event-status.js.es6 new file mode 100644 index 00000000..8a1dfc37 --- /dev/null +++ b/assets/javascripts/discourse/widgets/post-event-status.js.es6 @@ -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 + ) + ); + } +}); diff --git a/assets/javascripts/discourse/widgets/post-event.js.es6 b/assets/javascripts/discourse/widgets/post-event.js.es6 new file mode 100644 index 00000000..26811752 --- /dev/null +++ b/assets/javascripts/discourse/widgets/post-event.js.es6 @@ -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}} +
+
+
{{transformed.startsAtMonth}}
+
{{transformed.startsAtDay}}
+
+
+
+ + {{transformed.statusIcon}} + {{transformed.postEventStatusLabel}} + + + {{transformed.postEventName}} + +
+ + Created by {{attach widget="post-event-creator" attrs=(hash user=state.postEvent.creator)}} + +
+ + {{#if state.postEvent.can_act_on_post_event}} +
+ {{attach + widget="button" + attrs=(hash + className="btn-small" + icon="pencil-alt" + action="editPostEvent" + actionParam=state.postEvent.id + ) + }} +
+ {{/if}} +
+ + {{#if state.postEvent.can_update_attendance}} +
+ {{attach + widget="post-event-status" + attrs=(hash + watchingInvitee=this.state.postEvent.watching_invitee + ) + }} +
+ {{/if}} + +
+ + {{attach widget="post-event-dates" attrs=(hash localDates=attrs.localDates postEvent=state.postEvent)}} + + {{#if state.postEvent.should_display_invitees}} +
+ {{attach widget="post-event-invitees" attrs=(hash postEvent=state.postEvent)}} + {{/if}} + + + {{/if}} + ` +}); diff --git a/assets/javascripts/helpers/format-future-date.js.es6 b/assets/javascripts/helpers/format-future-date.js.es6 new file mode 100644 index 00000000..ca73642f --- /dev/null +++ b/assets/javascripts/helpers/format-future-date.js.es6 @@ -0,0 +1,5 @@ +import { htmlHelper } from "discourse-common/lib/helpers"; + +export default htmlHelper(postEvent => { + return moment(postEvent.starts_at).format("LLL"); +}); diff --git a/assets/javascripts/helpers/format-post-event-name.js.es6 b/assets/javascripts/helpers/format-post-event-name.js.es6 new file mode 100644 index 00000000..332a4d5f --- /dev/null +++ b/assets/javascripts/helpers/format-post-event-name.js.es6 @@ -0,0 +1,5 @@ +import { htmlHelper } from "discourse-common/lib/helpers"; + +export default htmlHelper(postEvent => { + return postEvent.name || postEvent.post.topic.title; +}); diff --git a/assets/javascripts/initializers/add-event-ui-builder.js.es6 b/assets/javascripts/initializers/add-event-ui-builder.js.es6 new file mode 100644 index 00000000..c6f785a8 --- /dev/null +++ b/assets/javascripts/initializers/add-event-ui-builder.js.es6 @@ -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); + } +}; diff --git a/assets/javascripts/initializers/post-event-decorator.js.es6 b/assets/javascripts/initializers/post-event-decorator.js.es6 new file mode 100644 index 00000000..c92590e9 --- /dev/null +++ b/assets/javascripts/initializers/post-event-decorator.js.es6 @@ -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 = '
'; + 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); + } +}; diff --git a/assets/stylesheets/common/post-event.scss b/assets/stylesheets/common/post-event.scss new file mode 100644 index 00000000..989a7e1e --- /dev/null +++ b/assets/stylesheets/common/post-event.scss @@ -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; + } + } +} diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index ea8a3079..e6267848 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -1,6 +1,7 @@ en: js: discourse_calendar: + invite_user_notification: "%{username} invited you to: %{description}" on_holiday: "On Holiday" holiday: "Holiday" add_to_calendar: "Add to calendar" @@ -8,6 +9,51 @@ en: title: "Timezone" instructions: "Your current timezone is %{timezone}." none: "Select a timezone..." + event: + display_invitees: + everyone: "To everyone" + invitees_only: "To invited users only" + none: "Do not display invited users" + invitee_status: + unknown: "Undecided?" + going: "✓ Going" + not_going: "× Not Going" + interested: "? Interested" + post_event_status: + standalone: + title: Standalone + description: "A standalone event can't be joined." + public: + title: Public + description: "A public event can be joined by anyone." + private: + title: Private + description: "A private event can only be joined by invited users." + post_ui: + show_all: show all + add_to_calendar: add to calendar + send_pm_to_creator: contact + post-event-invitees-modal: + title: "List of invited users" + filter_placeholder: "Filter invited users" + ui_builder: + create_event_title: Create Event + update_event_title: Update Event + confirm_delete: Are you sure you want to delete this event? + create: Create + update: Save + attach: Create event + name: + label: Event name + placeholder: Optional, defaults to topic title + invitees: + label: Invited users/groups + status: + label: Status + display_invitees: + label: Display invited users + upcoming_events: + title: Upcoming events group_timezones: search: "Search..." group_availability: "%{group} availability" diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 59c3d3a4..61f75adf 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -1,6 +1,7 @@ en: site_settings: calendar_enabled: "Enable the discourse-calendar plugin. This will add support for a [calendar][/calendar] tag in the first post of a topic." + events_enabled: "Enables users to create events on a topic." holiday_calendar_topic_id: "Topic ID of staffs holiday / absence calendar." delete_expired_event_posts_after: "Posts with expired events will be automatically deleted after (n) hours. Set to -1 to disable deletion." all_day_event_start_time: "Events that do not have a start time specified will start at this time. Format is HH:mm. For 6:00 am, enter 06:00" @@ -13,7 +14,12 @@ en: working_day_end_hour: "End time of the working day hours." close_to_working_day_hours_extension: "Set extension time in working day hours to highlight the timezones." discourse_calendar: + invite_user_notification: "%{username} invited you to: %{description}" calendar_must_be_in_first_post: "Calendar tag can only be used in first post of a topic." more_than_one_calendar: "You can’t have more than one calendar in a post." more_than_two_dates: "A post of a calendar topic can’t contain more than two dates." event_expired: "Event expired" + post_event: + errors: + raw_invitees_length: "An event is limited to %{count} users/groups" + ends_at_before_starts_at: "An event can't end before it starts" diff --git a/config/settings.yml b/config/settings.yml index ae2ac0e0..dfa1b6ef 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -2,6 +2,9 @@ plugins: calendar_enabled: default: false client: true + events_enabled: + default: true + client: true holiday_calendar_topic_id: default: "" client: true @@ -40,3 +43,7 @@ plugins: close_to_working_day_hours_extension: default: 2 client: true + displayed_invitees_limit: + default: 10 + client: false + max: 25 diff --git a/db/migrate/20201303000001_create_post_events_table.rb b/db/migrate/20201303000001_create_post_events_table.rb new file mode 100644 index 00000000..1f3c758c --- /dev/null +++ b/db/migrate/20201303000001_create_post_events_table.rb @@ -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 diff --git a/db/migrate/20201303000002_create_invitees_table.rb b/db/migrate/20201303000002_create_invitees_table.rb new file mode 100644 index 00000000..cb3432be --- /dev/null +++ b/db/migrate/20201303000002_create_invitees_table.rb @@ -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 diff --git a/lib/time_sniffer.rb b/lib/time_sniffer.rb new file mode 100644 index 00000000..75e98da9 --- /dev/null +++ b/lib/time_sniffer.rb @@ -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 diff --git a/plugin.rb b/plugin.rb index 8f36ded0..5073d980 100644 --- a/plugin.rb +++ b/plugin.rb @@ -3,7 +3,7 @@ # name: discourse-calendar # about: Display a calendar in the first post of a topic # version: 0.2 -# author: Joffrey Jaffeux +# author: Daniel Waterworth, Joffrey Jaffeux # url: https://github.com/discourse/discourse-calendar gem "holidays", "8.0.0", require: false @@ -14,10 +14,15 @@ enabled_site_setting :calendar_enabled register_asset "stylesheets/vendor/fullcalendar.min.css" register_asset "stylesheets/common/discourse-calendar.scss" +register_asset "stylesheets/common/post-event.scss" register_asset "stylesheets/mobile/discourse-calendar.scss", :mobile register_asset "stylesheets/desktop/discourse-calendar.scss", :desktop +register_svg_icon "fas fa-calendar-day" +register_svg_icon "fas fa-question" +register_svg_icon "fas fa-clock" after_initialize do + module ::DiscourseCalendar PLUGIN_NAME ||= "discourse-calendar" @@ -43,18 +48,32 @@ after_initialize do def self.users_on_holiday=(usernames) PluginStore.set(PLUGIN_NAME, USERS_ON_HOLIDAY_KEY, usernames) end + + class Engine < ::Rails::Engine + engine_name PLUGIN_NAME + isolate_namespace DiscourseCalendar + end end [ "../app/models/calendar_event.rb", + "../app/models/guardian.rb", "../app/serializers/user_timezone_serializer.rb", + "../app/controllers/discourse_calendar/invitees_controller.rb", + "../app/controllers/discourse_calendar/post_events_controller.rb", + "../app/controllers/discourse_calendar/upcoming_events_controller.rb", + "../app/models/discourse_calendar/post_event.rb", + "../app/models/discourse_calendar/invitee.rb", + "../app/serializers/discourse_calendar/invitee_serializer.rb", + "../app/serializers/discourse_calendar/post_event_serializer.rb", "../jobs/scheduled/create_holiday_events.rb", "../jobs/scheduled/destroy_past_events.rb", "../jobs/scheduled/update_holiday_usernames.rb", "../lib/calendar_validator.rb", "../lib/calendar.rb", "../lib/event_validator.rb", - "../lib/group_timezones.rb" + "../lib/group_timezones.rb", + "../lib/time_sniffer.rb", ].each { |path| load File.expand_path(path, __FILE__) } register_post_custom_field_type(DiscourseCalendar::CALENDAR_CUSTOM_FIELD, :string) @@ -198,4 +217,102 @@ after_initialize do add_to_serializer(:site, :include_users_on_holiday?) do scope.is_staff? end + + require 'post' + class ::Post + has_one :post_event, + dependent: :destroy, + class_name: 'DiscourseCalendar::PostEvent', + foreign_key: :id + end + + add_to_serializer(:post, :post_event) do + DiscourseCalendar::PostEventSerializer.new(object.post_event, scope: scope, root: false) + end + + reloadable_patch do |plugin| + add_to_serializer(:post, :include_post_event?) do + plugin.enabled? + end + end + + Discourse::Application.routes.append do + mount ::DiscourseCalendar::Engine, at: '/' + end + + DiscourseCalendar::Engine.routes.draw do + get '/discourse-calendar/post-events/:id' => 'post_events#show' + delete '/discourse-calendar/post-events/:id' => 'post_events#destroy' + get '/discourse-calendar/post-events' => 'post_events#index' + post '/discourse-calendar/post-events' => 'post_events#create' + put '/discourse-calendar/post-events/:id' => 'post_events#update' + put '/discourse-calendar/invitees/:id' => 'invitees#update' + post '/discourse-calendar/invitees' => 'invitees#create' + get '/discourse-calendar/invitees' => 'invitees#index' + get '/upcoming-events' => 'upcoming_events#index' + end + + DiscourseEvent.on(:post_destroyed) do |post| + if post.post_event + post.post_event.update!(deleted_at: Time.now) + end + end + + DiscourseEvent.on(:post_recovered) do |post| + if post.post_event + post.post_event.update!(deleted_at: nil) + end + end + + DiscourseEvent.on(:post_edited) do |post, topic_changed| + if post.post_event && post.is_first_post? && post.topic && topic_changed && post.topic != Archetype.private_message + time_range = extract_time_range(post.topic, post.user) + + case time_range + when TimeSniffer::Interval + post.post_event.update!( + starts_at: time_range.from.to_time.utc, + ends_at: time_range.to.to_time.utc, + ) + when TimeSniffer::Event + post.post_event.update!( + starts_at: time_range.at.to_time.utc + ) + end + + post.post_event.publish_update! + end + end + + def extract_time_range(topic, user) + TimeSniffer.new( + topic.title, + at: topic.created_at, + timezone: user.user_option.timezone || 'UTC', + date_order: :sane, + matchers: [:tomorrow, :date, :time], + ).sniff + end + + DiscourseEvent.on(:topic_created) do |topic, args, user| + if topic.archetype != Archetype.private_message + time_range = extract_time_range(topic, user) + + case time_range + when TimeSniffer::Interval + DiscourseCalendar::PostEvent.create!( + id: topic.first_post.id, + starts_at: time_range.from.to_time.utc, + ends_at: time_range.to.to_time.utc, + status: DiscourseCalendar::PostEvent.statuses[:standalone] + ) + when TimeSniffer::Event + DiscourseCalendar::PostEvent.create!( + id: topic.first_post.id, + starts_at: time_range.at.to_time.utc, + status: DiscourseCalendar::PostEvent.statuses[:standalone] + ) + end + end + end end diff --git a/spec/acceptance/post_spec.rb b/spec/acceptance/post_spec.rb new file mode 100644 index 00000000..7473c285 --- /dev/null +++ b/spec/acceptance/post_spec.rb @@ -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 diff --git a/spec/acceptance/topic_spec.rb b/spec/acceptance/topic_spec.rb new file mode 100644 index 00000000..9eb4c3f5 --- /dev/null +++ b/spec/acceptance/topic_spec.rb @@ -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 diff --git a/spec/fabricators/post_event_fabricator.rb b/spec/fabricators/post_event_fabricator.rb new file mode 100644 index 00000000..637f8f05 --- /dev/null +++ b/spec/fabricators/post_event_fabricator.rb @@ -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 diff --git a/spec/lib/time_sniffer_spec.rb b/spec/lib/time_sniffer_spec.rb new file mode 100644 index 00000000..1f81a0f4 --- /dev/null +++ b/spec/lib/time_sniffer_spec.rb @@ -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 diff --git a/spec/requests/invitees_controller_spec.rb b/spec/requests/invitees_controller_spec.rb new file mode 100644 index 00000000..82a0fa61 --- /dev/null +++ b/spec/requests/invitees_controller_spec.rb @@ -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 diff --git a/spec/requests/post_events_controller_spec.rb b/spec/requests/post_events_controller_spec.rb new file mode 100644 index 00000000..9726cf32 --- /dev/null +++ b/spec/requests/post_events_controller_spec.rb @@ -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