Skip to content

Commit d72e3c9

Browse files
committed
Add a dialog to the switching-devices form wizard for calendar reminders.
The dialog also allows the user to copy the download URL to their clipboard. The dialog is not yet wired up to anything, but can be invoked by calling `openReminderDialog()` on the setup-device-step element. In this patch, I also needed to update some pre-existing tests to clean up their spies/stubs so that the form-wizard-reminder-dialog-tests.js could run correctly. Fixes mozilla/sumo#1494.
1 parent 2673667 commit d72e3c9

10 files changed

Lines changed: 637 additions & 1 deletion
Lines changed: 4 additions & 0 deletions
Loading
Lines changed: 347 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,347 @@
1+
import reminderDialogStylesURL from "../scss/reminder-dialog.styles.scss";
2+
import closeIconURL from "sumo/img/close.svg";
3+
import successIconUrl from "sumo/img/success.svg";
4+
5+
const NEW_DEVICE_DOWNLOAD_URL = "https://mzl.la/newdevice";
6+
7+
const CALENDAR_FORMATS = Object.freeze({
8+
ICAL: 1,
9+
OUTLOOK: 2,
10+
GCAL: 3,
11+
});
12+
13+
/**
14+
* The Form Wizard can open a ReminderDialog, which is a <dialog>
15+
* that lets the user create calendar events for themselves to
16+
* remind them download and install Firefox on their new devices.
17+
*
18+
* The ReminderDialog also lets users copy the download link to
19+
* their clipboard, in the event that they want to send themselves
20+
* the link via text or email.
21+
*/
22+
export class ReminderDialog extends HTMLDialogElement {
23+
#shadow = null;
24+
25+
static get REMINDER_DAYS_IN_THE_FUTURE() {
26+
return 14;
27+
}
28+
29+
static get markup() {
30+
return `
31+
<template>
32+
<div id="reminder-dialog-content">
33+
<div id="header" class="hbox">
34+
<h1 class="text-display-xxs">${gettext("Add to calendar")}</h1>
35+
<button id="close" class="mzp-c-button mzp-t-neutral" aria-label="${gettext("Close")}" data-event-category="device-migration-wizard" data-event-action="click" data-event-label="close-reminder-dialog"><img src="${closeIconURL}" aria-hidden="true"/></button>
36+
</div>
37+
<div class="vbox">
38+
<div id="directions">${gettext("Save the download link to your calendar and finish the download whenever you’re ready.")}</div>
39+
<label for="choose-calendar">${gettext("Choose calendar")}</label>
40+
<div class="hbox">
41+
<select id="choose-calendar">
42+
<option value="gcal">Google Calendar</option>
43+
<option value="outlook">Outlook.com</option>
44+
<option value="ics">${gettext("Other calendar")}</option>
45+
</select>
46+
<button id="create-event" class="mzp-c-button mzp-t-product" data-event-category="device-migration-wizard" data-event-action="click" data-event-label="create-calendar-event">${gettext("Save")}</button>
47+
</div>
48+
<hr>
49+
50+
<div class="hbox">
51+
<span id="copy-link-message">${gettext("You can also access the link directly")}</span>
52+
<div id="copy-link-container" class="hbox">
53+
<button id="copy-link" class="mzp-c-button mzp-t-product mzp-t-secondary mzp-t-md" data-event-category="device-migration-wizard" data-event-action="click" data-event-label="copy-link-to-clipboard-button">${gettext("Copy link")}</button>
54+
<span id="copied-message"><img src="${successIconUrl}" aria-hidden="true">${gettext("Copied!")}</span>
55+
</div>
56+
</div>
57+
</div>
58+
</div>
59+
</template>
60+
`;
61+
}
62+
63+
static get styles() {
64+
let stylesheet = document.createElement("link");
65+
stylesheet.rel = "stylesheet";
66+
stylesheet.href = reminderDialogStylesURL;
67+
return stylesheet;
68+
}
69+
70+
constructor() {
71+
super();
72+
// We cannot create a shadow root on a <dialog>, so we'll create a root
73+
// <div> node and put the shadow root there instead.
74+
let rootNode = document.createElement("div");
75+
this.appendChild(rootNode);
76+
let shadow = rootNode.attachShadow({ mode: "open" });
77+
78+
let parser = new DOMParser();
79+
let doc = parser.parseFromString(ReminderDialog.markup, "text/html");
80+
let template = doc.querySelector("template");
81+
shadow.append(ReminderDialog.styles, template.content.cloneNode(true));
82+
83+
this.#shadow = shadow;
84+
}
85+
86+
get shadow() {
87+
return this.#shadow;
88+
}
89+
90+
connectedCallback() {
91+
let close = this.#shadow.querySelector("#close");
92+
close.addEventListener("click", this);
93+
94+
let copyLinkButton = this.#shadow.querySelector("#copy-link");
95+
copyLinkButton.addEventListener("click", this);
96+
97+
let createEventButton = this.#shadow.querySelector("#create-event");
98+
createEventButton.addEventListener("click", this);
99+
}
100+
101+
handleEvent(event) {
102+
switch (event.currentTarget.id) {
103+
case "close": {
104+
this.close();
105+
break;
106+
}
107+
case "copy-link": {
108+
this.#copyLink();
109+
break;
110+
}
111+
case "create-event": {
112+
let calendarType = this.#shadow.querySelector("#choose-calendar").value;
113+
this.#createEvent(calendarType)
114+
}
115+
}
116+
}
117+
118+
/**
119+
* This is a thin wrapper around window.location so that our automated
120+
* tests can easily stub this out and override it (since the test
121+
* framework we use gets upset when writing to window.location).
122+
*
123+
* @param {string} url
124+
* The URL to send the browser to.
125+
*/
126+
redirect(url) {
127+
window.location.href = url;
128+
}
129+
130+
/**
131+
* Creates a summary and description string appropriate for a calendar
132+
* event for downloading and installing Firefox on a new device. This
133+
* will be translated to the current user's locale.
134+
*
135+
* @param {string} linkURL
136+
* The URL that the event should provide to download Firefox.
137+
* @param {integer} format
138+
* One of the CALENDAR_FORMATS constants.
139+
* @returns {object} result
140+
* @returns {string} result.summary
141+
* The summary for the event.
142+
* @returns {string} result.description
143+
* The description for the event, including the linkURL.
144+
*/
145+
#generateEventSummaryAndDescription(linkURL, format) {
146+
let summary = gettext("Reminder to complete your Firefox backup");
147+
let description = interpolate(
148+
gettext("Your Firefox data has been successfully backed up.\n\nFollow this link to start your download: %s"),
149+
[linkURL]
150+
);
151+
152+
if (format == CALENDAR_FORMATS.ICAL) {
153+
description = description.replace(/\n/g, "\\n");
154+
}
155+
156+
return { summary, description };
157+
}
158+
159+
/**
160+
* Generates start and end dates for the reminder. This function
161+
* will compute the correct start and end dates to produce an
162+
* "All Day" event for the given CALENDAR_FORMAT.
163+
*
164+
* @param {number} format
165+
* One of the CALENDAR_FORMATS constants.
166+
* @returns {object} result
167+
* @returns {string} result.dtStart
168+
* The start date of the event in a format appropriate for
169+
* CALENDAR_FORMAT.
170+
* @returns {string} result.dtEnd
171+
* The end date of the event in a format appropriate for
172+
* CALENDAR_FORMAT.
173+
*/
174+
#generateDTStartDTEnd(format) {
175+
let startDate = new Date();
176+
// Set the reminder for REMINDER_DAYS_IN_THE_FUTURE days in the future.
177+
startDate.setDate(startDate.getDate() + ReminderDialog.REMINDER_DAYS_IN_THE_FUTURE);
178+
179+
let dtStart = this.#convertDateToDTFormat(format, startDate);
180+
181+
// For Google Calendar, all-day events are encoded by not including
182+
// the time (handled by #generateDTStartDTEnd), and having the start
183+
// and end date match.
184+
// See https://stackoverflow.com/questions/37335415/link-to-add-all-day-event-to-google-calendar
185+
if (format == CALENDAR_FORMATS.GCAL) {
186+
return { dtStart, dtEnd: dtStart };
187+
}
188+
189+
// For other formats, it's expected that the end date for an
190+
// "All Day" event be the next day.
191+
let endDate = new Date(startDate);
192+
endDate.setDate(startDate.getDate() + 1);
193+
let dtEnd = this.#convertDateToDTFormat(format, endDate);
194+
195+
return { dtStart, dtEnd };
196+
}
197+
198+
/**
199+
* Converts a DOM Date object into a string representation that
200+
* is compatible with the VEVENT format.
201+
*
202+
* @param {number} format
203+
* One of the CALENDAR_FORMATS constants.
204+
* @param {Date} date
205+
* The date to encode.
206+
* @returns {string}
207+
* The VEVENT-compatible date, set at midnight, with no timezone
208+
* information.
209+
*/
210+
#convertDateToDTFormat(format, date) {
211+
let year = date.getFullYear().toString();
212+
213+
// In all cases, months or days with single digits are expected to start
214+
// with a 0.
215+
216+
// getMonth() is 0-indexed.
217+
let month = new String(date.getMonth() + 1).padStart(2, "0");
218+
let day = new String(date.getDate()).padStart(2, "0");
219+
220+
switch (format) {
221+
case CALENDAR_FORMATS.ICAL: {
222+
return `${year}${month}${day}T000000Z`;
223+
}
224+
case CALENDAR_FORMATS.OUTLOOK: {
225+
return `${year}-${month}-${day}`
226+
}
227+
case CALENDAR_FORMATS.GCAL: {
228+
return `${year}${month}${day}`
229+
}
230+
default: {
231+
throw new Error("#convertDateToDTFormat wasn't given a format for the date.");
232+
}
233+
}
234+
}
235+
236+
/**
237+
* Generates a URL to download a .ics file that can be interpreted
238+
* by calendaring software to add an event to a calendar that reminds
239+
* the user to download and install Firefox on their new device.
240+
*
241+
* @returns {string}
242+
* The blob URL for the .ics file download.
243+
*/
244+
#generateICSFileDownload() {
245+
let now = this.#convertDateToDTFormat(CALENDAR_FORMATS.ICAL, new Date());
246+
let { dtStart, dtEnd } = this.#generateDTStartDTEnd(CALENDAR_FORMATS.ICAL);
247+
let timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
248+
249+
// mozilla/sumo#1503: The NEW_DEVICE_DOWNLOAD_URL should be replaced with
250+
// a link that attributes the download to an event from an ICS file.
251+
let { summary, description } = this.#generateEventSummaryAndDescription(NEW_DEVICE_DOWNLOAD_URL, CALENDAR_FORMATS.ICAL);
252+
253+
// The random value here is not meant to be cryptographically
254+
// secure. The randomValue is used to create a unique UID for
255+
// the VEVENT.
256+
let randomValue = dtStart + Math.random();
257+
258+
let icsFile = `BEGIN:VCALENDAR
259+
VERSION:2.0
260+
PRODID:-//Mozilla.org/NONSGML support.mozilla.org switching devices wizard V1.0//EN
261+
METHOD:REQUEST
262+
BEGIN:VEVENT
263+
UID:${randomValue}
264+
DTSTAMP:${now}
265+
DTSTART;TZID=${timezone}:${dtStart}
266+
DTEND;TZID=${timezone}:${dtEnd}
267+
SUMMARY:${summary}
268+
DESCRIPTION:${description}
269+
END:VEVENT
270+
END:VCALENDAR
271+
`;
272+
let blob = new Blob([icsFile], {type: "text/calendar;charset=utf-8;"});
273+
let blobURL = window.URL.createObjectURL(blob);
274+
return blobURL;
275+
}
276+
277+
/**
278+
* Copies the NEW_DEVICE_DOWNLOAD_URL string to the clipboard,
279+
* and updates the UI to indicate that the copy was successful for
280+
* 5 seconds before returning to normal.
281+
*/
282+
#copyLink() {
283+
let contentNode = this.#shadow.querySelector("#reminder-dialog-content");
284+
let copyLinkButton = this.#shadow.querySelector("#copy-link");
285+
navigator.clipboard.writeText(NEW_DEVICE_DOWNLOAD_URL);
286+
contentNode.toggleAttribute("data-copied", true);
287+
setTimeout(() => {
288+
contentNode.toggleAttribute("data-copied", false);
289+
}, 5000);
290+
}
291+
292+
#createEvent(calendarType) {
293+
switch (calendarType) {
294+
case "gcal": {
295+
this.#openGCalTab();
296+
break;
297+
}
298+
case "outlook": {
299+
this.#openOutlookTab();
300+
break;
301+
}
302+
case "ics": {
303+
let icsDownload = this.#generateICSFileDownload();
304+
this.redirect(icsDownload);
305+
break;
306+
}
307+
}
308+
}
309+
310+
#openGCalTab() {
311+
const GCAL_ENDPOINT = "https://calendar.google.com/calendar/render?";
312+
313+
// mozilla/sumo#1503: The NEW_DEVICE_DOWNLOAD_URL should be replaced with
314+
// a link that attributes the download to an event from Google Calendar.
315+
let { summary, description } = this.#generateEventSummaryAndDescription(NEW_DEVICE_DOWNLOAD_URL, CALENDAR_FORMATS.GCAL);
316+
let { dtStart, dtEnd } = this.#generateDTStartDTEnd(CALENDAR_FORMATS.GCAL);
317+
let params = new URLSearchParams();
318+
params.set("action", "TEMPLATE");
319+
params.set("dates", `${dtStart}/${dtEnd}`);
320+
params.set("text", summary);
321+
params.set("details", description);
322+
window.open(GCAL_ENDPOINT + params);
323+
}
324+
325+
#openOutlookTab() {
326+
const OUTLOOK_ENDPOINT = "https://outlook.live.com/calendar/0/deeplink/compose/?";
327+
328+
// mozilla/sumo#1503: The NEW_DEVICE_DOWNLOAD_URL should be replaced with
329+
// a link that attributes the download to an event from Microsoft Outlook.
330+
let { summary, description } = this.#generateEventSummaryAndDescription(NEW_DEVICE_DOWNLOAD_URL, CALENDAR_FORMATS.OUTLOOK);
331+
let { dtStart, dtEnd } = this.#generateDTStartDTEnd(CALENDAR_FORMATS.OUTLOOK);
332+
let params = new URLSearchParams();
333+
334+
// The arguments here were supplied by
335+
// https://interactiondesignfoundation.github.io/add-event-to-calendar-docs/services/outlook-web.html.
336+
params.set("body", description);
337+
params.set("subject", summary);
338+
params.set("startdt", dtStart);
339+
params.set("enddt", dtEnd);
340+
params.set("rru", "addevent");
341+
params.set("path", "/calendar/action/compose");
342+
params.set("allday", "true");
343+
window.open(OUTLOOK_ENDPOINT + params);
344+
}
345+
}
346+
347+
customElements.define("reminder-dialog", ReminderDialog, {extends: "dialog"});

kitsune/sumo/static/sumo/js/form-wizard-setup-device-step.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import { BaseFormStep } from "sumo/js/form-wizard";
2+
import { ReminderDialog } from "sumo/js/form-wizard-reminder-dialog"
23
import setupDeviceStepStyles from "../scss/form-wizard-setup-device-step.styles.scss";
34
import successIconUrl from "sumo/img/success.svg";
45

56
export class SetupDeviceStep extends BaseFormStep {
7+
#reminderDialog = null;
8+
69
get template() {
710
return `
811
<template>
@@ -54,5 +57,25 @@ export class SetupDeviceStep extends BaseFormStep {
5457
root.toggleAttribute("data-copied", false);
5558
}, 5000);
5659
}
60+
61+
/**
62+
* Opens the dialog to create calendar events to remind the user
63+
* to download and install Firefox in the future.
64+
*
65+
* This is currently a public method to facilitate easier manual
66+
* testing, as the step that will eventually present a button for
67+
* opening the dialog isn't ready yet.
68+
*/
69+
openReminderDialog() {
70+
if (!this.#reminderDialog) {
71+
let dialog = new ReminderDialog();
72+
dialog.classList.add("reminder-dialog");
73+
document.body.appendChild(dialog);
74+
75+
this.#reminderDialog = dialog;
76+
}
77+
78+
this.#reminderDialog.showModal();
79+
}
5780
}
5881
customElements.define("setup-device-step", SetupDeviceStep);

0 commit comments

Comments
 (0)