Bug 1943514 - Close the RC sidebar panel when users opt out of/turn off Review Checke...
[gecko.git] / toolkit / components / normandy / content / about-studies / about-studies.js
blob4113c41c952dc331bd73e739f591b1ff261e7194
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5 "use strict";
6 /* global classnames PropTypes React ReactDOM */
8 /**
9 * Shorthand for creating elements (to avoid using a JSX preprocessor)
11 const r = React.createElement;
13 /**
14 * Dispatches a page event to the privileged frame script for this tab.
15 * @param {String} action
16 * @param {Object} data
18 function sendPageEvent(action, data) {
19 const event = new CustomEvent("ShieldPageEvent", {
20 bubbles: true,
21 detail: { action, data },
22 });
23 document.dispatchEvent(event);
26 function readOptinParams() {
27 let searchParams = new URLSearchParams(new URL(location).search);
28 return {
29 slug: searchParams.get("optin_slug"),
30 branch: searchParams.get("optin_branch"),
31 collection: searchParams.get("optin_collection"),
32 applyTargeting: !!searchParams.get("apply_targeting"),
36 /**
37 * Handle basic layout and routing within about:studies.
39 class AboutStudies extends React.Component {
40 constructor(props) {
41 super(props);
43 this.remoteValueNameMap = {
44 AddonStudyList: "addonStudies",
45 PreferenceStudyList: "prefStudies",
46 MessagingSystemList: "experiments",
47 ShieldLearnMoreHref: "learnMoreHref",
48 StudiesEnabled: "studiesEnabled",
49 ShieldTranslations: "translations",
52 this.state = {};
53 for (const stateName of Object.values(this.remoteValueNameMap)) {
54 this.state[stateName] = null;
56 this.state.optInMessage = false;
59 initializeData() {
60 for (const remoteName of Object.keys(this.remoteValueNameMap)) {
61 document.addEventListener(`ReceiveRemoteValue:${remoteName}`, this);
62 sendPageEvent(`GetRemoteValue:${remoteName}`);
66 componentWillMount() {
67 let optinParams = readOptinParams();
68 if (optinParams.branch && optinParams.slug) {
69 const onOptIn = ({ detail: value }) => {
70 this.setState({ optInMessage: value });
71 this.initializeData();
72 document.removeEventListener(
73 `ReceiveRemoteValue:OptInMessage`,
74 onOptIn
77 document.addEventListener(`ReceiveRemoteValue:OptInMessage`, onOptIn);
78 sendPageEvent(`ExperimentOptIn`, optinParams);
79 } else {
80 this.initializeData();
84 componentWillUnmount() {
85 for (const remoteName of Object.keys(this.remoteValueNameMap)) {
86 document.removeEventListener(`ReceiveRemoteValue:${remoteName}`, this);
90 /** Event handle to receive remote values from documentAddEventListener */
91 handleEvent({ type, detail: value }) {
92 const prefix = "ReceiveRemoteValue:";
93 if (type.startsWith(prefix)) {
94 const name = type.substring(prefix.length);
95 this.setState({ [this.remoteValueNameMap[name]]: value });
99 render() {
100 const {
101 translations,
102 learnMoreHref,
103 studiesEnabled,
104 addonStudies,
105 prefStudies,
106 experiments,
107 optInMessage,
108 } = this.state;
109 // Wait for all values to be loaded before rendering. Some of the values may
110 // be falsey, so an explicit null check is needed.
111 if (Object.values(this.state).some(v => v === null)) {
112 return null;
115 return r(
116 "div",
117 { className: "about-studies-container main-content" },
118 r(WhatsThisBox, { translations, learnMoreHref, studiesEnabled }),
119 optInMessage && r(OptInBox, optInMessage),
120 r(StudyList, {
121 translations,
122 addonStudies,
123 prefStudies,
124 experiments,
131 * Explains the contents of the page, and offers a way to learn more and update preferences.
133 class WhatsThisBox extends React.Component {
134 handleUpdateClick() {
135 sendPageEvent("NavigateToDataPreferences");
138 render() {
139 const { learnMoreHref, studiesEnabled, translations } = this.props;
141 return r(
142 "div",
143 { className: "info-box" },
145 "div",
146 { className: "info-box-content" },
148 "span",
150 studiesEnabled ? translations.enabledList : translations.disabledList
153 "a",
154 { id: "shield-studies-learn-more", href: learnMoreHref },
155 translations.learnMore
159 "button",
161 id: "shield-studies-update-preferences",
162 onClick: this.handleUpdateClick,
165 "div",
166 { className: "button-box" },
167 navigator.platform.includes("Win")
168 ? translations.updateButtonWin
169 : translations.updateButtonUnix
176 /**OptInMessage
177 * Explains the contents of the page, and offers a way to learn more and update preferences.
179 function OptInBox({ error, message }) {
180 return r(
181 "div",
182 { className: "opt-in-box" + (error ? " opt-in-error" : "") },
183 message
188 * Shows a list of studies, with an option to end in-progress ones.
190 class StudyList extends React.Component {
191 render() {
192 const { addonStudies, prefStudies, translations, experiments } = this.props;
194 if (!addonStudies.length && !prefStudies.length && !experiments.length) {
195 return r("p", { className: "study-list-info" }, translations.noStudies);
198 const activeStudies = [];
199 const inactiveStudies = [];
201 // Since we are modifying the study objects, it is polite to make copies
202 for (const study of addonStudies) {
203 const clonedStudy = Object.assign({}, study, {
204 type: "addon",
205 sortDate: study.studyStartDate,
207 if (study.active) {
208 activeStudies.push(clonedStudy);
209 } else {
210 inactiveStudies.push(clonedStudy);
214 for (const study of prefStudies) {
215 const clonedStudy = Object.assign({}, study, {
216 type: "pref",
217 sortDate: new Date(study.lastSeen),
219 if (study.expired) {
220 inactiveStudies.push(clonedStudy);
221 } else {
222 activeStudies.push(clonedStudy);
226 for (const study of experiments) {
227 const clonedStudy = Object.assign({}, study, {
228 type: study.experimentType,
229 sortDate: new Date(study.lastSeen),
231 if (!study.active) {
232 inactiveStudies.push(clonedStudy);
233 } else {
234 activeStudies.push(clonedStudy);
238 activeStudies.sort((a, b) => b.sortDate - a.sortDate);
239 inactiveStudies.sort((a, b) => b.sortDate - a.sortDate);
240 return r(
241 "div",
243 r("h2", {}, translations.activeStudiesList),
245 "ul",
246 { className: "study-list active-study-list" },
247 activeStudies.map(study => {
248 if (study.type === "addon") {
249 return r(AddonStudyListItem, {
250 key: study.slug,
251 study,
252 translations,
255 if (study.type === "nimbus" || study.type === "rollout") {
256 return r(MessagingSystemListItem, {
257 key: study.slug,
258 study,
259 translations,
262 if (study.type === "pref") {
263 return r(PreferenceStudyListItem, {
264 key: study.slug,
265 study,
266 translations,
269 return null;
272 r("h2", {}, translations.completedStudiesList),
274 "ul",
275 { className: "study-list inactive-study-list" },
276 inactiveStudies.map(study => {
277 if (study.type === "addon") {
278 return r(AddonStudyListItem, {
279 key: study.slug,
280 study,
281 translations,
284 if (
285 study.type === "nimbus" ||
286 study.type === "messaging_experiment" ||
287 study.type === "rollout"
289 return r(MessagingSystemListItem, {
290 key: study.slug,
291 study,
292 translations,
295 if (study.type === "pref") {
296 return r(PreferenceStudyListItem, {
297 key: study.slug,
298 study,
299 translations,
302 return null;
308 StudyList.propTypes = {
309 addonStudies: PropTypes.array.isRequired,
310 translations: PropTypes.object.isRequired,
313 class MessagingSystemListItem extends React.Component {
314 constructor(props) {
315 super(props);
316 this.handleClickRemove = this.handleClickRemove.bind(this);
319 handleClickRemove() {
320 sendPageEvent("RemoveMessagingSystemExperiment", {
321 slug: this.props.study.slug,
322 reason: "individual-opt-out",
326 render() {
327 const { study, translations } = this.props;
328 const userFacingName = study.userFacingName || study.slug;
329 const userFacingDescription =
330 study.userFacingDescription || "Nimbus experiment.";
331 return r(
332 "li",
334 className: classnames("study nimbus", {
335 disabled: !study.active,
337 "data-study-slug": study.slug, // used to identify this row in tests
339 r("div", { className: "study-icon" }, userFacingName.slice(0, 1)),
341 "div",
342 { className: "study-details" },
344 "div",
345 { className: "study-header" },
346 r("span", { className: "study-name" }, userFacingName),
347 r("span", {}, "\u2022"), // •
348 !study.isRollout && [
349 r("span", { className: "study-branch-slug" }, study.branch.slug),
350 r("span", {}, "\u2022"), // •
353 "span",
354 { className: "study-status" },
355 study.active
356 ? translations.activeStatus
357 : translations.completeStatus
360 r("div", { className: "study-description" }, userFacingDescription)
363 "div",
364 { className: "study-actions" },
365 study.active &&
367 "button",
368 { className: "remove-button", onClick: this.handleClickRemove },
369 r("div", { className: "button-box" }, translations.removeButton)
377 * Details about an individual add-on study, with an option to end it if it is active.
379 class AddonStudyListItem extends React.Component {
380 constructor(props) {
381 super(props);
382 this.handleClickRemove = this.handleClickRemove.bind(this);
385 handleClickRemove() {
386 sendPageEvent("RemoveAddonStudy", {
387 recipeId: this.props.study.recipeId,
388 reason: "individual-opt-out",
392 render() {
393 const { study, translations } = this.props;
394 return r(
395 "li",
397 className: classnames("study addon-study", { disabled: !study.active }),
398 "data-study-slug": study.slug, // used to identify this row in tests
401 "div",
402 { className: "study-icon" },
403 study.userFacingName
404 .replace(/-?add-?on-?/i, "")
405 .replace(/-?study-?/i, "")
406 .slice(0, 1)
409 "div",
410 { className: "study-details" },
412 "div",
413 { className: "study-header" },
414 r("span", { className: "study-name" }, study.userFacingName),
415 r("span", {}, "\u2022"), // •
417 "span",
418 { className: "study-status" },
419 study.active
420 ? translations.activeStatus
421 : translations.completeStatus
425 "div",
426 { className: "study-description" },
427 study.userFacingDescription
431 "div",
432 { className: "study-actions" },
433 study.active &&
435 "button",
436 { className: "remove-button", onClick: this.handleClickRemove },
437 r("div", { className: "button-box" }, translations.removeButton)
443 AddonStudyListItem.propTypes = {
444 study: PropTypes.shape({
445 recipeId: PropTypes.number.isRequired,
446 slug: PropTypes.string.isRequired,
447 userFacingName: PropTypes.string.isRequired,
448 active: PropTypes.bool.isRequired,
449 userFacingDescription: PropTypes.string.isRequired,
450 }).isRequired,
451 translations: PropTypes.object.isRequired,
455 * Details about an individual preference study, with an option to end it if it is active.
457 class PreferenceStudyListItem extends React.Component {
458 constructor(props) {
459 super(props);
460 this.handleClickRemove = this.handleClickRemove.bind(this);
463 handleClickRemove() {
464 sendPageEvent("RemovePreferenceStudy", {
465 experimentName: this.props.study.slug,
466 reason: "individual-opt-out",
470 render() {
471 const { study, translations } = this.props;
473 let iconLetter = (study.userFacingName || study.slug)
474 .replace(/-?pref-?(flip|study)-?/, "")
475 .replace(/-?study-?/, "")
476 .slice(0, 1)
477 .toUpperCase();
479 let description = study.userFacingDescription;
480 if (!description) {
481 // Assume there is exactly one preference (old-style preference experiment).
482 const [preferenceName, { preferenceValue }] = Object.entries(
483 study.preferences
484 )[0];
485 // Sanitize the values by setting them as the text content of an element,
486 // and then getting the HTML representation of that text. This will have the
487 // browser safely sanitize them. Use outerHTML to also include the <code>
488 // element in the string.
489 const sanitizer = document.createElement("code");
490 sanitizer.textContent = preferenceName;
491 const sanitizedPreferenceName = sanitizer.outerHTML;
492 sanitizer.textContent = preferenceValue;
493 const sanitizedPreferenceValue = sanitizer.outerHTML;
494 description = translations.preferenceStudyDescription
495 .replace(/%(?:1\$)?S/, sanitizedPreferenceName)
496 .replace(/%(?:2\$)?S/, sanitizedPreferenceValue);
499 return r(
500 "li",
502 className: classnames("study pref-study", { disabled: study.expired }),
503 "data-study-slug": study.slug, // used to identify this row in tests
505 r("div", { className: "study-icon" }, iconLetter),
507 "div",
508 { className: "study-details" },
510 "div",
511 { className: "study-header" },
513 "span",
514 { className: "study-name" },
515 study.userFacingName || study.slug
517 r("span", {}, "\u2022"), // &bullet;
519 "span",
520 { className: "study-status" },
521 study.expired
522 ? translations.completeStatus
523 : translations.activeStatus
526 r("div", {
527 className: "study-description",
528 dangerouslySetInnerHTML: { __html: description },
532 "div",
533 { className: "study-actions" },
534 !study.expired &&
536 "button",
537 { className: "remove-button", onClick: this.handleClickRemove },
538 r("div", { className: "button-box" }, translations.removeButton)
544 PreferenceStudyListItem.propTypes = {
545 study: PropTypes.shape({
546 slug: PropTypes.string.isRequired,
547 userFacingName: PropTypes.string,
548 userFacingDescription: PropTypes.string,
549 expired: PropTypes.bool.isRequired,
550 preferenceName: PropTypes.string.isRequired,
551 preferenceValue: PropTypes.oneOf(
552 PropTypes.string,
553 PropTypes.bool,
554 PropTypes.number
555 ).isRequired,
556 }).isRequired,
557 translations: PropTypes.object.isRequired,
560 ReactDOM.render(r(AboutStudies), document.getElementById("app"));