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/eyeline
|
||||
//= require ./discourse/app/lib/show-modal
|
||||
//= require ./discourse/app/lib/download-calendar
|
||||
//= require ./discourse/app/mixins/scrolling
|
||||
//= require ./discourse/app/lib/ajax-error
|
||||
//= 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",
|
||||
"date_of_birth",
|
||||
"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"),
|
||||
|
||||
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",
|
||||
"timezone",
|
||||
"skip_new_user_tips",
|
||||
"default_calendar",
|
||||
];
|
||||
|
||||
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>
|
||||
{{/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-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,
|
||||
},
|
||||
"/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": {
|
||||
post_stream: {
|
||||
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 "relative-time-picker";
|
||||
@import "share-and-invite-modal";
|
||||
@import "download-calendar";
|
||||
@import "svg";
|
||||
@import "tap-tile";
|
||||
@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;
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
||||
enum default_calendar: { none_selected: 0, ics: 1, google: 2 }
|
||||
|
||||
def self.ensure_consistency!
|
||||
sql = <<~SQL
|
||||
SELECT u.id FROM users u
|
||||
|
@ -256,8 +258,10 @@ end
|
|||
# dark_scheme_id :integer
|
||||
# skip_new_user_tips :boolean default(FALSE), not null
|
||||
# color_scheme_id :integer
|
||||
# default_calendar :integer default("none_selected"), not null
|
||||
#
|
||||
# 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,
|
||||
:can_review,
|
||||
:draft_count,
|
||||
:default_calendar,
|
||||
|
||||
def groups
|
||||
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
|
||||
end
|
||||
|
||||
def default_calendar
|
||||
object.user_option.default_calendar
|
||||
end
|
||||
|
||||
def can_send_private_email_messages
|
||||
scope.can_send_private_messages_to_email?
|
||||
end
|
||||
|
|
|
@ -32,7 +32,8 @@ class UserOptionSerializer < ApplicationSerializer
|
|||
:text_size_seq,
|
||||
:title_count_mode,
|
||||
:timezone,
|
||||
:skip_new_user_tips
|
||||
:skip_new_user_tips,
|
||||
:default_calendar,
|
||||
|
||||
def 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,
|
||||
:title_count_mode,
|
||||
:timezone,
|
||||
:skip_new_user_tips
|
||||
:skip_new_user_tips,
|
||||
:default_calendar
|
||||
]
|
||||
|
||||
NOTIFICATION_SCHEDULE_ATTRS = -> {
|
||||
|
|
|
@ -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_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:
|
||||
all_tags: "All Tags"
|
||||
other_tags: "Other Tags"
|
||||
|
|
|
@ -650,6 +650,8 @@ Discourse::Application.routes.draw do
|
|||
end
|
||||
end
|
||||
|
||||
get "/calendars" => "calendars#download", constraints: { format: :ics }
|
||||
|
||||
resources :bookmarks, only: %i[create destroy update] do
|
||||
put "toggle_pin"
|
||||
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 { withPluginApi } from "discourse/lib/plugin-api";
|
||||
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) {
|
||||
if (!siteSettings.discourse_local_dates_enabled) {
|
||||
|
@ -162,9 +164,54 @@ function buildHtmlPreview(element, siteSettings) {
|
|||
previewsNode.classList.add("locale-dates-previews");
|
||||
htmlPreviews.forEach((htmlPreview) => previewsNode.appendChild(htmlPreview));
|
||||
|
||||
previewsNode.appendChild(_downloadCalendarNode(element));
|
||||
|
||||
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) {
|
||||
const [startDataset, endDataset] = _rangeElements(element).map(
|
||||
(dateElement) => dateElement.dataset
|
||||
|
@ -199,6 +246,17 @@ export default {
|
|||
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);
|
||||
|
||||
window.addEventListener("click", this.showDatePopover);
|
||||
window.addEventListener("mouseout", this.hideDatePopover);
|
||||
|
||||
const siteSettings = container.lookup("site-settings:main");
|
||||
if (siteSettings.discourse_local_dates_enabled) {
|
||||
|
@ -231,6 +288,5 @@ export default {
|
|||
|
||||
teardown() {
|
||||
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 {
|
||||
|
|
|
@ -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": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"default_calendar": {
|
||||
"type": "none_selected"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
|
|
@ -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