FEATURE: adds support for custom fields on event

Custom fields are create in the site settings of the event plugin. Once at least one custom field is created, a new form will appear in each event UI. These custom fields are passed when DIscourseEvent triggers of the plugin are called, allowing you to pass custom data of the even to other plugins.
This commit is contained in:
jjaffeux 2020-08-07 17:50:15 +02:00
parent bf31bb359c
commit 6754bdfc8b
15 changed files with 218 additions and 18 deletions

View File

@ -8,5 +8,13 @@ Topic discussing the plugin itself can be found here: [https://meta.discourse.or
#### Plugins #### 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_started` this DiscourseEvent will be triggered when an event starts
- `discourse_post_event_event_ended` this DiscourseEvent will be triggered when an event ends - `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.

View File

@ -113,6 +113,8 @@ module DiscoursePostEvent
private private
def event_params def event_params
allowed_custom_fields = SiteSetting.discourse_post_event_allowed_custom_fields.split('|')
params params
.require(:event) .require(:event)
.permit( .permit(
@ -122,6 +124,7 @@ module DiscoursePostEvent
:ends_at, :ends_at,
:status, :status,
:url, :url,
custom_fields: allowed_custom_fields,
raw_invitees: [] raw_invitees: []
) )
end end

View File

@ -38,10 +38,15 @@ module DiscoursePostEvent
def setup_starts_at_handler def setup_starts_at_handler
if !transaction_include_any_action?([:create]) 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_started, event_id: self.id)
Jobs.cancel_scheduled_job(:discourse_post_event_event_will_start, event_id: self.id)
end end
if self.starts_at > Time.now if self.starts_at > Time.now
Jobs.enqueue_at(self.starts_at, :discourse_post_event_event_started, event_id: self.id) 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
end end
@ -102,6 +107,16 @@ module DiscoursePostEvent
end end
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) def create_invitees(attrs)
timestamp = Time.now timestamp = Time.now
attrs.map! do |attr| attrs.map! do |attr|
@ -219,6 +234,8 @@ module DiscoursePostEvent
end end
def update_with_params!(params) def update_with_params!(params)
params[:custom_fields] = (params[:custom_fields] || {}).reject { |_, value| value.blank? }
case params[:status].to_i case params[:status].to_i
when Event.statuses[:private] when Event.statuses[:private]
raw_invitees = Array(params[:raw_invitees]) raw_invitees = Array(params[:raw_invitees])

View File

@ -18,6 +18,7 @@ module DiscoursePostEvent
attributes :is_expired attributes :is_expired
attributes :should_display_invitees attributes :should_display_invitees
attributes :url attributes :url
attributes :custom_fields
def can_act_on_discourse_post_event def can_act_on_discourse_post_event
scope.can_act_on_discourse_post_event?(object) scope.can_act_on_discourse_post_event?(object)

View File

@ -1,3 +1,4 @@
import { set } from "@ember/object";
import TextLib from "discourse/lib/text"; import TextLib from "discourse/lib/text";
import Group from "discourse/models/group"; import Group from "discourse/models/group";
import ModalFunctionality from "discourse/mixins/modal-functionality"; import ModalFunctionality from "discourse/mixins/modal-functionality";
@ -5,8 +6,14 @@ import Controller from "@ember/controller";
import { action, computed } from "@ember/object"; import { action, computed } from "@ember/object";
import { equal } from "@ember/object/computed"; import { equal } from "@ember/object/computed";
import { extractError } from "discourse/lib/ajax-error"; import { extractError } from "discourse/lib/ajax-error";
import { Promise } from "rsvp";
export default Controller.extend(ModalFunctionality, { export default Controller.extend(ModalFunctionality, {
init() {
this._super(...arguments);
this._dirtyCustomFields = false;
},
modalTitle: computed("model.eventModel.isNew", { modalTitle: computed("model.eventModel.isNew", {
get() { get() {
return this.model.eventModel.isNew 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) { groupFinder(term) {
return Group.findAll({ term, ignore_automatic: true }); return Group.findAll({ term, ignore_automatic: true });
}, },
allowsInvitees: equal("model.eventModel.status", "private"), 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 @action
setRawInvitees(_, newInvitees) { setRawInvitees(_, newInvitees) {
this.set("model.eventModel.raw_invitees", newInvitees); this.set("model.eventModel.raw_invitees", newInvitees);
@ -106,9 +129,23 @@ export default Controller.extend(ModalFunctionality, {
@action @action
updateEvent() { updateEvent() {
const eventParams = this._buildEventParams();
return this.store.find("post", this.model.eventModel.id).then(post => { return this.store.find("post", this.model.eventModel.id).then(post => {
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 raw = post.raw;
const eventParams = this._buildEventParams();
const newRaw = this._replaceRawEvent(eventParams, raw); const newRaw = this._replaceRawEvent(eventParams, raw);
if (newRaw) { if (newRaw) {
@ -122,10 +159,16 @@ export default Controller.extend(ModalFunctionality, {
return post return post
.save(props) .save(props)
.catch(e => this.flash(extractError(e), "error")) .catch(e => this.flash(extractError(e), "error"))
.then(result => result && this.send("closeModal")); .then(result => result && this.send("closeModal"))
.finally(resolve);
}); });
} else {
resolve();
} }
}); });
return Promise.all(promises.concat(updateRawPromise));
});
}, },
_buildEventParams() { _buildEventParams() {

View File

@ -76,6 +76,21 @@
placeholderKey="topic.invite_private.group_name" placeholderKey="topic.invite_private.group_name"
}} }}
{{/event-field}} {{/event-field}}
{{#if allowedCustomFields.length}}
{{#event-field label="discourse_post_event.builder_modal.custom_fields.label"}}
<p>{{i18n "discourse_post_event.builder_modal.custom_fields.description"}}</p>
{{#each allowedCustomFields as |allowedCustomField|}}
<span class="label custom-field-label">{{allowedCustomField}}</span>
{{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}}
</form> </form>
{{/conditional-loading-section}} {{/conditional-loading-section}}
{{/d-modal-body}} {{/d-modal-body}}

View File

@ -88,6 +88,15 @@
flex: 1; flex: 1;
flex-direction: column; flex-direction: column;
.custom-field-label {
font-weight: 500;
margin-bottom: 0.5em;
}
.custom-field-input {
width: 100%;
}
.radio-label { .radio-label {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@ -320,6 +320,9 @@ en:
<blockquote>username or group name,desired attendance (going, not_going, interested, unknown)</blockquote> <blockquote>username or group name,desired attendance (going, not_going, interested, unknown)</blockquote>
download_sample_csv: Download a sample CSV file download_sample_csv: Download a sample CSV file
builder_modal: 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 create_event_title: Create Event
update_event_title: Edit Event update_event_title: Edit Event
confirm_delete: Are you sure you want to delete this event? confirm_delete: Are you sure you want to delete this event?

View File

@ -22,6 +22,7 @@ en:
displayed_invitees_limit: "Limits the numbers of invitees displayed on an event." 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." 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_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." holiday_calendar_topic_id: "Topic ID of staffs holiday / absence calendar."
delete_expired_event_posts_after: "Posts with expired events will be automatically deleted after (n) hours. Set to -1 to disable deletion." delete_expired_event_posts_after: "Posts with expired events will be automatically deleted after (n) hours. Set to -1 to disable deletion."
all_day_event_start_time: "Events that do not have a start time specified will start at this time. Format is HH:mm. For 6:00 am, enter 06:00" all_day_event_start_time: "Events that do not have a start time specified will start at this time. Format is HH:mm. For 6:00 am, enter 06:00"
@ -54,5 +55,6 @@ en:
end_must_be_a_valid_date: "End date must be a valid date." 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_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." 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: name:
length: "Event name length must be between %{minimum} and %{maximum} characters." length: "Event name length must be between %{minimum} and %{maximum} characters."

View File

@ -62,3 +62,7 @@ discourse_post_event:
discourse_post_event_max_bulk_invitees: discourse_post_event_max_bulk_invitees:
default: 500 default: 500
hidden: true hidden: true
discourse_post_event_allowed_custom_fields:
type: list
client: true
default: ""

View File

@ -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

View File

@ -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

View File

@ -88,6 +88,7 @@ after_initialize do
"../lib/discourse_post_event/event_parser.rb", "../lib/discourse_post_event/event_parser.rb",
"../lib/discourse_post_event/event_validator.rb", "../lib/discourse_post_event/event_validator.rb",
"../jobs/regular/discourse_post_event/bulk_invite.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_started.rb",
"../jobs/regular/discourse_post_event/event_ended.rb", "../jobs/regular/discourse_post_event/event_ended.rb",
"../lib/discourse_post_event/event_finder.rb", "../lib/discourse_post_event/event_finder.rb",
@ -517,5 +518,22 @@ after_initialize do
ids ids
end end
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
end end

View File

@ -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 'doesnt 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

View File

@ -69,7 +69,8 @@ describe DiscoursePostEvent::Event do
expect { expect {
Event.create!(id: first_post.id, starts_at: Time.now - 1.day) Event.create!(id: first_post.id, starts_at: Time.now - 1.day)
}.to change { }.to change {
Jobs::DiscoursePostEventEventStarted.jobs.count Jobs::DiscoursePostEventEventStarted.jobs.count +
Jobs::DiscoursePostEventEventWillStart.jobs.count
}.by(0) }.by(0)
end end
end end
@ -86,13 +87,20 @@ describe DiscoursePostEvent::Event do
.with(:discourse_post_event_event_started, event_id: first_post.id) .with(:discourse_post_event_event_started, event_id: first_post.id)
.once .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) Event.create!(id: first_post.id, starts_at: starts_at)
expect(Jobs::DiscoursePostEventEventStarted.jobs.count).to eq(1) 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) Event.find(first_post.id).update!(starts_at: Time.now + 2.hours)
expect(Jobs::DiscoursePostEventEventStarted.jobs.count).to eq(2) expect(Jobs::DiscoursePostEventEventStarted.jobs.count).to eq(2)
expect(Jobs::DiscoursePostEventEventWillStart.jobs.count).to eq(1)
end end
end end
end end
@ -130,6 +138,11 @@ describe DiscoursePostEvent::Event do
.with(:discourse_post_event_event_started, event_id: first_post.id) .with(:discourse_post_event_event_started, event_id: first_post.id)
.once .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) 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) expect(Jobs::DiscoursePostEventEventEnded.jobs.count).to eq(1)