mirror of
https://github.com/discourse/discourse.git
synced 2024-11-29 00:55:06 +08:00
FEATURE: save local date to calendar (#14486)
It allows saving local date to calendar. Modal is giving option to pick between ics and google. User choice can be remembered as a default for the next actions.
This commit is contained in:
parent
6ab5f70090
commit
cb5b0cb9d8
|
@ -31,6 +31,7 @@
|
||||||
//= require ./discourse/app/lib/text-direction
|
//= require ./discourse/app/lib/text-direction
|
||||||
//= require ./discourse/app/lib/eyeline
|
//= require ./discourse/app/lib/eyeline
|
||||||
//= require ./discourse/app/lib/show-modal
|
//= require ./discourse/app/lib/show-modal
|
||||||
|
//= require ./discourse/app/lib/download-calendar
|
||||||
//= require ./discourse/app/mixins/scrolling
|
//= require ./discourse/app/mixins/scrolling
|
||||||
//= require ./discourse/app/lib/ajax-error
|
//= require ./discourse/app/lib/ajax-error
|
||||||
//= require ./discourse/app/models/result-set
|
//= require ./discourse/app/models/result-set
|
||||||
|
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { action } from "@ember/object";
|
||||||
|
import Controller from "@ember/controller";
|
||||||
|
import ModalFunctionality from "discourse/mixins/modal-functionality";
|
||||||
|
import { downloadGoogle, downloadIcs } from "discourse/lib/download-calendar";
|
||||||
|
|
||||||
|
export default Controller.extend(ModalFunctionality, {
|
||||||
|
selectedCalendar: "ics",
|
||||||
|
remember: false,
|
||||||
|
|
||||||
|
@action
|
||||||
|
downloadCalendar() {
|
||||||
|
if (this.remember) {
|
||||||
|
this.currentUser.setProperties({
|
||||||
|
default_calendar: this.selectedCalendar,
|
||||||
|
user_option: { default_calendar: this.selectedCalendar },
|
||||||
|
});
|
||||||
|
this.currentUser.save(["default_calendar"]);
|
||||||
|
}
|
||||||
|
if (this.selectedCalendar === "ics") {
|
||||||
|
downloadIcs(this.model.postId, this.model.title, this.model.dates);
|
||||||
|
} else {
|
||||||
|
downloadGoogle(this.model.title, this.model.dates);
|
||||||
|
}
|
||||||
|
this.send("closeModal");
|
||||||
|
},
|
||||||
|
});
|
|
@ -23,6 +23,12 @@ export default Controller.extend({
|
||||||
"card_background_upload_url",
|
"card_background_upload_url",
|
||||||
"date_of_birth",
|
"date_of_birth",
|
||||||
"timezone",
|
"timezone",
|
||||||
|
"default_calendar",
|
||||||
|
];
|
||||||
|
|
||||||
|
this.calendarOptions = [
|
||||||
|
{ name: I18n.t("download_calendar.google"), value: "google" },
|
||||||
|
{ name: I18n.t("download_calendar.ics"), value: "ics" },
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -45,6 +51,11 @@ export default Controller.extend({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@discourseComputed("model.default_calendar")
|
||||||
|
canChangeDefaultCalendar(defaultCalendar) {
|
||||||
|
return defaultCalendar !== "none_selected";
|
||||||
|
},
|
||||||
|
|
||||||
canChangeBio: readOnly("model.can_change_bio"),
|
canChangeBio: readOnly("model.can_change_bio"),
|
||||||
|
|
||||||
canChangeLocation: readOnly("model.can_change_location"),
|
canChangeLocation: readOnly("model.can_change_location"),
|
||||||
|
|
|
@ -0,0 +1,67 @@
|
||||||
|
import User from "discourse/models/user";
|
||||||
|
import showModal from "discourse/lib/show-modal";
|
||||||
|
import getURL from "discourse-common/lib/get-url";
|
||||||
|
|
||||||
|
export function downloadCalendar(postId, title, dates) {
|
||||||
|
const currentUser = User.current();
|
||||||
|
|
||||||
|
const formattedDates = formatDates(dates);
|
||||||
|
|
||||||
|
switch (currentUser.default_calendar) {
|
||||||
|
case "none_selected":
|
||||||
|
_displayModal(postId, title, formattedDates);
|
||||||
|
break;
|
||||||
|
case "ics":
|
||||||
|
downloadIcs(postId, title, formattedDates);
|
||||||
|
break;
|
||||||
|
case "google":
|
||||||
|
downloadGoogle(title, formattedDates);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function downloadIcs(postId, title, dates) {
|
||||||
|
let datesParam = "";
|
||||||
|
dates.forEach((date, index) => {
|
||||||
|
datesParam = datesParam.concat(
|
||||||
|
`&dates[${index}][starts_at]=${date.startsAt}&dates[${index}][ends_at]=${date.endsAt}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const link = getURL(
|
||||||
|
`/calendars.ics?post_id=${postId}&title=${title}&${datesParam}`
|
||||||
|
);
|
||||||
|
window.open(link, "_blank", "noopener", "noreferrer");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function downloadGoogle(title, dates) {
|
||||||
|
dates.forEach((date) => {
|
||||||
|
const encodedTitle = encodeURIComponent(title);
|
||||||
|
const link = getURL(`
|
||||||
|
https://www.google.com/calendar/event?action=TEMPLATE&text=${encodedTitle}&dates=${_formatDateForGoogleApi(
|
||||||
|
date.startsAt
|
||||||
|
)}/${_formatDateForGoogleApi(date.endsAt)}
|
||||||
|
`).trim();
|
||||||
|
window.open(link, "_blank", "noopener", "noreferrer");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDates(dates) {
|
||||||
|
return dates.map((date) => {
|
||||||
|
return {
|
||||||
|
startsAt: date.startsAt,
|
||||||
|
endsAt: date.endsAt
|
||||||
|
? date.endsAt
|
||||||
|
: moment.utc(date.startsAt).add(1, "hours").format(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _displayModal(postId, title, dates) {
|
||||||
|
showModal("download-calendar", { model: { title, postId, dates } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function _formatDateForGoogleApi(date) {
|
||||||
|
return moment(date)
|
||||||
|
.toISOString()
|
||||||
|
.replace(/-|:|\.\d\d\d/g, "");
|
||||||
|
}
|
|
@ -97,6 +97,7 @@ let userOptionFields = [
|
||||||
"title_count_mode",
|
"title_count_mode",
|
||||||
"timezone",
|
"timezone",
|
||||||
"skip_new_user_tips",
|
"skip_new_user_tips",
|
||||||
|
"default_calendar",
|
||||||
];
|
];
|
||||||
|
|
||||||
export function addSaveableUserOptionField(fieldName) {
|
export function addSaveableUserOptionField(fieldName) {
|
||||||
|
|
|
@ -0,0 +1,45 @@
|
||||||
|
<div>
|
||||||
|
{{#d-modal-body title="download_calendar.title"}}
|
||||||
|
<div class="control-group">
|
||||||
|
<div class="ics">
|
||||||
|
<label class="radio" for="ics">
|
||||||
|
{{radio-button
|
||||||
|
name="select-calendar"
|
||||||
|
id="ics"
|
||||||
|
value="ics"
|
||||||
|
selection=selectedCalendar
|
||||||
|
onChange=(action (mut selectedCalendar))
|
||||||
|
}}
|
||||||
|
{{i18n "download_calendar.save_ics"}}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="google">
|
||||||
|
<label class="radio" for="google">
|
||||||
|
{{radio-button
|
||||||
|
name="select-calendar"
|
||||||
|
id="google"
|
||||||
|
value="google"
|
||||||
|
selection=selectedCalendar
|
||||||
|
onChange=(action (mut selectedCalendar))
|
||||||
|
}}
|
||||||
|
{{i18n "download_calendar.save_google"}}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="control-group remember">
|
||||||
|
<label>
|
||||||
|
{{input type="checkbox" checked=remember}} <span>{{i18n "download_calendar.remember"}}</span>
|
||||||
|
</label>
|
||||||
|
<span>{{i18n "download_calendar.remember_explanation"}}</span>
|
||||||
|
</div>
|
||||||
|
{{/d-modal-body}}
|
||||||
|
<div class="modal-footer">
|
||||||
|
{{d-button
|
||||||
|
class="btn-primary"
|
||||||
|
action=(action "downloadCalendar")
|
||||||
|
label="download_calendar.download"
|
||||||
|
}}
|
||||||
|
{{d-modal-cancel close=(route-action "closeModal")}}
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -103,6 +103,24 @@
|
||||||
</div>
|
</div>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if canChangeDefaultCalendar }}
|
||||||
|
<div class="control-group">
|
||||||
|
<label class="control-label">{{i18n "download_calendar.default_calendar"}}</label>
|
||||||
|
<div>
|
||||||
|
{{combo-box
|
||||||
|
valueProperty="value"
|
||||||
|
content=calendarOptions
|
||||||
|
value=model.user_option.default_calendar
|
||||||
|
id="user-default-calendar"
|
||||||
|
onChange=(action (mut model.user_option.default_calendar))
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<div class="instructions">
|
||||||
|
{{i18n "download_calendar.default_calendar_instruction"}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
{{plugin-outlet name="user-preferences-profile" args=(hash model=model save=(action "save"))}}
|
{{plugin-outlet name="user-preferences-profile" args=(hash model=model save=(action "save"))}}
|
||||||
|
|
||||||
{{plugin-outlet name="user-custom-preferences" args=(hash model=model)}}
|
{{plugin-outlet name="user-custom-preferences" args=(hash model=model)}}
|
||||||
|
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { acceptance, exists } from "discourse/tests/helpers/qunit-helpers";
|
||||||
|
import { visit } from "@ember/test-helpers";
|
||||||
|
import { test } from "qunit";
|
||||||
|
|
||||||
|
acceptance(
|
||||||
|
"User profile preferences without default calendar set",
|
||||||
|
function (needs) {
|
||||||
|
needs.user({ default_calendar: "none_selected" });
|
||||||
|
|
||||||
|
test("default calendar option is not visible", async function (assert) {
|
||||||
|
await visit("/u/eviltrout/preferences/profile");
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
!exists("#user-default-calendar"),
|
||||||
|
"option to change default calendar is hidden"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
acceptance(
|
||||||
|
"User profile preferences with default calendar set",
|
||||||
|
function (needs) {
|
||||||
|
needs.user({ default_calendar: "google" });
|
||||||
|
|
||||||
|
test("default calendar can be changed", async function (assert) {
|
||||||
|
await visit("/u/eviltrout/preferences/profile");
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
exists("#user-default-calendar"),
|
||||||
|
"option to change default calendar"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
|
@ -3301,6 +3301,196 @@ export default {
|
||||||
],
|
],
|
||||||
tags: null,
|
tags: null,
|
||||||
},
|
},
|
||||||
|
"/t/281.json": {
|
||||||
|
post_stream: {
|
||||||
|
posts: [
|
||||||
|
{
|
||||||
|
id: 133,
|
||||||
|
name: null,
|
||||||
|
username: "bianca",
|
||||||
|
avatar_template: "/letter_avatar_proxy/v4/letter/b/3be4f8/{size}.png",
|
||||||
|
created_at: "2020-07-05T09:28:36.371Z",
|
||||||
|
cooked:
|
||||||
|
"<p><span data-date=\"2021-09-30\" data-time=\"13:00:00\" class=\"discourse-local-date\" data-timezone=\"Africa/Cairo\" data-email-preview=\"2021-09-30T11:00:00Z UTC\">2021-09-30T11:00:00Z</span></p>",
|
||||||
|
post_number: 1,
|
||||||
|
post_type: 1,
|
||||||
|
updated_at: "2020-07-05T09:28:36.371Z",
|
||||||
|
reply_count: 0,
|
||||||
|
reply_to_post_number: null,
|
||||||
|
quote_count: 0,
|
||||||
|
incoming_link_count: 0,
|
||||||
|
reads: 1,
|
||||||
|
readers_count: 0,
|
||||||
|
score: 0,
|
||||||
|
yours: true,
|
||||||
|
topic_id: 281,
|
||||||
|
topic_slug: "local-dates",
|
||||||
|
display_username: null,
|
||||||
|
primary_group_name: null,
|
||||||
|
flair_name: null,
|
||||||
|
flair_url: null,
|
||||||
|
flair_bg_color: null,
|
||||||
|
flair_color: null,
|
||||||
|
version: 1,
|
||||||
|
can_edit: true,
|
||||||
|
can_delete: false,
|
||||||
|
can_recover: false,
|
||||||
|
can_wiki: true,
|
||||||
|
read: true,
|
||||||
|
user_title: "Tester",
|
||||||
|
title_is_group: false,
|
||||||
|
actions_summary: [
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
can_act: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
can_act: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 8,
|
||||||
|
can_act: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
can_act: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
moderator: false,
|
||||||
|
admin: true,
|
||||||
|
staff: true,
|
||||||
|
user_id: 1,
|
||||||
|
hidden: false,
|
||||||
|
trust_level: 0,
|
||||||
|
deleted_at: null,
|
||||||
|
user_deleted: false,
|
||||||
|
edit_reason: null,
|
||||||
|
can_view_edit_history: true,
|
||||||
|
wiki: false,
|
||||||
|
reviewable_id: 0,
|
||||||
|
reviewable_score_count: 0,
|
||||||
|
reviewable_score_pending_count: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
stream: [133],
|
||||||
|
},
|
||||||
|
timeline_lookup: [[1, 0]],
|
||||||
|
related_messages: [],
|
||||||
|
suggested_topics: [],
|
||||||
|
id: 281,
|
||||||
|
title: "Local dates",
|
||||||
|
fancy_title: "Local dates",
|
||||||
|
posts_count: 1,
|
||||||
|
created_at: "2020-07-05T09:28:36.260Z",
|
||||||
|
views: 1,
|
||||||
|
reply_count: 0,
|
||||||
|
like_count: 0,
|
||||||
|
last_posted_at: "2020-07-05T09:28:36.371Z",
|
||||||
|
visible: true,
|
||||||
|
closed: false,
|
||||||
|
archived: false,
|
||||||
|
has_summary: false,
|
||||||
|
archetype: "regular",
|
||||||
|
slug: "local-dates",
|
||||||
|
category_id: null,
|
||||||
|
word_count: 86,
|
||||||
|
deleted_at: null,
|
||||||
|
user_id: 1,
|
||||||
|
featured_link: null,
|
||||||
|
pinned_globally: false,
|
||||||
|
pinned_at: null,
|
||||||
|
pinned_until: null,
|
||||||
|
image_url: null,
|
||||||
|
draft: null,
|
||||||
|
draft_key: "topic_281",
|
||||||
|
draft_sequence: 0,
|
||||||
|
posted: true,
|
||||||
|
unpinned: null,
|
||||||
|
pinned: false,
|
||||||
|
current_post_number: 1,
|
||||||
|
highest_post_number: 1,
|
||||||
|
last_read_post_number: 1,
|
||||||
|
last_read_post_id: 133,
|
||||||
|
deleted_by: null,
|
||||||
|
has_deleted: false,
|
||||||
|
actions_summary: [
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
count: 0,
|
||||||
|
hidden: false,
|
||||||
|
can_act: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 8,
|
||||||
|
count: 0,
|
||||||
|
hidden: false,
|
||||||
|
can_act: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
count: 0,
|
||||||
|
hidden: false,
|
||||||
|
can_act: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
chunk_size: 20,
|
||||||
|
bookmarked: false,
|
||||||
|
bookmarks: [],
|
||||||
|
message_archived: false,
|
||||||
|
topic_timer: null,
|
||||||
|
message_bus_last_id: 5,
|
||||||
|
participant_count: 1,
|
||||||
|
pm_with_non_human_user: false,
|
||||||
|
show_read_indicator: false,
|
||||||
|
requested_group_name: null,
|
||||||
|
thumbnails: null,
|
||||||
|
tags_disable_ads: false,
|
||||||
|
details: {
|
||||||
|
notification_level: 3,
|
||||||
|
notifications_reason_id: 1,
|
||||||
|
can_move_posts: true,
|
||||||
|
can_edit: true,
|
||||||
|
can_delete: true,
|
||||||
|
can_remove_allowed_users: true,
|
||||||
|
can_invite_to: true,
|
||||||
|
can_invite_via_email: true,
|
||||||
|
can_create_post: true,
|
||||||
|
can_reply_as_new_topic: true,
|
||||||
|
can_flag_topic: true,
|
||||||
|
can_convert_topic: true,
|
||||||
|
can_review_topic: true,
|
||||||
|
can_remove_self_id: 1,
|
||||||
|
participants: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
username: "bianca",
|
||||||
|
name: null,
|
||||||
|
avatar_template: "/letter_avatar_proxy/v4/letter/b/3be4f8/{size}.png",
|
||||||
|
post_count: 1,
|
||||||
|
primary_group_name: null,
|
||||||
|
flair_name: null,
|
||||||
|
flair_url: null,
|
||||||
|
flair_color: null,
|
||||||
|
flair_bg_color: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
allowed_users: [],
|
||||||
|
created_by: {
|
||||||
|
id: 1,
|
||||||
|
username: "bianca",
|
||||||
|
name: null,
|
||||||
|
avatar_template: "/letter_avatar_proxy/v4/letter/b/3be4f8/{size}.png",
|
||||||
|
},
|
||||||
|
last_poster: {
|
||||||
|
id: 1,
|
||||||
|
username: "bianca",
|
||||||
|
name: null,
|
||||||
|
avatar_template: "/letter_avatar_proxy/v4/letter/b/3be4f8/{size}.png",
|
||||||
|
},
|
||||||
|
allowed_groups: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
"/t/28830/1.json": {
|
"/t/28830/1.json": {
|
||||||
post_stream: {
|
post_stream: {
|
||||||
posts: [
|
posts: [
|
||||||
|
|
|
@ -0,0 +1,63 @@
|
||||||
|
import { module, test } from "qunit";
|
||||||
|
import {
|
||||||
|
downloadGoogle,
|
||||||
|
downloadIcs,
|
||||||
|
formatDates,
|
||||||
|
} from "discourse/lib/download-calendar";
|
||||||
|
import sinon from "sinon";
|
||||||
|
|
||||||
|
module("Unit | Utility | download-calendar", function (hooks) {
|
||||||
|
hooks.beforeEach(function () {
|
||||||
|
let win = { focus: function () {} };
|
||||||
|
sinon.stub(window, "open").returns(win);
|
||||||
|
sinon.stub(win, "focus");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("correct url for Ics", function (assert) {
|
||||||
|
downloadIcs(1, "event", [
|
||||||
|
{
|
||||||
|
startsAt: "2021-10-12T15:00:00.000Z",
|
||||||
|
endsAt: "2021-10-12T16:00:00.000Z",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
assert.ok(
|
||||||
|
window.open.calledWith(
|
||||||
|
"/calendars.ics?post_id=1&title=event&&dates[0][starts_at]=2021-10-12T15:00:00.000Z&dates[0][ends_at]=2021-10-12T16:00:00.000Z",
|
||||||
|
"_blank",
|
||||||
|
"noopener",
|
||||||
|
"noreferrer"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("correct url for Google", function (assert) {
|
||||||
|
downloadGoogle("event", [
|
||||||
|
{
|
||||||
|
startsAt: "2021-10-12T15:00:00.000Z",
|
||||||
|
endsAt: "2021-10-12T16:00:00.000Z",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
assert.ok(
|
||||||
|
window.open.calledWith(
|
||||||
|
"https://www.google.com/calendar/event?action=TEMPLATE&text=event&dates=20211012T150000Z/20211012T160000Z",
|
||||||
|
"_blank",
|
||||||
|
"noopener",
|
||||||
|
"noreferrer"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("calculates end date when none given", function (assert) {
|
||||||
|
let dates = formatDates([{ startsAt: "2021-10-12T15:00:00.000Z" }]);
|
||||||
|
assert.deepEqual(
|
||||||
|
dates,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
startsAt: "2021-10-12T15:00:00.000Z",
|
||||||
|
endsAt: "2021-10-12T16:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"endsAt is one hour after startsAt"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
|
@ -20,6 +20,7 @@
|
||||||
@import "pick-files-button";
|
@import "pick-files-button";
|
||||||
@import "relative-time-picker";
|
@import "relative-time-picker";
|
||||||
@import "share-and-invite-modal";
|
@import "share-and-invite-modal";
|
||||||
|
@import "download-calendar";
|
||||||
@import "svg";
|
@import "svg";
|
||||||
@import "tap-tile";
|
@import "tap-tile";
|
||||||
@import "time-input";
|
@import "time-input";
|
||||||
|
|
|
@ -0,0 +1,13 @@
|
||||||
|
.download-calendar-modal .remember {
|
||||||
|
margin-top: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#d-popover .download-calendar {
|
||||||
|
color: var(--primary-med-or-secondary-med);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-preferences {
|
||||||
|
#user-default-calendar {
|
||||||
|
min-width: 175px;
|
||||||
|
}
|
||||||
|
}
|
31
app/controllers/calendars_controller.rb
Normal file
31
app/controllers/calendars_controller.rb
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class CalendarsController < ApplicationController
|
||||||
|
skip_before_action :check_xhr, only: [ :index ], if: :ics_request?
|
||||||
|
requires_login
|
||||||
|
|
||||||
|
def download
|
||||||
|
@post = Post.find(calendar_params[:post_id])
|
||||||
|
@title = calendar_params[:title]
|
||||||
|
@dates = calendar_params[:dates].values
|
||||||
|
|
||||||
|
guardian.ensure_can_see!(@post)
|
||||||
|
|
||||||
|
respond_to do |format|
|
||||||
|
format.ics do
|
||||||
|
filename = "events-#{@title.parameterize}"
|
||||||
|
response.headers['Content-Disposition'] = "attachment; filename=\"#{filename}.#{request.format.symbol}\""
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def ics_request?
|
||||||
|
request.format.symbol == :ics
|
||||||
|
end
|
||||||
|
|
||||||
|
def calendar_params
|
||||||
|
params.permit(:post_id, :title, dates: [:starts_at, :ends_at])
|
||||||
|
end
|
||||||
|
end
|
|
@ -11,6 +11,8 @@ class UserOption < ActiveRecord::Base
|
||||||
|
|
||||||
after_save :update_tracked_topics
|
after_save :update_tracked_topics
|
||||||
|
|
||||||
|
enum default_calendar: { none_selected: 0, ics: 1, google: 2 }
|
||||||
|
|
||||||
def self.ensure_consistency!
|
def self.ensure_consistency!
|
||||||
sql = <<~SQL
|
sql = <<~SQL
|
||||||
SELECT u.id FROM users u
|
SELECT u.id FROM users u
|
||||||
|
@ -256,8 +258,10 @@ end
|
||||||
# dark_scheme_id :integer
|
# dark_scheme_id :integer
|
||||||
# skip_new_user_tips :boolean default(FALSE), not null
|
# skip_new_user_tips :boolean default(FALSE), not null
|
||||||
# color_scheme_id :integer
|
# color_scheme_id :integer
|
||||||
|
# default_calendar :integer default("none_selected"), not null
|
||||||
#
|
#
|
||||||
# Indexes
|
# Indexes
|
||||||
#
|
#
|
||||||
# index_user_options_on_user_id (user_id) UNIQUE
|
# index_user_options_on_user_id (user_id) UNIQUE
|
||||||
|
# index_user_options_on_user_id_and_default_calendar (user_id,default_calendar)
|
||||||
#
|
#
|
||||||
|
|
|
@ -66,6 +66,7 @@ class CurrentUserSerializer < BasicUserSerializer
|
||||||
:has_topic_draft,
|
:has_topic_draft,
|
||||||
:can_review,
|
:can_review,
|
||||||
:draft_count,
|
:draft_count,
|
||||||
|
:default_calendar,
|
||||||
|
|
||||||
def groups
|
def groups
|
||||||
owned_group_ids = GroupUser.where(user_id: id, owner: true).pluck(:group_id).to_set
|
owned_group_ids = GroupUser.where(user_id: id, owner: true).pluck(:group_id).to_set
|
||||||
|
@ -140,6 +141,10 @@ class CurrentUserSerializer < BasicUserSerializer
|
||||||
object.user_option.timezone
|
object.user_option.timezone
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def default_calendar
|
||||||
|
object.user_option.default_calendar
|
||||||
|
end
|
||||||
|
|
||||||
def can_send_private_email_messages
|
def can_send_private_email_messages
|
||||||
scope.can_send_private_messages_to_email?
|
scope.can_send_private_messages_to_email?
|
||||||
end
|
end
|
||||||
|
|
|
@ -32,7 +32,8 @@ class UserOptionSerializer < ApplicationSerializer
|
||||||
:text_size_seq,
|
:text_size_seq,
|
||||||
:title_count_mode,
|
:title_count_mode,
|
||||||
:timezone,
|
:timezone,
|
||||||
:skip_new_user_tips
|
:skip_new_user_tips,
|
||||||
|
:default_calendar,
|
||||||
|
|
||||||
def auto_track_topics_after_msecs
|
def auto_track_topics_after_msecs
|
||||||
object.auto_track_topics_after_msecs || SiteSetting.default_other_auto_track_topics_after_msecs
|
object.auto_track_topics_after_msecs || SiteSetting.default_other_auto_track_topics_after_msecs
|
||||||
|
|
|
@ -46,7 +46,8 @@ class UserUpdater
|
||||||
:text_size,
|
:text_size,
|
||||||
:title_count_mode,
|
:title_count_mode,
|
||||||
:timezone,
|
:timezone,
|
||||||
:skip_new_user_tips
|
:skip_new_user_tips,
|
||||||
|
:default_calendar
|
||||||
]
|
]
|
||||||
|
|
||||||
NOTIFICATION_SCHEDULE_ATTRS = -> {
|
NOTIFICATION_SCHEDULE_ATTRS = -> {
|
||||||
|
|
15
app/views/calendars/download.ics.erb
Normal file
15
app/views/calendars/download.ics.erb
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
PRODID:-//Discourse//<%= Discourse.current_hostname %>//<%= Discourse.full_version %>//EN
|
||||||
|
<% @dates.each do |date, index| %>
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:post_#<%= @post.id %>_<%= date[:starts_at].to_datetime.to_i %>_<%= date[:ends_at].to_datetime.to_i %>@<%= Discourse.current_hostname %>
|
||||||
|
DTSTAMP:<%= Time.now.utc.strftime("%Y%m%dT%H%M%SZ") %>
|
||||||
|
DTSTART:<%= date[:starts_at].to_datetime.strftime("%Y%m%dT%H%M%SZ") %>
|
||||||
|
DTEND:<%= date[:ends_at].presence ? date[:ends_at].to_datetime.strftime("%Y%m%dT%H%M%SZ") : (date[:starts_at].to_datetime + 1.hour).strftime("%Y%m%dT%H%M%SZ") %>
|
||||||
|
SUMMARY:<%= @title %>
|
||||||
|
DESCRIPTION:<%= PrettyText.format_for_email(@post.excerpt, @post).html_safe %>
|
||||||
|
URL:<%= Discourse.base_url %>/t/-/<%= @post.topic_id %>/<%= @post.post_number %>
|
||||||
|
END:VEVENT
|
||||||
|
<% end %>
|
||||||
|
END:VCALENDAR
|
|
@ -3723,6 +3723,18 @@ en:
|
||||||
favorite_max_not_reached: "Mark this badge as favorite"
|
favorite_max_not_reached: "Mark this badge as favorite"
|
||||||
favorite_count: "%{count}/%{max} badges marked as favorite"
|
favorite_count: "%{count}/%{max} badges marked as favorite"
|
||||||
|
|
||||||
|
download_calendar:
|
||||||
|
title: "Download calendar"
|
||||||
|
save_ics: "Download .ics file"
|
||||||
|
save_google: "Add to Google calendar"
|
||||||
|
remember: "Don’t ask me again"
|
||||||
|
remember_explanation: "(you can change this preference in your user prefs)"
|
||||||
|
download: "Download"
|
||||||
|
default_calendar: "Default calendar"
|
||||||
|
default_calendar_instruction: "Determine which calendar should be used when dates are saved"
|
||||||
|
add_to_calendar: "Add to calendar"
|
||||||
|
google: "Google Calendar"
|
||||||
|
ics: "ICS"
|
||||||
tagging:
|
tagging:
|
||||||
all_tags: "All Tags"
|
all_tags: "All Tags"
|
||||||
other_tags: "Other Tags"
|
other_tags: "Other Tags"
|
||||||
|
|
|
@ -650,6 +650,8 @@ Discourse::Application.routes.draw do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
get "/calendars" => "calendars#download", constraints: { format: :ics }
|
||||||
|
|
||||||
resources :bookmarks, only: %i[create destroy update] do
|
resources :bookmarks, only: %i[create destroy update] do
|
||||||
put "toggle_pin"
|
put "toggle_pin"
|
||||||
end
|
end
|
||||||
|
|
|
@ -0,0 +1,8 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class AddDefaultCalendarToUserOptions < ActiveRecord::Migration[6.1]
|
||||||
|
def change
|
||||||
|
add_column :user_options, :default_calendar, :integer, default: 0, null: false
|
||||||
|
add_index :user_options, [:user_id, :default_calendar]
|
||||||
|
end
|
||||||
|
end
|
|
@ -4,6 +4,8 @@ import { hidePopover, showPopover } from "discourse/lib/d-popover";
|
||||||
import LocalDateBuilder from "../lib/local-date-builder";
|
import LocalDateBuilder from "../lib/local-date-builder";
|
||||||
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 { downloadCalendar } from "discourse/lib/download-calendar";
|
||||||
|
import { renderIcon } from "discourse-common/lib/icon-library";
|
||||||
|
|
||||||
export function applyLocalDates(dates, siteSettings) {
|
export function applyLocalDates(dates, siteSettings) {
|
||||||
if (!siteSettings.discourse_local_dates_enabled) {
|
if (!siteSettings.discourse_local_dates_enabled) {
|
||||||
|
@ -162,9 +164,54 @@ function buildHtmlPreview(element, siteSettings) {
|
||||||
previewsNode.classList.add("locale-dates-previews");
|
previewsNode.classList.add("locale-dates-previews");
|
||||||
htmlPreviews.forEach((htmlPreview) => previewsNode.appendChild(htmlPreview));
|
htmlPreviews.forEach((htmlPreview) => previewsNode.appendChild(htmlPreview));
|
||||||
|
|
||||||
|
previewsNode.appendChild(_downloadCalendarNode(element));
|
||||||
|
|
||||||
return previewsNode.outerHTML;
|
return previewsNode.outerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _downloadCalendarNode(element) {
|
||||||
|
const node = document.createElement("div");
|
||||||
|
node.classList.add("download-calendar");
|
||||||
|
node.innerHTML = `${renderIcon("string", "file")} ${I18n.t(
|
||||||
|
"download_calendar.add_to_calendar"
|
||||||
|
)}`;
|
||||||
|
const [startDataset, endDataset] = _rangeElements(element).map(
|
||||||
|
(dateElement) => dateElement.dataset
|
||||||
|
);
|
||||||
|
node.setAttribute(
|
||||||
|
"data-starts-at",
|
||||||
|
moment
|
||||||
|
.tz(
|
||||||
|
`${startDataset.date} ${startDataset.time || ""}`,
|
||||||
|
startDataset.timezone
|
||||||
|
)
|
||||||
|
.toISOString()
|
||||||
|
);
|
||||||
|
if (endDataset) {
|
||||||
|
node.setAttribute(
|
||||||
|
"data-ends-at",
|
||||||
|
moment
|
||||||
|
.tz(`${endDataset.date} ${endDataset.time || ""}`, endDataset.timezone)
|
||||||
|
.toISOString()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!startDataset.time && !endDataset) {
|
||||||
|
node.setAttribute(
|
||||||
|
"data-ends-at",
|
||||||
|
moment
|
||||||
|
.tz(`${startDataset.date}`, startDataset.timezone)
|
||||||
|
.add(24, "hours")
|
||||||
|
.toISOString()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
node.setAttribute(
|
||||||
|
"data-title",
|
||||||
|
document.querySelector("#topic-title a").innerText
|
||||||
|
);
|
||||||
|
node.setAttribute("data-post-id", element.closest("article").dataset.postId);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
function _calculateDuration(element) {
|
function _calculateDuration(element) {
|
||||||
const [startDataset, endDataset] = _rangeElements(element).map(
|
const [startDataset, endDataset] = _rangeElements(element).map(
|
||||||
(dateElement) => dateElement.dataset
|
(dateElement) => dateElement.dataset
|
||||||
|
@ -199,6 +246,17 @@ export default {
|
||||||
htmlContent: buildHtmlPreview(event.target, siteSettings),
|
htmlContent: buildHtmlPreview(event.target, siteSettings),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} else if (event?.target?.classList?.contains("download-calendar")) {
|
||||||
|
const dataset = event.target.dataset;
|
||||||
|
hidePopover(event);
|
||||||
|
downloadCalendar(dataset.postId, dataset.title, [
|
||||||
|
{
|
||||||
|
startsAt: dataset.startsAt,
|
||||||
|
endsAt: dataset.endsAt,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
hidePopover(event);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -213,7 +271,6 @@ export default {
|
||||||
router.on("routeWillChange", hidePopover);
|
router.on("routeWillChange", hidePopover);
|
||||||
|
|
||||||
window.addEventListener("click", this.showDatePopover);
|
window.addEventListener("click", this.showDatePopover);
|
||||||
window.addEventListener("mouseout", this.hideDatePopover);
|
|
||||||
|
|
||||||
const siteSettings = container.lookup("site-settings:main");
|
const siteSettings = container.lookup("site-settings:main");
|
||||||
if (siteSettings.discourse_local_dates_enabled) {
|
if (siteSettings.discourse_local_dates_enabled) {
|
||||||
|
@ -231,6 +288,5 @@ export default {
|
||||||
|
|
||||||
teardown() {
|
teardown() {
|
||||||
window.removeEventListener("click", this.showDatePopover);
|
window.removeEventListener("click", this.showDatePopover);
|
||||||
window.removeEventListener("mouseout", this.hideDatePopover);
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -40,6 +40,12 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.download-calendar {
|
||||||
|
text-align: right;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: 0.5em;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.discourse-local-dates-create-modal-footer {
|
.discourse-local-dates-create-modal-footer {
|
||||||
|
|
|
@ -0,0 +1,68 @@
|
||||||
|
import {
|
||||||
|
acceptance,
|
||||||
|
exists,
|
||||||
|
query,
|
||||||
|
} from "discourse/tests/helpers/qunit-helpers";
|
||||||
|
import { click, visit } from "@ember/test-helpers";
|
||||||
|
import I18n from "I18n";
|
||||||
|
import { test } from "qunit";
|
||||||
|
import { fixturesByUrl } from "discourse/tests/helpers/create-pretender";
|
||||||
|
import sinon from "sinon";
|
||||||
|
|
||||||
|
acceptance(
|
||||||
|
"Local Dates - Download calendar without default calendar option set",
|
||||||
|
function (needs) {
|
||||||
|
needs.user({ default_calendar: "none_selected" });
|
||||||
|
needs.settings({ discourse_local_dates_enabled: true });
|
||||||
|
needs.pretender((server, helper) => {
|
||||||
|
const response = { ...fixturesByUrl["/t/281.json"] };
|
||||||
|
server.get("/t/281.json", () => helper.response(response));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Display pick calendar modal", async function (assert) {
|
||||||
|
await visit("/t/local-dates/281");
|
||||||
|
|
||||||
|
await click(".discourse-local-date");
|
||||||
|
await click(document.querySelector(".download-calendar"));
|
||||||
|
assert.equal(
|
||||||
|
query("#discourse-modal-title").textContent.trim(),
|
||||||
|
I18n.t("download_calendar.title"),
|
||||||
|
"it should display modal to select calendar"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
acceptance(
|
||||||
|
"Local Dates - Download calendar with default calendar option set",
|
||||||
|
function (needs) {
|
||||||
|
needs.user({ default_calendar: "google" });
|
||||||
|
needs.settings({ discourse_local_dates_enabled: true });
|
||||||
|
needs.pretender((server, helper) => {
|
||||||
|
const response = { ...fixturesByUrl["/t/281.json"] };
|
||||||
|
server.get("/t/281.json", () => helper.response(response));
|
||||||
|
});
|
||||||
|
|
||||||
|
needs.hooks.beforeEach(function () {
|
||||||
|
let win = { focus: function () {} };
|
||||||
|
sinon.stub(window, "open").returns(win);
|
||||||
|
sinon.stub(win, "focus");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("saves into default calendar", async function (assert) {
|
||||||
|
await visit("/t/local-dates/281");
|
||||||
|
|
||||||
|
await click(".discourse-local-date");
|
||||||
|
await click(document.querySelector(".download-calendar"));
|
||||||
|
assert.ok(!exists(document.querySelector("#discourse-modal-title")));
|
||||||
|
assert.ok(
|
||||||
|
window.open.calledWith(
|
||||||
|
"https://www.google.com/calendar/event?action=TEMPLATE&text=Local%20dates%20&dates=20210930T110000Z/20210930T120000Z",
|
||||||
|
"_blank",
|
||||||
|
"noopener",
|
||||||
|
"noreferrer"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
|
@ -775,6 +775,9 @@
|
||||||
},
|
},
|
||||||
"skip_new_user_tips": {
|
"skip_new_user_tips": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"default_calendar": {
|
||||||
|
"type": "none_selected"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
|
|
53
spec/requests/calendars_controller_spec.rb
Normal file
53
spec/requests/calendars_controller_spec.rb
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
describe CalendarsController do
|
||||||
|
fab!(:user) { Fabricate(:user) }
|
||||||
|
fab!(:post) { Fabricate(:post) }
|
||||||
|
|
||||||
|
describe "#download" do
|
||||||
|
it "returns an .ics file for dates" do
|
||||||
|
sign_in(user)
|
||||||
|
get "/calendars.ics", params: {
|
||||||
|
post_id: post.id,
|
||||||
|
title: "event title",
|
||||||
|
dates: {
|
||||||
|
"0": {
|
||||||
|
starts_at: "2021-10-12T15:00:00.000Z",
|
||||||
|
ends_at: "2021-10-13T16:30:00.000Z",
|
||||||
|
},
|
||||||
|
"1": {
|
||||||
|
starts_at: "2021-10-15T17:00:00.000Z",
|
||||||
|
ends_at: "2021-10-15T18:00:00.000Z",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(response.status).to eq(200)
|
||||||
|
expect(response.body).to eq(<<~ICS)
|
||||||
|
BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
PRODID:-//Discourse//#{Discourse.current_hostname}//#{Discourse.full_version}//EN
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:post_##{post.id}_#{"2021-10-12T15:00:00.000Z".to_datetime.to_i}_#{"2021-10-13T16:30:00.000Z".to_datetime.to_i}@#{Discourse.current_hostname}
|
||||||
|
DTSTAMP:#{Time.now.utc.strftime("%Y%m%dT%H%M%SZ")}
|
||||||
|
DTSTART:#{"2021-10-12T15:00:00.000Z".to_datetime.strftime("%Y%m%dT%H%M%SZ")}
|
||||||
|
DTEND:#{"2021-10-13T16:30:00.000Z".to_datetime.strftime("%Y%m%dT%H%M%SZ")}
|
||||||
|
SUMMARY:event title
|
||||||
|
DESCRIPTION:Hello world
|
||||||
|
URL:#{Discourse.base_url}/t/-/#{post.topic_id}/#{post.post_number}
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:post_##{post.id}_#{"2021-10-15T17:00:00.000Z".to_datetime.to_i}_#{"2021-10-15T18:00:00.000Z".to_datetime.to_i}@#{Discourse.current_hostname}
|
||||||
|
DTSTAMP:#{Time.now.utc.strftime("%Y%m%dT%H%M%SZ")}
|
||||||
|
DTSTART:#{"2021-10-15T17:00:00.000Z".to_datetime.strftime("%Y%m%dT%H%M%SZ")}
|
||||||
|
DTEND:#{"2021-10-15T18:00:00.000Z".to_datetime.strftime("%Y%m%dT%H%M%SZ")}
|
||||||
|
SUMMARY:event title
|
||||||
|
DESCRIPTION:Hello world
|
||||||
|
URL:#{Discourse.base_url}/t/-/#{post.topic_id}/#{post.post_number}
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR
|
||||||
|
ICS
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
Loading…
Reference in New Issue
Block a user