UX: events are now managed through md markup

This commit is contained in:
jjaffeux 2020-04-11 20:26:37 +02:00
parent 1213b09932
commit d12d45d247
14 changed files with 473 additions and 109 deletions

View File

@ -55,21 +55,7 @@ module DiscoursePostEvent
event = Event.find(params[:id]) event = Event.find(params[:id])
guardian.ensure_can_edit!(event.post) guardian.ensure_can_edit!(event.post)
guardian.ensure_can_act_on_event!(event) guardian.ensure_can_act_on_event!(event)
event.enforce_utc!(event_params) event.update_with_params!(event_params)
case event_params[:status].to_i
when Event.statuses[:private]
raw_invitees = Array(event_params[:raw_invitees])
event.update!(event_params.merge(raw_invitees: raw_invitees))
event.enforce_raw_invitees!
when Event.statuses[:public]
event.update!(event_params.merge(raw_invitees: []))
when Event.statuses[:standalone]
event.update!(event_params.merge(raw_invitees: []))
event.invitees.destroy_all
end
event.publish_update!
serializer = EventSerializer.new(event, scope: guardian) serializer = EventSerializer.new(event, scope: guardian)
render_json_dump(serializer) render_json_dump(serializer)
end end

View File

@ -42,8 +42,10 @@ module DiscoursePostEvent
validates :starts_at, presence: true validates :starts_at, presence: true
MIN_NAME_LENGTH = 5
MAX_NAME_LENGTH = 30
validates :name, validates :name,
length: { in: 5..30 }, length: { in: MIN_NAME_LENGTH..MAX_NAME_LENGTH },
unless: -> (event) { event.name.blank? } unless: -> (event) { event.name.blank? }
validate :raw_invitees_length validate :raw_invitees_length
@ -148,11 +150,11 @@ module DiscoursePostEvent
end end
def enforce_utc!(params) def enforce_utc!(params)
if params['starts_at'].present? if params[:starts_at].present?
params['starts_at'] = Time.parse(params['starts_at']).utc params[:starts_at] = Time.parse(params[:starts_at]).utc
end end
if params['ends_at'].present? if params[:ends_at].present?
params['ends_at'] = Time.parse(params['ends_at']).utc params[:ends_at] = Time.parse(params[:ends_at]).utc
end end
end end
@ -171,5 +173,40 @@ module DiscoursePostEvent
def is_expired? def is_expired?
Time.now > (self.ends_at || self.starts_at || Time.now) Time.now > (self.ends_at || self.starts_at || Time.now)
end end
def self.update_from_raw(post)
events = DiscoursePostEvent::EventParser.extract_events(post.raw)
if events.present?
event_params = events.first
event = post.event || Event.new(id: post.id)
params = {
name: event_params[:name] || event.name,
starts_at: event_params[:start] || event.starts_at,
ends_at: event_params[:end] || event.ends_at,
status: event_params[:status].present? ? Event.statuses[event_params[:status].to_sym] : event.status,
raw_invitees: event_params[:allowedGroups] ? event_params[:allowedGroups].split(',') : nil
}
event.enforce_utc!(params)
event.update_with_params!(params)
elsif post.event
post.event.destroy
end
end
def update_with_params!(params)
case params[:status].to_i
when Event.statuses[:private]
raw_invitees = Array(params[:raw_invitees])
self.update!(params.merge(raw_invitees: raw_invitees))
self.enforce_raw_invitees!
when Event.statuses[:public]
self.update!(params.merge(raw_invitees: []))
when Event.statuses[:standalone]
self.update!(params.merge(raw_invitees: []))
self.invitees.destroy_all
end
self.publish_update!
end
end end
end end

View File

@ -1,3 +1,4 @@
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";
import Controller from "@ember/controller"; import Controller from "@ember/controller";
@ -58,14 +59,32 @@ export default Controller.extend(ModalFunctionality, {
@action @action
destroyPostEvent() { destroyPostEvent() {
bootbox.confirm( bootbox.confirm(
I18n.t("event.ui_builder.confirm_delete"), I18n.t("discourse_post_event.builder_modal.confirm_delete"),
I18n.t("no_value"), I18n.t("no_value"),
I18n.t("yes_value"), I18n.t("yes_value"),
confirmed => { confirmed => {
if (confirmed) { if (confirmed) {
this.model.eventModel return this.store
.destroyRecord() .find("post", this.model.eventModel.id)
.then(() => this.send("closeModal")); .then(post => {
const raw = post.raw;
const newRaw = this._removeRawEvent(raw);
if (newRaw) {
const props = {
raw: newRaw,
edit_reason: I18n.t("discourse_post_event.destroy_event")
};
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"));
});
}
});
} }
} }
); );
@ -73,17 +92,93 @@ export default Controller.extend(ModalFunctionality, {
@action @action
createEvent() { createEvent() {
this.model.eventModel if (!this.startsAt) {
.save() this.send("closeModal");
.then(() => this.send("closeModal")) return;
.catch(e => this.flash(extractError(e), "error")); }
const eventParams = this._buildEventParams();
const markdownParams = [];
Object.keys(eventParams).forEach(key => {
markdownParams.push(`${key}="${eventParams[key]}"`);
});
this.toolbarEvent.addText(
`[wrap=event ${markdownParams.join(" ")}]\n[/wrap]`
);
this.send("closeModal");
}, },
@action @action
updateEvent() { updateEvent() {
this.model.eventModel const eventParams = this._buildEventParams();
.save() return this.store.find("post", this.model.eventModel.id).then(post => {
.then(() => this.send("closeModal")) const raw = post.raw;
.catch(e => this.flash(extractError(e), "error")); const newRaw = this._replaceRawEvent(eventParams, raw);
if (newRaw) {
const props = {
raw: newRaw,
edit_reason: I18n.t("discourse_post_event.update_event")
};
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"));
});
}
});
},
_buildEventParams() {
const eventParams = {
start: this.startsAt,
status: this.model.eventModel.status,
name: this.model.eventModel.name
};
if (this.endsAt) {
eventParams.end = this.endsAt;
}
if (this.model.eventModel.status === "private") {
eventParams.allowedGroups = (
this.model.eventModel.raw_invitees || []
).join(",");
}
return eventParams;
},
_removeRawEvent(raw) {
const eventRegex = new RegExp(
`\\[wrap=event\\s(.*?)\\]\\n\\[\\/wrap\\]`,
"m"
);
return raw.replace(eventRegex, "");
},
_replaceRawEvent(eventparams, raw) {
const eventRegex = new RegExp(`\\[wrap=event\\s(.*?)\\]`, "m");
const eventMatches = raw.match(eventRegex);
if (eventMatches && eventMatches[1]) {
const markdownParams = [];
const eventParams = this._buildEventParams();
Object.keys(eventParams).forEach(eventParam =>
markdownParams.push(`${eventParam}="${eventParams[eventParam]}"`)
);
return raw.replace(
eventRegex,
`[wrap=event ${markdownParams.join(" ")}]`
);
}
return false;
} }
}); });

View File

@ -19,19 +19,6 @@
}} }}
{{#event-field label="discourse_post_event.builder_modal.status.label"}} {{#event-field label="discourse_post_event.builder_modal.status.label"}}
<label class="radio-label">
{{radio-button
name="status"
value="standalone"
selection=model.eventModel.status
onChange=(action (mut model.eventModel.status))
}}
<span class="message">
<span class="title">{{i18n "discourse_post_event.models.event.status.standalone.title"}}</span>
<span class="description">{{i18n "discourse_post_event.models.event.status.standalone.description"}}</span>
</span>
</label>
<label class="radio-label"> <label class="radio-label">
{{radio-button {{radio-button
name="status" name="status"
@ -56,6 +43,18 @@
<span class="description">{{i18n "discourse_post_event.models.event.status.private.description"}}</span> <span class="description">{{i18n "discourse_post_event.models.event.status.private.description"}}</span>
</span> </span>
</label> </label>
<label class="radio-label">
{{radio-button
name="status"
value="standalone"
selection=model.eventModel.status
onChange=(action (mut model.eventModel.status))
}}
<span class="message">
<span class="title">{{i18n "discourse_post_event.models.event.status.standalone.title"}}</span>
<span class="description">{{i18n "discourse_post_event.models.event.status.standalone.description"}}</span>
</span>
</label>
{{/event-field}} {{/event-field}}
{{#event-field enabled=allowsInvitees label="discourse_post_event.builder_modal.invitees.label"}} {{#event-field enabled=allowsInvitees label="discourse_post_event.builder_modal.invitees.label"}}

View File

@ -25,7 +25,7 @@ export default createWidget("discourse-post-event-dates", {
attrs.eventModel.status !== "standalone" attrs.eventModel.status !== "standalone"
) { ) {
let participants; let participants;
const label = I18n.t("event.post_ui.participants", { const label = I18n.t("discourse_post_event.event_ui.participants", {
count: attrs.eventModel.stats.going count: attrs.eventModel.stats.going
}); });
if (attrs.eventModel.stats.going > 0) { if (attrs.eventModel.stats.going > 0) {

View File

@ -136,7 +136,7 @@ export default createWidget("discourse-post-event", {
{{#unless transformed.isStandaloneEvent}} {{#unless transformed.isStandaloneEvent}}
{{#if state.eventModel.is_expired}} {{#if state.eventModel.is_expired}}
<span class="status expired"> <span class="status expired">
{{i18n "event.expired"}} {{i18n "discourse_post_event.models.event.expired"}}
</span> </span>
{{else}} {{else}}
<span class={{transformed.statusClass}} title={{transformed.eventStatusDescription}}> <span class={{transformed.statusClass}} title={{transformed.eventStatusDescription}}>

View File

@ -1,45 +1,43 @@
import { withPluginApi } from "discourse/lib/plugin-api"; import { withPluginApi } from "discourse/lib/plugin-api";
import showModal from "discourse/lib/show-modal"; import showModal from "discourse/lib/show-modal";
import { Promise } from "rsvp";
function initializeEventBuilder(api) { function initializeEventBuilder(api) {
api.attachWidgetAction("post", "showEventBuilder", function({ const currentUser = api.getCurrentUser();
postId, const siteSettings = api.container.lookup("site-settings:main");
topicId
}) {
return new Promise(resolve => {
if (postId) {
this.store
.find("discourse-post-event-event", postId)
.then(resolve)
.catch(() => {
const eventModel = this.store.createRecord(
"discourse-post-event-event"
);
eventModel.setProperties({
id: postId,
status: "public"
});
resolve(eventModel);
});
} else if (this.model) {
resolve(this.model);
}
}).then(eventModel => {
showModal("discourse-post-event-builder", {
model: { eventModel, topicId },
modalClass: "discourse-post-event-builder"
});
});
});
api.decorateWidget("post-admin-menu:after", dec => { api.onToolbarCreate(toolbar => {
return dec.attach("post-admin-menu-button", { if (!currentUser.staff) {
icon: "calendar-day", return;
label: "discourse_event.builder.attach", }
action: "showEventBuilder",
actionParam: { postId: dec.attrs.id, topicId: dec.attrs.topicId } const composer = toolbar.context.outletArgs.composer;
}); if (
!composer.replyingToTopic &&
(composer.topicFirstPost ||
(composer.editingPost &&
composer.post &&
composer.post.post_number === 1))
) {
toolbar.addButton({
title: "discourse_post_event.builder_modal.attach",
id: "insertEvent",
group: "insertions",
icon: "calendar-day",
perform: toolbarEvent => {
const eventModel = toolbar.context.store.createRecord(
"discourse-post-event-event"
);
eventModel.setProperties({
status: "public"
});
showModal("discourse-post-event-builder").setProperties({
toolbarEvent,
model: { eventModel }
});
}
});
}
}); });
} }

View File

@ -18,8 +18,9 @@ function cleanUp() {
function _attachWidget(api, cooked, eventModel) { function _attachWidget(api, cooked, eventModel) {
const existing = cooked.querySelector(".discourse-post-event"); const existing = cooked.querySelector(".discourse-post-event");
const wrap = cooked.querySelector("[data-wrap=event]");
if (eventModel) { if (eventModel && wrap) {
let widgetHeight = 300; let widgetHeight = 300;
if (eventModel.can_update_attendance) { if (eventModel.can_update_attendance) {
@ -31,7 +32,7 @@ function _attachWidget(api, cooked, eventModel) {
eventContainer.classList.add("is-loading"); eventContainer.classList.add("is-loading");
eventContainer.style.height = `${widgetHeight}px`; eventContainer.style.height = `${widgetHeight}px`;
eventContainer.innerHTML = '<div class="spinner medium"></div>'; eventContainer.innerHTML = '<div class="spinner medium"></div>';
cooked.prepend(eventContainer); wrap.prepend(eventContainer);
const dates = []; const dates = [];
const startsAt = moment(eventModel.starts_at); const startsAt = moment(eventModel.starts_at);
@ -79,7 +80,7 @@ function _attachWidget(api, cooked, eventModel) {
} }
} }
function initializeEventDecorator(api) { function initializeDiscoursePostEventDecorator(api) {
api.cleanupStream(cleanUp); api.cleanupStream(cleanUp);
api.decorateCooked(($cooked, helper) => { api.decorateCooked(($cooked, helper) => {
@ -128,7 +129,7 @@ export default {
initialize(container) { initialize(container) {
const siteSettings = container.lookup("site-settings:main"); const siteSettings = container.lookup("site-settings:main");
if (siteSettings.discourse_post_event_enabled) { if (siteSettings.discourse_post_event_enabled) {
withPluginApi("0.8.7", initializeEventDecorator); withPluginApi("0.8.7", initializeDiscoursePostEventDecorator);
} }
} }
}; };

View File

@ -24,5 +24,11 @@ en:
errors: errors:
models: models:
event: event:
raw_invitees_length: "An event is limited to %{count} users/groups" only_one_event: A post can only have one event.
ends_at_before_starts_at: "An event can't end before it starts" must_be_in_first_post: An event can only be in the first post of a topic.
raw_invitees_length: "An event is limited to %{count} users/groups."
ends_at_before_starts_at: "An event can't end before it starts."
start_must_be_present_and_a_valid_date: "An event requires a valid start date."
end_must_be_a_valid_date: "End date must be a valid date."
name:
length: "Event name length must be between %{minimum} and %{maximum} characters."

View File

@ -0,0 +1,33 @@
# frozen_string_literal: true
EVENT_REGEX = /\[wrap=event\s(.*?)\](?:\n|\\n)?\[\/wrap\]/m
VALID_OPTIONS = [
:start,
:end,
:status,
:allowedGroups,
:name
]
module DiscoursePostEvent
class EventParser
def self.extract_events(str)
str.scan(EVENT_REGEX).map do |scan|
extract_options(scan[0].gsub(/\\/, ''))
end.compact
end
def self.extract_options(str)
options = nil
str.split(" ").each do |option|
key, value = option.split("=")
if VALID_OPTIONS.include?(key.to_sym) && value
options ||= {}
options[key.to_sym] = value.delete('\\"')
end
end
options
end
end
end

View File

@ -0,0 +1,55 @@
# frozen_string_literal: true
module DiscoursePostEvent
class EventValidator
def initialize(post)
@post = post
end
def validate_event
extracted_events = DiscoursePostEvent::EventParser::extract_events(@post.raw)
if extracted_events.count == 0
return false
end
if extracted_events.count > 1
@post.errors.add(:base, I18n.t("discourse_post_event.errors.models.event.only_one_event"))
return false
end
if !@post.is_first_post?
@post.errors.add(:base, I18n.t("discourse_post_event.errors.models.event.must_be_in_first_post"))
return false
end
extracted_event = extracted_events.first
if extracted_event[:start].blank? || (DateTime.parse(extracted_event[:start]) rescue nil).nil?
@post.errors.add(:base, I18n.t("discourse_post_event.errors.models.event.start_must_be_present_and_a_valid_date"))
return false
end
if extracted_event[:end].present? && (DateTime.parse(extracted_event[:end]) rescue nil).nil?
@post.errors.add(:base, I18n.t("discourse_post_event.errors.models.event.end_must_be_a_valid_date"))
return false
end
if extracted_event[:start].present? && extracted_event[:end].present?
if Time.parse(extracted_event[:start]) > Time.parse(extracted_event[:end])
@post.errors.add(:base, I18n.t("discourse_post_event.errors.models.event.ends_at_before_starts_at"))
return false
end
end
if extracted_event[:name].present? && extracted_event[:name]
if !(Event::MIN_NAME_LENGTH..Event::MAX_NAME_LENGTH).cover?(extracted_event[:name].length)
@post.errors.add(:base, I18n.t('discourse_post_event.errors.models.event.name.length', minimum: Event::MIN_NAME_LENGTH, maximum: Event::MAX_NAME_LENGTH))
return false
end
end
true
end
end
end

View File

@ -81,6 +81,8 @@ after_initialize do
"../app/controllers/discourse_post_event/upcoming_events_controller.rb", "../app/controllers/discourse_post_event/upcoming_events_controller.rb",
"../app/models/discourse_post_event/event.rb", "../app/models/discourse_post_event/event.rb",
"../app/models/discourse_post_event/invitee.rb", "../app/models/discourse_post_event/invitee.rb",
"../lib/discourse_post_event/event_parser.rb",
"../lib/discourse_post_event/event_validator.rb",
"../lib/discourse_post_event/event_finder.rb", "../lib/discourse_post_event/event_finder.rb",
"../app/serializers/discourse_post_event/invitee_serializer.rb", "../app/serializers/discourse_post_event/invitee_serializer.rb",
"../app/serializers/discourse_post_event/event_serializer.rb" "../app/serializers/discourse_post_event/event_serializer.rb"
@ -88,25 +90,6 @@ after_initialize do
::ActionController::Base.prepend_view_path File.expand_path("../app/views", __FILE__) ::ActionController::Base.prepend_view_path File.expand_path("../app/views", __FILE__)
reloadable_patch do
require 'post'
class ::Post
has_one :event,
dependent: :destroy,
class_name: 'DiscoursePostEvent::Event',
foreign_key: :id
end
end
add_to_serializer(:post, :event) do
DiscoursePostEvent::EventSerializer.new(object.event, scope: scope, root: false)
end
add_to_serializer(:post, :include_event?) do
SiteSetting.discourse_post_event_enabled
end
Discourse::Application.routes.append do Discourse::Application.routes.append do
mount ::DiscoursePostEvent::Engine, at: '/' mount ::DiscoursePostEvent::Engine, at: '/'
end end
@ -124,6 +107,36 @@ after_initialize do
get '/upcoming-events' => 'upcoming_events#index' get '/upcoming-events' => 'upcoming_events#index'
end end
reloadable_patch do
require 'post'
class ::Post
has_one :event,
dependent: :destroy,
class_name: 'DiscoursePostEvent::Event',
foreign_key: :id
validate :valid_event
def valid_event
return unless self.raw_changed?
validator = DiscoursePostEvent::EventValidator.new(self)
validator.validate_event
end
end
end
add_to_serializer(:post, :event) do
DiscoursePostEvent::EventSerializer.new(object.event, scope: scope, root: false)
end
add_to_serializer(:post, :include_event?) do
SiteSetting.discourse_post_event_enabled
end
DiscourseEvent.on(:post_process_cooked) do |doc, post|
DiscoursePostEvent::Event.update_from_raw(post)
end
DiscourseEvent.on(:post_destroyed) do |post| DiscourseEvent.on(:post_destroyed) do |post|
if SiteSetting.discourse_post_event_enabled && post.event if SiteSetting.discourse_post_event_enabled && post.event
post.event.update!(deleted_at: Time.now) post.event.update!(deleted_at: Time.now)

View File

@ -17,6 +17,105 @@ describe Post do
SiteSetting.discourse_post_event_enabled = true SiteSetting.discourse_post_event_enabled = true
end end
context 'when a post is updated' do
context 'when the post has a valid event' do
context 'context the event markup is removed' do
it 'destroys the associated event' do
start = Time.now.utc.iso8601(3)
post = PostCreator.create!(
user,
title: 'Sell a boat party',
raw: "[wrap=event start=#{start}]\n[/wrap]",
)
expect(post.event.persisted?).to eq(true)
revisor = PostRevisor.new(post, post.topic)
revisor.revise!(user, raw: 'The event is over. Come back another day.')
expect(post.reload.event).to be(nil)
end
end
end
end
context 'when a post is created' do
context 'when the post contains one valid event' do
it 'creates the post event' do
start = Time.now.utc.iso8601(3)
post = PostCreator.create!(
user,
title: 'Sell a boat party',
raw: "[wrap=event start=#{start}]\n[/wrap]",
)
expect(post.persisted?).to eq(true)
expect(post.event.persisted?).to eq(true)
expect(post.event.starts_at).to eq_time(Time.parse(start))
end
end
context 'when the post contains one invalid event' do
context 'when start is not provided or is invalid' do
it 'raises an error' do
expect {
PostCreator.create!(
user,
title: 'Sell a boat party',
raw: "[wrap=event end=\"1\"]\n[/wrap]",
)
}.to(
raise_error(ActiveRecord::RecordNotSaved)
.with_message(I18n.t("discourse_post_event.errors.models.event.start_must_be_present_and_a_valid_date"))
)
expect {
PostCreator.create!(
user,
title: 'Sell a boat party',
raw: "[wrap=event start=\"x\"]\n[/wrap]",
)
}.to(
raise_error(ActiveRecord::RecordNotSaved)
.with_message(I18n.t("discourse_post_event.errors.models.event.start_must_be_present_and_a_valid_date"))
)
end
end
context 'when end is provided and is invalid' do
it 'raises an error' do
expect {
PostCreator.create!(
user,
title: 'Sell a boat party',
raw: "[wrap=event start=\"#{Time.now.utc.iso8601(3)}\" end=\"d\"]\n[/wrap]",
)
}.to(
raise_error(ActiveRecord::RecordNotSaved)
.with_message(I18n.t("discourse_post_event.errors.models.event.end_must_be_a_valid_date"))
)
end
end
end
context 'when the post contains multiple events' do
it 'raises an error' do
expect {
PostCreator.create!(
user,
title: 'Sell a boat party',
raw: "[wrap=event start=\"#{Time.now.utc.iso8601(3)}\"]\n[/wrap] foo [wrap=event start=\"#{Time.now.utc.iso8601(3)}\"]\n[/wrap]",
)
}.to(
raise_error(ActiveRecord::RecordNotSaved)
.with_message(I18n.t("discourse_post_event.errors.models.event.only_one_event"))
)
end
end
end
context 'when a post with an event is destroyed' do context 'when a post with an event is destroyed' do
it 'sets deleted_at on the post_event' do it 'sets deleted_at on the post_event' do
expect(post_event.deleted_at).to be_nil expect(post_event.deleted_at).to be_nil

View File

@ -0,0 +1,42 @@
# frozen_string_literal: true
require "rails_helper"
describe DiscoursePostEvent::EventParser do
subject { DiscoursePostEvent::EventParser }
it 'works with no event' do
events = subject.extract_events('this could be a nice event')
expect(events.length).to eq(0)
end
it 'finds one event' do
events = subject.extract_events('[wrap=event start="foo" end="bar"]\n[/wrap]')
expect(events.length).to eq(1)
end
it 'finds multiple events' do
events = subject.extract_events('[wrap=event start="foo" end="bar"]\n[/wrap] baz [wrap=event start="foo" end="bar"]\n[/wrap]')
expect(events.length).to eq(2)
end
it 'parses options' do
events = subject.extract_events('[wrap=event start="foo" end="bar"]\n[/wrap]')
expect(events[0][:start]).to eq("foo")
expect(events[0][:end]).to eq("bar")
end
it 'works with escaped string' do
events = subject.extract_events("I am going to get that fixed.\n\n[wrap=event start=\"bar\"]\n[/wrap]\n\n[wrap=event start=\"foo\"]\n[/wrap]")
expect(events[0][:start]).to eq("bar")
expect(events[1][:start]).to eq("foo")
end
it 'doesnt parse invalid options' do
events = subject.extract_events("I am going to get that fixed.\n\n[wrap=event start=\"foo\" something=\"bar\"]\n[/wrap]")
expect(events[0][:something]).to be(nil)
events = subject.extract_events("I am going to get that fixed.\n\n[wrap=event something=\"bar\"]\n[/wrap]")
expect(events).to eq([])
end
end