diff --git a/README.md b/README.md index fb130573..c4f37c59 100644 --- a/README.md +++ b/README.md @@ -8,5 +8,13 @@ Topic discussing the plugin itself can be found here: [https://meta.discourse.or #### Plugins +##### Events + +- `discourse_post_event_event_will_start` this DiscourseEvent will be triggered one hour before an event starts - `discourse_post_event_event_started` this DiscourseEvent will be triggered when an event starts - `discourse_post_event_event_ended` this DiscourseEvent will be triggered when an event ends + +#### Custom Fields + +Custom fields can be set in plugin settings. Once added a new form will appear on event UI. +These custom fields are available when a plugin event is triggered. diff --git a/app/controllers/discourse_post_event/events_controller.rb b/app/controllers/discourse_post_event/events_controller.rb index 9c652cc8..ef2e90e8 100644 --- a/app/controllers/discourse_post_event/events_controller.rb +++ b/app/controllers/discourse_post_event/events_controller.rb @@ -113,6 +113,8 @@ module DiscoursePostEvent private def event_params + allowed_custom_fields = SiteSetting.discourse_post_event_allowed_custom_fields.split('|') + params .require(:event) .permit( @@ -122,6 +124,7 @@ module DiscoursePostEvent :ends_at, :status, :url, + custom_fields: allowed_custom_fields, raw_invitees: [] ) end diff --git a/app/models/discourse_post_event/event.rb b/app/models/discourse_post_event/event.rb index f9613f4c..3c2aac7f 100644 --- a/app/models/discourse_post_event/event.rb +++ b/app/models/discourse_post_event/event.rb @@ -38,10 +38,15 @@ module DiscoursePostEvent def setup_starts_at_handler if !transaction_include_any_action?([:create]) Jobs.cancel_scheduled_job(:discourse_post_event_event_started, event_id: self.id) + Jobs.cancel_scheduled_job(:discourse_post_event_event_will_start, event_id: self.id) end if self.starts_at > Time.now Jobs.enqueue_at(self.starts_at, :discourse_post_event_event_started, event_id: self.id) + + if self.starts_at - 1.hour > Time.now + Jobs.enqueue_at(self.starts_at - 1.hour, :discourse_post_event_event_will_start, event_id: self.id) + end end end @@ -102,6 +107,16 @@ module DiscoursePostEvent end end + validate :allowed_custom_fields + def allowed_custom_fields + allowed_custom_fields = SiteSetting.discourse_post_event_allowed_custom_fields.split('|') + self.custom_fields.each do |key, value| + if !allowed_custom_fields.include?(key) + errors.add(:base, I18n.t("discourse_post_event.errors.models.event.custom_field_is_invalid", field: key)) + end + end + end + def create_invitees(attrs) timestamp = Time.now attrs.map! do |attr| @@ -219,6 +234,8 @@ module DiscoursePostEvent end def update_with_params!(params) + params[:custom_fields] = (params[:custom_fields] || {}).reject { |_, value| value.blank? } + case params[:status].to_i when Event.statuses[:private] raw_invitees = Array(params[:raw_invitees]) diff --git a/app/serializers/discourse_post_event/event_serializer.rb b/app/serializers/discourse_post_event/event_serializer.rb index 3dd92537..531e42a3 100644 --- a/app/serializers/discourse_post_event/event_serializer.rb +++ b/app/serializers/discourse_post_event/event_serializer.rb @@ -18,6 +18,7 @@ module DiscoursePostEvent attributes :is_expired attributes :should_display_invitees attributes :url + attributes :custom_fields def can_act_on_discourse_post_event scope.can_act_on_discourse_post_event?(object) diff --git a/assets/javascripts/discourse/controllers/discourse-post-event-builder.js.es6 b/assets/javascripts/discourse/controllers/discourse-post-event-builder.js.es6 index 7351f9df..12d968db 100644 --- a/assets/javascripts/discourse/controllers/discourse-post-event-builder.js.es6 +++ b/assets/javascripts/discourse/controllers/discourse-post-event-builder.js.es6 @@ -1,3 +1,4 @@ +import { set } from "@ember/object"; import TextLib from "discourse/lib/text"; import Group from "discourse/models/group"; import ModalFunctionality from "discourse/mixins/modal-functionality"; @@ -5,8 +6,14 @@ import Controller from "@ember/controller"; import { action, computed } from "@ember/object"; import { equal } from "@ember/object/computed"; import { extractError } from "discourse/lib/ajax-error"; +import { Promise } from "rsvp"; export default Controller.extend(ModalFunctionality, { + init() { + this._super(...arguments); + this._dirtyCustomFields = false; + }, + modalTitle: computed("model.eventModel.isNew", { get() { return this.model.eventModel.isNew @@ -15,12 +22,28 @@ export default Controller.extend(ModalFunctionality, { } }), + allowedCustomFields: computed( + "siteSettings.discourse_post_event_allowed_custom_fields", + function() { + return this.siteSettings.discourse_post_event_allowed_custom_fields + .split("|") + .filter(Boolean); + } + ), + groupFinder(term) { return Group.findAll({ term, ignore_automatic: true }); }, allowsInvitees: equal("model.eventModel.status", "private"), + @action + onChangeCustomField(field, event) { + this._dirtyCustomFields = true; + const value = event.target.value; + set(this.model.eventModel.custom_fields, field, value); + }, + @action setRawInvitees(_, newInvitees) { this.set("model.eventModel.raw_invitees", newInvitees); @@ -106,25 +129,45 @@ export default Controller.extend(ModalFunctionality, { @action updateEvent() { - const eventParams = this._buildEventParams(); return this.store.find("post", this.model.eventModel.id).then(post => { - const raw = post.raw; - const newRaw = this._replaceRawEvent(eventParams, raw); - - if (newRaw) { - const props = { - raw: newRaw, - edit_reason: I18n.t("discourse_post_event.edit_reason") - }; - - return TextLib.cookAsync(newRaw).then(cooked => { - props.cooked = cooked.string; - return post - .save(props) - .catch(e => this.flash(extractError(e), "error")) - .then(result => result && this.send("closeModal")); - }); + const promises = []; + if (this._dirtyCustomFields) { + // custom_fields are not stored on the raw and are updated separately + const customFields = this.model.eventModel.getProperties( + "custom_fields" + ); + promises.push( + this.model.eventModel + .update(customFields) + .finally(() => (this._dirtyCustomFields = false)) + ); } + + const updateRawPromise = new Promise(resolve => { + const raw = post.raw; + const eventParams = this._buildEventParams(); + const newRaw = this._replaceRawEvent(eventParams, raw); + + if (newRaw) { + const props = { + raw: newRaw, + edit_reason: I18n.t("discourse_post_event.edit_reason") + }; + + return TextLib.cookAsync(newRaw).then(cooked => { + props.cooked = cooked.string; + return post + .save(props) + .catch(e => this.flash(extractError(e), "error")) + .then(result => result && this.send("closeModal")) + .finally(resolve); + }); + } else { + resolve(); + } + }); + + return Promise.all(promises.concat(updateRawPromise)); }); }, diff --git a/assets/javascripts/discourse/templates/modal/discourse-post-event-builder.hbs b/assets/javascripts/discourse/templates/modal/discourse-post-event-builder.hbs index 80af407b..cfb8cd7e 100644 --- a/assets/javascripts/discourse/templates/modal/discourse-post-event-builder.hbs +++ b/assets/javascripts/discourse/templates/modal/discourse-post-event-builder.hbs @@ -76,6 +76,21 @@ placeholderKey="topic.invite_private.group_name" }} {{/event-field}} + + {{#if allowedCustomFields.length}} + {{#event-field label="discourse_post_event.builder_modal.custom_fields.label"}} +

{{i18n "discourse_post_event.builder_modal.custom_fields.description"}}

+ {{#each allowedCustomFields as |allowedCustomField|}} + {{allowedCustomField}} + {{input + class="custom-field-input" + value=(readonly (get model.eventModel.custom_fields allowedCustomField)) + placeholderKey="discourse_post_event.builder_modal.name.placeholder" + input=(action "onChangeCustomField" allowedCustomField) + }} + {{/each}} + {{/event-field}} + {{/if}} {{/conditional-loading-section}} {{/d-modal-body}} diff --git a/assets/stylesheets/common/discourse-post-event-builder.scss b/assets/stylesheets/common/discourse-post-event-builder.scss index 05e0f9e7..8f27adf9 100644 --- a/assets/stylesheets/common/discourse-post-event-builder.scss +++ b/assets/stylesheets/common/discourse-post-event-builder.scss @@ -88,6 +88,15 @@ flex: 1; flex-direction: column; + .custom-field-label { + font-weight: 500; + margin-bottom: 0.5em; + } + + .custom-field-input { + width: 100%; + } + .radio-label { display: flex; align-items: center; diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 91a660ed..4b80c78c 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -320,6 +320,9 @@ en:
username or group name,desired attendance (going, not_going, interested, unknown)
download_sample_csv: Download a sample CSV file builder_modal: + custom_fields: + label: Custom Fields + description: Allowed custom fields are defined in site settings. Custom fields are used to transmit data to other plugins. create_event_title: Create Event update_event_title: Edit Event confirm_delete: Are you sure you want to delete this event? diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index fcb59f27..a5c00c9c 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -22,6 +22,7 @@ en: displayed_invitees_limit: "Limits the numbers of invitees displayed on an event." display_post_event_date_on_topic_title: "Displays the date of the event after the topic title." discourse_post_event_allowed_on_groups: "Groups that are allowed to create events." + discourse_post_event_allowed_custom_fields: "Allows to let each each event to set the value of custom fields." 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" @@ -54,5 +55,6 @@ en: end_must_be_a_valid_date: "End date must be a valid date." acting_user_not_allowed_to_create_event: "Current user is not allowed to create events." acting_user_not_allowed_to_act_on_this_event: "Current user is not allowed to act on this event." + custom_field_is_invalid: "The custom field `%{field}` is not allowed." name: length: "Event name length must be between %{minimum} and %{maximum} characters." diff --git a/config/settings.yml b/config/settings.yml index c3b10cb0..82a5cbd1 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -62,3 +62,7 @@ discourse_post_event: discourse_post_event_max_bulk_invitees: default: 500 hidden: true + discourse_post_event_allowed_custom_fields: + type: list + client: true + default: "" diff --git a/db/migrate/20200805133257_add_custom_fields_to_event.rb b/db/migrate/20200805133257_add_custom_fields_to_event.rb new file mode 100644 index 00000000..2d9eaecf --- /dev/null +++ b/db/migrate/20200805133257_add_custom_fields_to_event.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class AddCustomFieldsToEvent < ActiveRecord::Migration[6.0] + def up + add_column :discourse_post_event_events, :custom_fields, :jsonb, null: false, default: {} + end + + def down + remove_column :discourse_post_event_events, :custom_fields + end +end diff --git a/jobs/regular/discourse_post_event/event_will_start.rb b/jobs/regular/discourse_post_event/event_will_start.rb new file mode 100644 index 00000000..362c4cdc --- /dev/null +++ b/jobs/regular/discourse_post_event/event_will_start.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Jobs + class DiscoursePostEventEventWillStart < ::Jobs::Base + sidekiq_options retry: false + + def execute(args) + raise Discourse::InvalidParameters.new(:event_id) if args[:event_id].blank? + event = DiscoursePostEvent::Event.find(args[:event_id]) + DiscourseEvent.trigger(:discourse_post_event_event_will_start, event) + end + end +end diff --git a/plugin.rb b/plugin.rb index f8a44e5d..29615da7 100644 --- a/plugin.rb +++ b/plugin.rb @@ -88,6 +88,7 @@ after_initialize do "../lib/discourse_post_event/event_parser.rb", "../lib/discourse_post_event/event_validator.rb", "../jobs/regular/discourse_post_event/bulk_invite.rb", + "../jobs/regular/discourse_post_event/event_will_start.rb", "../jobs/regular/discourse_post_event/event_started.rb", "../jobs/regular/discourse_post_event/event_ended.rb", "../lib/discourse_post_event/event_finder.rb", @@ -517,5 +518,22 @@ after_initialize do ids end end + + on(:site_setting_changed) do |name, old_val, new_val| + next if name != :discourse_post_event_allowed_custom_fields + + previous_fields = old_val.split('|') + new_fields = new_val.split('|') + removed_fields = previous_fields - new_fields + + next if removed_fields.empty? + + DiscoursePostEvent::Event.all.find_each do |event| + removed_fields.each do |field| + event.custom_fields.delete(field) + end + event.save + end + end end end diff --git a/spec/acceptance/allowed_custom_fields_setting.rb b/spec/acceptance/allowed_custom_fields_setting.rb new file mode 100644 index 00000000..3a2f2dff --- /dev/null +++ b/spec/acceptance/allowed_custom_fields_setting.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require 'rails_helper' + +require_relative '../fabricators/event_fabricator' + +describe 'discourse_post_event_allowed_custom_fields' do + let(:user_1) { Fabricate(:user, admin: true) } + let(:topic_1) { Fabricate(:topic, user: user_1) } + let(:post_1) { Fabricate(:post, topic: topic_1) } + let(:post_event_1) { Fabricate(:event, post: post_1) } + + before do + SiteSetting.discourse_post_event_allowed_custom_fields = 'foo|bar' + post_event_1.update!(custom_fields: {}) + + SiteSetting.calendar_enabled = true + SiteSetting.discourse_post_event_enabled = true + end + + it 'removes the key on the custom fields when removing a key from site setting' do + post_event_1.update!(custom_fields: { foo: 1, bar: 2 }) + + expect(post_event_1.custom_fields['foo']).to eq(1) + expect(post_event_1.custom_fields['bar']).to eq(2) + + DiscourseEvent.trigger(:site_setting_changed, :discourse_post_event_allowed_custom_fields, 'foo|bar', 'foo') + + post_event_1.reload + + expect(post_event_1.custom_fields['foo']).to eq(1) + expect(post_event_1.custom_fields['bar']).to eq(nil) + end + + it 'doesn’t set a not allowed key from site setting' do + expect { + post_event_1.update!(custom_fields: { baz: 3 }) + }.to raise_error(ActiveRecord::RecordInvalid) + end +end diff --git a/spec/models/discourse_post_event/event_spec.rb b/spec/models/discourse_post_event/event_spec.rb index bc5fff19..4cb09a47 100644 --- a/spec/models/discourse_post_event/event_spec.rb +++ b/spec/models/discourse_post_event/event_spec.rb @@ -69,7 +69,8 @@ describe DiscoursePostEvent::Event do expect { Event.create!(id: first_post.id, starts_at: Time.now - 1.day) }.to change { - Jobs::DiscoursePostEventEventStarted.jobs.count + Jobs::DiscoursePostEventEventStarted.jobs.count + + Jobs::DiscoursePostEventEventWillStart.jobs.count }.by(0) end end @@ -86,13 +87,20 @@ describe DiscoursePostEvent::Event do .with(:discourse_post_event_event_started, event_id: first_post.id) .once + Jobs + .expects(:cancel_scheduled_job) + .with(:discourse_post_event_event_will_start, event_id: first_post.id) + .once + Event.create!(id: first_post.id, starts_at: starts_at) expect(Jobs::DiscoursePostEventEventStarted.jobs.count).to eq(1) + expect(Jobs::DiscoursePostEventEventWillStart.jobs.count).to eq(0) Event.find(first_post.id).update!(starts_at: Time.now + 2.hours) expect(Jobs::DiscoursePostEventEventStarted.jobs.count).to eq(2) + expect(Jobs::DiscoursePostEventEventWillStart.jobs.count).to eq(1) end end end @@ -130,6 +138,11 @@ describe DiscoursePostEvent::Event do .with(:discourse_post_event_event_started, event_id: first_post.id) .once + Jobs + .expects(:cancel_scheduled_job) + .with(:discourse_post_event_event_will_start, event_id: first_post.id) + .once + Event.create!(id: first_post.id, starts_at: Time.now - 1.day, ends_at: Time.now + 12.hours) expect(Jobs::DiscoursePostEventEventEnded.jobs.count).to eq(1)