From cb5b0cb9d833345798dc93e9eaed6ac68c95e038 Mon Sep 17 00:00:00 2001 From: Krzysztof Kotlarek Date: Wed, 6 Oct 2021 14:11:52 +1100 Subject: [PATCH] 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. --- app/assets/javascripts/application.js | 1 + .../app/controllers/download-calendar.js | 26 +++ .../app/controllers/preferences/profile.js | 11 + .../discourse/app/lib/download-calendar.js | 67 ++++++ .../javascripts/discourse/app/models/user.js | 1 + .../app/templates/modal/download-calendar.hbs | 45 +++++ .../app/templates/preferences/profile.hbs | 18 ++ .../user-preferences-profile-test.js | 35 ++++ .../discourse/tests/fixtures/topic.js | 190 ++++++++++++++++++ .../tests/unit/lib/download-calendar-test.js | 63 ++++++ .../stylesheets/common/components/_index.scss | 1 + .../common/components/download-calendar.scss | 13 ++ app/controllers/calendars_controller.rb | 31 +++ app/models/user_option.rb | 6 +- app/serializers/current_user_serializer.rb | 5 + app/serializers/user_option_serializer.rb | 3 +- app/services/user_updater.rb | 3 +- app/views/calendars/download.ics.erb | 15 ++ config/locales/client.en.yml | 12 ++ config/routes.rb | 2 + ...53_add_default_calendar_to_user_options.rb | 8 + .../initializers/discourse-local-dates.js.es6 | 60 +++++- .../common/discourse-local-dates.scss | 6 + .../acceptance/download-calendar-test.js.es6 | 68 +++++++ .../api/schemas/json/user_get_response.json | 3 + spec/requests/calendars_controller_spec.rb | 53 +++++ 26 files changed, 741 insertions(+), 5 deletions(-) create mode 100644 app/assets/javascripts/discourse/app/controllers/download-calendar.js create mode 100644 app/assets/javascripts/discourse/app/lib/download-calendar.js create mode 100644 app/assets/javascripts/discourse/app/templates/modal/download-calendar.hbs create mode 100644 app/assets/javascripts/discourse/tests/acceptance/user-preferences-profile-test.js create mode 100644 app/assets/javascripts/discourse/tests/unit/lib/download-calendar-test.js create mode 100644 app/assets/stylesheets/common/components/download-calendar.scss create mode 100644 app/controllers/calendars_controller.rb create mode 100644 app/views/calendars/download.ics.erb create mode 100644 db/migrate/20210920044353_add_default_calendar_to_user_options.rb create mode 100644 plugins/discourse-local-dates/test/javascripts/acceptance/download-calendar-test.js.es6 create mode 100644 spec/requests/calendars_controller_spec.rb diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 0e9654e0ddb..6ddaadf6db3 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -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 diff --git a/app/assets/javascripts/discourse/app/controllers/download-calendar.js b/app/assets/javascripts/discourse/app/controllers/download-calendar.js new file mode 100644 index 00000000000..a956fb1c180 --- /dev/null +++ b/app/assets/javascripts/discourse/app/controllers/download-calendar.js @@ -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"); + }, +}); diff --git a/app/assets/javascripts/discourse/app/controllers/preferences/profile.js b/app/assets/javascripts/discourse/app/controllers/preferences/profile.js index d6a40c3c2cc..5356a8a0b9b 100644 --- a/app/assets/javascripts/discourse/app/controllers/preferences/profile.js +++ b/app/assets/javascripts/discourse/app/controllers/preferences/profile.js @@ -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"), diff --git a/app/assets/javascripts/discourse/app/lib/download-calendar.js b/app/assets/javascripts/discourse/app/lib/download-calendar.js new file mode 100644 index 00000000000..de1b615b174 --- /dev/null +++ b/app/assets/javascripts/discourse/app/lib/download-calendar.js @@ -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, ""); +} diff --git a/app/assets/javascripts/discourse/app/models/user.js b/app/assets/javascripts/discourse/app/models/user.js index 775b40db8ee..d449e14a65e 100644 --- a/app/assets/javascripts/discourse/app/models/user.js +++ b/app/assets/javascripts/discourse/app/models/user.js @@ -97,6 +97,7 @@ let userOptionFields = [ "title_count_mode", "timezone", "skip_new_user_tips", + "default_calendar", ]; export function addSaveableUserOptionField(fieldName) { diff --git a/app/assets/javascripts/discourse/app/templates/modal/download-calendar.hbs b/app/assets/javascripts/discourse/app/templates/modal/download-calendar.hbs new file mode 100644 index 00000000000..68f5236ef77 --- /dev/null +++ b/app/assets/javascripts/discourse/app/templates/modal/download-calendar.hbs @@ -0,0 +1,45 @@ +
+ {{#d-modal-body title="download_calendar.title"}} +
+
+ +
+
+ +
+
+ +
+ + {{i18n "download_calendar.remember_explanation"}} +
+ {{/d-modal-body}} + +
diff --git a/app/assets/javascripts/discourse/app/templates/preferences/profile.hbs b/app/assets/javascripts/discourse/app/templates/preferences/profile.hbs index f9447606d5e..a0f53843151 100644 --- a/app/assets/javascripts/discourse/app/templates/preferences/profile.hbs +++ b/app/assets/javascripts/discourse/app/templates/preferences/profile.hbs @@ -103,6 +103,24 @@ {{/if}} +{{#if canChangeDefaultCalendar }} +
+ +
+ {{combo-box + valueProperty="value" + content=calendarOptions + value=model.user_option.default_calendar + id="user-default-calendar" + onChange=(action (mut model.user_option.default_calendar)) + }} +
+
+ {{i18n "download_calendar.default_calendar_instruction"}} +
+
+{{/if}} + {{plugin-outlet name="user-preferences-profile" args=(hash model=model save=(action "save"))}} {{plugin-outlet name="user-custom-preferences" args=(hash model=model)}} diff --git a/app/assets/javascripts/discourse/tests/acceptance/user-preferences-profile-test.js b/app/assets/javascripts/discourse/tests/acceptance/user-preferences-profile-test.js new file mode 100644 index 00000000000..3abd43494c5 --- /dev/null +++ b/app/assets/javascripts/discourse/tests/acceptance/user-preferences-profile-test.js @@ -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" + ); + }); + } +); diff --git a/app/assets/javascripts/discourse/tests/fixtures/topic.js b/app/assets/javascripts/discourse/tests/fixtures/topic.js index d6f982021b4..096b7f20597 100644 --- a/app/assets/javascripts/discourse/tests/fixtures/topic.js +++ b/app/assets/javascripts/discourse/tests/fixtures/topic.js @@ -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: + "

2021-09-30T11:00:00Z

", + 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: [ diff --git a/app/assets/javascripts/discourse/tests/unit/lib/download-calendar-test.js b/app/assets/javascripts/discourse/tests/unit/lib/download-calendar-test.js new file mode 100644 index 00000000000..09bfc9614ee --- /dev/null +++ b/app/assets/javascripts/discourse/tests/unit/lib/download-calendar-test.js @@ -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" + ); + }); +}); diff --git a/app/assets/stylesheets/common/components/_index.scss b/app/assets/stylesheets/common/components/_index.scss index 02745f3cf00..33291330225 100644 --- a/app/assets/stylesheets/common/components/_index.scss +++ b/app/assets/stylesheets/common/components/_index.scss @@ -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"; diff --git a/app/assets/stylesheets/common/components/download-calendar.scss b/app/assets/stylesheets/common/components/download-calendar.scss new file mode 100644 index 00000000000..7425ec06b6f --- /dev/null +++ b/app/assets/stylesheets/common/components/download-calendar.scss @@ -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; + } +} diff --git a/app/controllers/calendars_controller.rb b/app/controllers/calendars_controller.rb new file mode 100644 index 00000000000..d1f28ed01f7 --- /dev/null +++ b/app/controllers/calendars_controller.rb @@ -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 diff --git a/app/models/user_option.rb b/app/models/user_option.rb index 0adcf21d0f5..47afb7d2d29 100644 --- a/app/models/user_option.rb +++ b/app/models/user_option.rb @@ -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) # diff --git a/app/serializers/current_user_serializer.rb b/app/serializers/current_user_serializer.rb index 5bd1e7e0157..4740969dc79 100644 --- a/app/serializers/current_user_serializer.rb +++ b/app/serializers/current_user_serializer.rb @@ -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 diff --git a/app/serializers/user_option_serializer.rb b/app/serializers/user_option_serializer.rb index ed47d0e6dbe..a4c9826ba3c 100644 --- a/app/serializers/user_option_serializer.rb +++ b/app/serializers/user_option_serializer.rb @@ -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 diff --git a/app/services/user_updater.rb b/app/services/user_updater.rb index 84446b98794..49680afd1af 100644 --- a/app/services/user_updater.rb +++ b/app/services/user_updater.rb @@ -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 = -> { diff --git a/app/views/calendars/download.ics.erb b/app/views/calendars/download.ics.erb new file mode 100644 index 00000000000..055170d8b91 --- /dev/null +++ b/app/views/calendars/download.ics.erb @@ -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 diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index d0e40469195..7c915104b91 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -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" diff --git a/config/routes.rb b/config/routes.rb index adc5edcea69..8156fc40a71 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/db/migrate/20210920044353_add_default_calendar_to_user_options.rb b/db/migrate/20210920044353_add_default_calendar_to_user_options.rb new file mode 100644 index 00000000000..0bbc5a3c73b --- /dev/null +++ b/db/migrate/20210920044353_add_default_calendar_to_user_options.rb @@ -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 diff --git a/plugins/discourse-local-dates/assets/javascripts/initializers/discourse-local-dates.js.es6 b/plugins/discourse-local-dates/assets/javascripts/initializers/discourse-local-dates.js.es6 index 10776b2892c..19a9be3843e 100644 --- a/plugins/discourse-local-dates/assets/javascripts/initializers/discourse-local-dates.js.es6 +++ b/plugins/discourse-local-dates/assets/javascripts/initializers/discourse-local-dates.js.es6 @@ -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); }, }; diff --git a/plugins/discourse-local-dates/assets/stylesheets/common/discourse-local-dates.scss b/plugins/discourse-local-dates/assets/stylesheets/common/discourse-local-dates.scss index 3c7db2310ae..4f9760ca7cd 100644 --- a/plugins/discourse-local-dates/assets/stylesheets/common/discourse-local-dates.scss +++ b/plugins/discourse-local-dates/assets/stylesheets/common/discourse-local-dates.scss @@ -40,6 +40,12 @@ } } } + + .download-calendar { + text-align: right; + cursor: pointer; + margin-top: 0.5em; + } } .discourse-local-dates-create-modal-footer { diff --git a/plugins/discourse-local-dates/test/javascripts/acceptance/download-calendar-test.js.es6 b/plugins/discourse-local-dates/test/javascripts/acceptance/download-calendar-test.js.es6 new file mode 100644 index 00000000000..32d7963d9c0 --- /dev/null +++ b/plugins/discourse-local-dates/test/javascripts/acceptance/download-calendar-test.js.es6 @@ -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" + ) + ); + }); + } +); diff --git a/spec/requests/api/schemas/json/user_get_response.json b/spec/requests/api/schemas/json/user_get_response.json index b2a18ca0b52..81d1662b483 100644 --- a/spec/requests/api/schemas/json/user_get_response.json +++ b/spec/requests/api/schemas/json/user_get_response.json @@ -775,6 +775,9 @@ }, "skip_new_user_tips": { "type": "boolean" + }, + "default_calendar": { + "type": "none_selected" } }, "required": [ diff --git a/spec/requests/calendars_controller_spec.rb b/spec/requests/calendars_controller_spec.rb new file mode 100644 index 00000000000..43db456216c --- /dev/null +++ b/spec/requests/calendars_controller_spec.rb @@ -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