diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
commit | 26a029d407be480d791972afb5975cf62c9360a6 (patch) | |
tree | f435a8308119effd964b339f76abb83a57c29483 /toolkit/components/normandy/content/about-studies | |
parent | Initial commit. (diff) | |
download | firefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz firefox-26a029d407be480d791972afb5975cf62c9360a6.zip |
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/normandy/content/about-studies')
3 files changed, 764 insertions, 0 deletions
diff --git a/toolkit/components/normandy/content/about-studies/about-studies.css b/toolkit/components/normandy/content/about-studies/about-studies.css new file mode 100644 index 0000000000..76819ba423 --- /dev/null +++ b/toolkit/components/normandy/content/about-studies/about-studies.css @@ -0,0 +1,176 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +:root { + --icon-background-color-1: #0A84FF; + --icon-background-color-2: #008EA4; + --icon-background-color-3: #ED00B5; + --icon-background-color-4: #058B00; + --icon-background-color-5: #A47F00; + --icon-background-color-6: #FF0039; + --icon-background-disabled-color: #737373; + --body-text-disabled-color: #737373; + --study-status-active-color: #058B00; + --study-status-disabled-color: #737373; +} + +html, +body, +#app { + height: 100%; + width: 100%; +} + +button > .button-box { + padding-inline: 10px; +} + +.about-studies-container { + max-width: 960px; + margin: 0 auto; +} + +.info-box { + margin-bottom: 10px; + text-align: center; +} + +.info-box-content { + align-items: center; + background: var(--in-content-box-info-background); + border: 1px solid var(--in-content-border-color); + display: inline-flex; + padding: 10px 15px; +} + +.info-box-content > * { + margin-right: 10px; +} + +.info-box-content > *:last-child { + margin-right: 0; +} + +.study-list { + list-style-type: none; + margin: 0; + padding: 0; +} + +.study { + align-items: center; + border-bottom: 1px solid var(--in-content-border-color); + display: flex; + flex-direction: row; + padding: 10px; +} + +.study.disabled { + color: var(--body-text-disabled-color); +} + +.study .study-status { + color: var(--study-status-active-color); + font-weight: bold; +} + +.study.disabled .study-status { + color: var(--study-status-disabled-color); +} + +.study:last-child { + border-bottom: none; +} + +.study > * { + margin-right: 15px; +} + +.study > *:last-child { + margin-right: 0; +} + +.study-icon { + color: #FFF; + flex: 0 0 40px; + font-size: 26px; + height: 40px; + line-height: 40px; + text-align: center; + text-transform: capitalize; +} + +.study:nth-child(6n+0) .study-icon { + background: var(--icon-background-color-1); +} + +.study:nth-child(6n+1) .study-icon { + background: var(--icon-background-color-2); +} + +.study:nth-child(6n+2) .study-icon { + background: var(--icon-background-color-3); +} + +.study:nth-child(6n+3) .study-icon { + background: var(--icon-background-color-4); +} + +.study:nth-child(6n+4) .study-icon { + background: var(--icon-background-color-5); +} + +.study:nth-child(6n+5) .study-icon { + background: var(--icon-background-color-6); +} + +.study.disabled .study-icon { + background: var(--icon-background-disabled-color); +} + +.study-details { + flex: 1; + overflow: hidden; +} + +.study-name { + font-weight: bold; +} + +.study-header { + margin-bottom: .3em; +} + +.study-header > * { + margin-right: 5px; +} + +.study-header > *:last-child { + margin-right: 0; +} + +.study-description code { + background-color: rgb(128, 128, 128, 0.1); + border-radius: 3px; + box-sizing: border-box; + color: var(--in-content-text-color); + font-size: 85%; + font-family: 'Fira Mono', 'mono', monospace; + padding: .05em .4em; +} + +.study-actions { + flex: 0 0; +} + +.opt-in-box { + border-radius: 3px; + padding: 10px; + color: var(--study-status-active-color); + border: 1px solid; +} + +.opt-in-box.opt-in-error { + color: var(--text-color-error); +} diff --git a/toolkit/components/normandy/content/about-studies/about-studies.html b/toolkit/components/normandy/content/about-studies/about-studies.html new file mode 100644 index 0000000000..e6f1347227 --- /dev/null +++ b/toolkit/components/normandy/content/about-studies/about-studies.html @@ -0,0 +1,29 @@ +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8" /> + <meta name="color-scheme" content="light dark" /> + <meta + http-equiv="Content-Security-Policy" + content="default-src chrome:; script-src resource:; style-src resource: chrome:; object-src 'none'" + /> + <title>about:studies</title> + <link rel="stylesheet" href="chrome://global/skin/global.css" /> + <link rel="stylesheet" href="chrome://global/skin/in-content/common.css" /> + <link + rel="stylesheet" + href="resource://normandy-content/about-studies/about-studies.css" + /> + </head> + <body> + <div id="app"></div> + <script src="resource://normandy-vendor/React.js"></script> + <script src="resource://normandy-vendor/ReactDOM.js"></script> + <script src="resource://normandy-vendor/PropTypes.js"></script> + <script src="resource://normandy-vendor/classnames.js"></script> + <script src="resource://normandy-content/about-studies/about-studies.js"></script> + </body> +</html> diff --git a/toolkit/components/normandy/content/about-studies/about-studies.js b/toolkit/components/normandy/content/about-studies/about-studies.js new file mode 100644 index 0000000000..b43c1b32d8 --- /dev/null +++ b/toolkit/components/normandy/content/about-studies/about-studies.js @@ -0,0 +1,559 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; +/* global classnames PropTypes React ReactDOM */ + +/** + * Shorthand for creating elements (to avoid using a JSX preprocessor) + */ +const r = React.createElement; + +/** + * Dispatches a page event to the privileged frame script for this tab. + * @param {String} action + * @param {Object} data + */ +function sendPageEvent(action, data) { + const event = new CustomEvent("ShieldPageEvent", { + bubbles: true, + detail: { action, data }, + }); + document.dispatchEvent(event); +} + +function readOptinParams() { + let searchParams = new URLSearchParams(new URL(location).search); + return { + slug: searchParams.get("optin_slug"), + branch: searchParams.get("optin_branch"), + collection: searchParams.get("optin_collection"), + applyTargeting: !!searchParams.get("apply_targeting"), + }; +} + +/** + * Handle basic layout and routing within about:studies. + */ +class AboutStudies extends React.Component { + constructor(props) { + super(props); + + this.remoteValueNameMap = { + AddonStudyList: "addonStudies", + PreferenceStudyList: "prefStudies", + MessagingSystemList: "experiments", + ShieldLearnMoreHref: "learnMoreHref", + StudiesEnabled: "studiesEnabled", + ShieldTranslations: "translations", + }; + + this.state = {}; + for (const stateName of Object.values(this.remoteValueNameMap)) { + this.state[stateName] = null; + } + this.state.optInMessage = false; + } + + initializeData() { + for (const remoteName of Object.keys(this.remoteValueNameMap)) { + document.addEventListener(`ReceiveRemoteValue:${remoteName}`, this); + sendPageEvent(`GetRemoteValue:${remoteName}`); + } + } + + componentWillMount() { + let optinParams = readOptinParams(); + if (optinParams.branch && optinParams.slug) { + const onOptIn = ({ detail: value }) => { + this.setState({ optInMessage: value }); + this.initializeData(); + document.removeEventListener( + `ReceiveRemoteValue:OptInMessage`, + onOptIn + ); + }; + document.addEventListener(`ReceiveRemoteValue:OptInMessage`, onOptIn); + sendPageEvent(`ExperimentOptIn`, optinParams); + } else { + this.initializeData(); + } + } + + componentWillUnmount() { + for (const remoteName of Object.keys(this.remoteValueNameMap)) { + document.removeEventListener(`ReceiveRemoteValue:${remoteName}`, this); + } + } + + /** Event handle to receive remote values from documentAddEventListener */ + handleEvent({ type, detail: value }) { + const prefix = "ReceiveRemoteValue:"; + if (type.startsWith(prefix)) { + const name = type.substring(prefix.length); + this.setState({ [this.remoteValueNameMap[name]]: value }); + } + } + + render() { + const { + translations, + learnMoreHref, + studiesEnabled, + addonStudies, + prefStudies, + experiments, + optInMessage, + } = this.state; + // Wait for all values to be loaded before rendering. Some of the values may + // be falsey, so an explicit null check is needed. + if (Object.values(this.state).some(v => v === null)) { + return null; + } + + return r( + "div", + { className: "about-studies-container main-content" }, + r(WhatsThisBox, { translations, learnMoreHref, studiesEnabled }), + optInMessage && r(OptInBox, optInMessage), + r(StudyList, { + translations, + addonStudies, + prefStudies, + experiments, + }) + ); + } +} + +/** + * Explains the contents of the page, and offers a way to learn more and update preferences. + */ +class WhatsThisBox extends React.Component { + handleUpdateClick() { + sendPageEvent("NavigateToDataPreferences"); + } + + render() { + const { learnMoreHref, studiesEnabled, translations } = this.props; + + return r( + "div", + { className: "info-box" }, + r( + "div", + { className: "info-box-content" }, + r( + "span", + {}, + studiesEnabled ? translations.enabledList : translations.disabledList + ), + r( + "a", + { id: "shield-studies-learn-more", href: learnMoreHref }, + translations.learnMore + ), + + r( + "button", + { + id: "shield-studies-update-preferences", + onClick: this.handleUpdateClick, + }, + r( + "div", + { className: "button-box" }, + navigator.platform.includes("Win") + ? translations.updateButtonWin + : translations.updateButtonUnix + ) + ) + ) + ); + } +} +/**OptInMessage + * Explains the contents of the page, and offers a way to learn more and update preferences. + */ +function OptInBox({ error, message }) { + return r( + "div", + { className: "opt-in-box" + (error ? " opt-in-error" : "") }, + message + ); +} + +/** + * Shows a list of studies, with an option to end in-progress ones. + */ +class StudyList extends React.Component { + render() { + const { addonStudies, prefStudies, translations, experiments } = this.props; + + if (!addonStudies.length && !prefStudies.length && !experiments.length) { + return r("p", { className: "study-list-info" }, translations.noStudies); + } + + const activeStudies = []; + const inactiveStudies = []; + + // Since we are modifying the study objects, it is polite to make copies + for (const study of addonStudies) { + const clonedStudy = Object.assign({}, study, { + type: "addon", + sortDate: study.studyStartDate, + }); + if (study.active) { + activeStudies.push(clonedStudy); + } else { + inactiveStudies.push(clonedStudy); + } + } + + for (const study of prefStudies) { + const clonedStudy = Object.assign({}, study, { + type: "pref", + sortDate: new Date(study.lastSeen), + }); + if (study.expired) { + inactiveStudies.push(clonedStudy); + } else { + activeStudies.push(clonedStudy); + } + } + + for (const study of experiments) { + const clonedStudy = Object.assign({}, study, { + type: study.experimentType, + sortDate: new Date(study.lastSeen), + }); + if (!study.active && !study.isRollout) { + inactiveStudies.push(clonedStudy); + } else { + activeStudies.push(clonedStudy); + } + } + + activeStudies.sort((a, b) => b.sortDate - a.sortDate); + inactiveStudies.sort((a, b) => b.sortDate - a.sortDate); + return r( + "div", + {}, + r("h2", {}, translations.activeStudiesList), + r( + "ul", + { className: "study-list active-study-list" }, + activeStudies.map(study => { + if (study.type === "addon") { + return r(AddonStudyListItem, { + key: study.slug, + study, + translations, + }); + } + if (study.type === "nimbus" || study.type === "rollout") { + return r(MessagingSystemListItem, { + key: study.slug, + study, + translations, + }); + } + if (study.type === "pref") { + return r(PreferenceStudyListItem, { + key: study.slug, + study, + translations, + }); + } + return null; + }) + ), + r("h2", {}, translations.completedStudiesList), + r( + "ul", + { className: "study-list inactive-study-list" }, + inactiveStudies.map(study => { + if (study.type === "addon") { + return r(AddonStudyListItem, { + key: study.slug, + study, + translations, + }); + } + if ( + study.type === "nimbus" || + study.type === "messaging_experiment" + ) { + return r(MessagingSystemListItem, { + key: study.slug, + study, + translations, + }); + } + if (study.type === "pref") { + return r(PreferenceStudyListItem, { + key: study.slug, + study, + translations, + }); + } + return null; + }) + ) + ); + } +} +StudyList.propTypes = { + addonStudies: PropTypes.array.isRequired, + translations: PropTypes.object.isRequired, +}; + +class MessagingSystemListItem extends React.Component { + constructor(props) { + super(props); + this.handleClickRemove = this.handleClickRemove.bind(this); + } + + handleClickRemove() { + sendPageEvent("RemoveMessagingSystemExperiment", { + slug: this.props.study.slug, + reason: "individual-opt-out", + }); + } + + render() { + const { study, translations } = this.props; + const userFacingName = study.userFacingName || study.slug; + const userFacingDescription = + study.userFacingDescription || "Nimbus experiment."; + return r( + "li", + { + className: classnames("study nimbus", { + disabled: !study.active, + }), + "data-study-slug": study.slug, // used to identify this row in tests + }, + r("div", { className: "study-icon" }, userFacingName.slice(0, 1)), + r( + "div", + { className: "study-details" }, + r( + "div", + { className: "study-header" }, + r("span", { className: "study-name" }, userFacingName), + r("span", {}, "\u2022"), // • + !study.isRollout && [ + r("span", { className: "study-branch-slug" }, study.branch.slug), + r("span", {}, "\u2022"), // • + ], + r( + "span", + { className: "study-status" }, + study.active + ? translations.activeStatus + : translations.completeStatus + ) + ), + r("div", { className: "study-description" }, userFacingDescription) + ), + r( + "div", + { className: "study-actions" }, + study.active && + r( + "button", + { className: "remove-button", onClick: this.handleClickRemove }, + r("div", { className: "button-box" }, translations.removeButton) + ) + ) + ); + } +} + +/** + * Details about an individual add-on study, with an option to end it if it is active. + */ +class AddonStudyListItem extends React.Component { + constructor(props) { + super(props); + this.handleClickRemove = this.handleClickRemove.bind(this); + } + + handleClickRemove() { + sendPageEvent("RemoveAddonStudy", { + recipeId: this.props.study.recipeId, + reason: "individual-opt-out", + }); + } + + render() { + const { study, translations } = this.props; + return r( + "li", + { + className: classnames("study addon-study", { disabled: !study.active }), + "data-study-slug": study.slug, // used to identify this row in tests + }, + r( + "div", + { className: "study-icon" }, + study.userFacingName + .replace(/-?add-?on-?/i, "") + .replace(/-?study-?/i, "") + .slice(0, 1) + ), + r( + "div", + { className: "study-details" }, + r( + "div", + { className: "study-header" }, + r("span", { className: "study-name" }, study.userFacingName), + r("span", {}, "\u2022"), // • + r( + "span", + { className: "study-status" }, + study.active + ? translations.activeStatus + : translations.completeStatus + ) + ), + r( + "div", + { className: "study-description" }, + study.userFacingDescription + ) + ), + r( + "div", + { className: "study-actions" }, + study.active && + r( + "button", + { className: "remove-button", onClick: this.handleClickRemove }, + r("div", { className: "button-box" }, translations.removeButton) + ) + ) + ); + } +} +AddonStudyListItem.propTypes = { + study: PropTypes.shape({ + recipeId: PropTypes.number.isRequired, + slug: PropTypes.string.isRequired, + userFacingName: PropTypes.string.isRequired, + active: PropTypes.bool.isRequired, + userFacingDescription: PropTypes.string.isRequired, + }).isRequired, + translations: PropTypes.object.isRequired, +}; + +/** + * Details about an individual preference study, with an option to end it if it is active. + */ +class PreferenceStudyListItem extends React.Component { + constructor(props) { + super(props); + this.handleClickRemove = this.handleClickRemove.bind(this); + } + + handleClickRemove() { + sendPageEvent("RemovePreferenceStudy", { + experimentName: this.props.study.slug, + reason: "individual-opt-out", + }); + } + + render() { + const { study, translations } = this.props; + + let iconLetter = (study.userFacingName || study.slug) + .replace(/-?pref-?(flip|study)-?/, "") + .replace(/-?study-?/, "") + .slice(0, 1) + .toUpperCase(); + + let description = study.userFacingDescription; + if (!description) { + // Assume there is exactly one preference (old-style preference experiment). + const [preferenceName, { preferenceValue }] = Object.entries( + study.preferences + )[0]; + // Sanitize the values by setting them as the text content of an element, + // and then getting the HTML representation of that text. This will have the + // browser safely sanitize them. Use outerHTML to also include the <code> + // element in the string. + const sanitizer = document.createElement("code"); + sanitizer.textContent = preferenceName; + const sanitizedPreferenceName = sanitizer.outerHTML; + sanitizer.textContent = preferenceValue; + const sanitizedPreferenceValue = sanitizer.outerHTML; + description = translations.preferenceStudyDescription + .replace(/%(?:1\$)?S/, sanitizedPreferenceName) + .replace(/%(?:2\$)?S/, sanitizedPreferenceValue); + } + + return r( + "li", + { + className: classnames("study pref-study", { disabled: study.expired }), + "data-study-slug": study.slug, // used to identify this row in tests + }, + r("div", { className: "study-icon" }, iconLetter), + r( + "div", + { className: "study-details" }, + r( + "div", + { className: "study-header" }, + r( + "span", + { className: "study-name" }, + study.userFacingName || study.slug + ), + r("span", {}, "\u2022"), // • + r( + "span", + { className: "study-status" }, + study.expired + ? translations.completeStatus + : translations.activeStatus + ) + ), + r("div", { + className: "study-description", + dangerouslySetInnerHTML: { __html: description }, + }) + ), + r( + "div", + { className: "study-actions" }, + !study.expired && + r( + "button", + { className: "remove-button", onClick: this.handleClickRemove }, + r("div", { className: "button-box" }, translations.removeButton) + ) + ) + ); + } +} +PreferenceStudyListItem.propTypes = { + study: PropTypes.shape({ + slug: PropTypes.string.isRequired, + userFacingName: PropTypes.string, + userFacingDescription: PropTypes.string, + expired: PropTypes.bool.isRequired, + preferenceName: PropTypes.string.isRequired, + preferenceValue: PropTypes.oneOf( + PropTypes.string, + PropTypes.bool, + PropTypes.number + ).isRequired, + }).isRequired, + translations: PropTypes.object.isRequired, +}; + +ReactDOM.render(r(AboutStudies), document.getElementById("app")); |