From 9415a9f13dc1fb4c2de4e1b9dac57aecf117a894 Mon Sep 17 00:00:00 2001 From: Mike Conley Date: Thu, 14 Dec 2023 18:46:55 +0000 Subject: [PATCH] Bug 1866802 - Move ASRouterAdmin tool to about:asrouter and its own component folder. r=pdahiya,Gijs,desktop-theme-reviewers,dao This tries to maintain stylistic continuity, while also trying to decouple from newtab as much as possible. This is a first foray, and future patches will further this decoupling. This also modifies about:asrouter to show an error message if the ASRouter devtools pref is not set to true. Differential Revision: https://phabricator.services.mozilla.com/D194811 --- .eslintrc.js | 2 + .gitignore | 8 + .hgignore | 4 + .prettierignore | 3 + .stylelintignore | 1 + .stylelintrc.js | 1 + browser/components/BrowserGlue.sys.mjs | 2 + browser/components/about/AboutRedirector.cpp | 5 + browser/components/about/components.conf | 1 + browser/components/asrouter/.eslintrc.js | 175 + .../ASRouterAdmin/ASRouterAdmin.jsx | 1503 ++++ .../ASRouterAdmin/ASRouterAdmin.scss | 353 + .../components/ASRouterAdmin/CopyButton.jsx | 33 + .../ASRouterAdmin/ImpressionsSection.jsx | 146 + .../ASRouterAdmin/SimpleHashRouter.jsx | 35 + .../asrouter/content/asrouter-admin.bundle.js | 1946 +++++ .../asrouter/content/asrouter-admin.html | 38 + .../ASRouterAdmin/ASRouterAdmin.css | 546 ++ browser/components/asrouter/content/render.js | 7 + browser/components/asrouter/jar.mn | 9 + browser/components/asrouter/moz.build | 10 + browser/components/asrouter/package-lock.json | 7292 +++++++++++++++++ browser/components/asrouter/package.json | 64 + .../asrouter/webpack.asrouter-admin.config.js | 34 + browser/components/asrouter/yamscripts.yml | 44 + browser/components/moz.build | 1 + browser/components/newtab/lib/ASRouter.jsm | 1 + .../test/unit/asrouter/ASRouter.test.js | 1 + tools/rewriting/Generated.txt | 3 + 29 files changed, 12268 insertions(+) create mode 100644 browser/components/asrouter/.eslintrc.js create mode 100644 browser/components/asrouter/content-src/components/ASRouterAdmin/ASRouterAdmin.jsx create mode 100644 browser/components/asrouter/content-src/components/ASRouterAdmin/ASRouterAdmin.scss create mode 100644 browser/components/asrouter/content-src/components/ASRouterAdmin/CopyButton.jsx create mode 100644 browser/components/asrouter/content-src/components/ASRouterAdmin/ImpressionsSection.jsx create mode 100644 browser/components/asrouter/content-src/components/ASRouterAdmin/SimpleHashRouter.jsx create mode 100644 browser/components/asrouter/content/asrouter-admin.bundle.js create mode 100644 browser/components/asrouter/content/asrouter-admin.html create mode 100644 browser/components/asrouter/content/components/ASRouterAdmin/ASRouterAdmin.css create mode 100644 browser/components/asrouter/content/render.js create mode 100644 browser/components/asrouter/jar.mn create mode 100644 browser/components/asrouter/moz.build create mode 100644 browser/components/asrouter/package-lock.json create mode 100644 browser/components/asrouter/package.json create mode 100644 browser/components/asrouter/webpack.asrouter-admin.config.js create mode 100644 browser/components/asrouter/yamscripts.yml diff --git a/.eslintrc.js b/.eslintrc.js index 9b250bc107a8..03f75ca8ee2d 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -233,6 +233,7 @@ module.exports = { "browser/components/Browser*", "browser/components/aboutlogins/**", "browser/components/aboutwelcome/**", + "browser/components/asrouter/**", "browser/components/attribution/**", "browser/components/customizableui/**", "browser/components/downloads/**", @@ -498,6 +499,7 @@ module.exports = { extends: ["plugin:react-hooks/recommended"], files: [ "browser/components/aboutwelcome/**", + "browser/components/asrouter/**", "browser/components/newtab/**", "browser/components/pocket/**", "devtools/**", diff --git a/.gitignore b/.gitignore index b10285e79d49..8ef23e362cf6 100644 --- a/.gitignore +++ b/.gitignore @@ -56,12 +56,20 @@ security/manager/.nss.checkout # gecko.log is generated by various test harnesses /gecko.log +# Ignore all node_modules directories +node_modules/ +# ...but allow ones under third_party +!/third_party/**/node_modules/ + # Ignore newtab component build assets browser/components/newtab/logs/ # Ignore about:welcome component build assets browser/components/aboutwelcome/logs/ +# Ignore ASRouter component build assets +browser/components/asrouter/logs/ + # Ignore ASRouter generated test files browser/components/newtab/content-src/asrouter/schemas/corpus/CFRMessageProvider.messages.json browser/components/newtab/content-src/asrouter/schemas/corpus/OnboardingMessageProvider.messages.json diff --git a/.hgignore b/.hgignore index 372191c60bea..caa7ca78f540 100644 --- a/.hgignore +++ b/.hgignore @@ -60,6 +60,9 @@ compile_commands\.json # Ignore about:welcome build assets ^browser/components/aboutwelcome/logs/ +# Ignore ASRouter component build assets +^browser/components/asrouter/logs/ + # Ignore ASRouter generated test files ^browser/components/newtab/content-src/asrouter/schemas/corpus/CFRMessageProvider.messages.json ^browser/components/newtab/content-src/asrouter/schemas/corpus/OnboardingMessageProvider.messages.json @@ -198,6 +201,7 @@ _OPT\.OBJ/ ^node_modules/ ^tools/browsertime/node_modules/ ^tools/lint/eslint/eslint-plugin-mozilla/node_modules/ +^browser/components/asrouter/node_modules/ ^browser/components/newtab/node_modules/ ^browser/components/aboutwelcome/node_modules/ ^tools/esmify/node_modules/ diff --git a/.prettierignore b/.prettierignore index 745d5c75327a..4b0e6cdceedb 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1449,6 +1449,9 @@ xpcom/io/crc32c.c .gradle/ browser/components/aboutwelcome/content/aboutwelcome.bundle.js +browser/components/asrouter/node_modules/ +browser/components/asrouter/content/asrouter-admin.bundle.js +browser/components/asrouter/logs/ browser/components/newtab/content-src/asrouter/schemas/BackgroundTaskMessagingExperiment.schema.json browser/components/newtab/content-src/asrouter/schemas/MessagingExperiment.schema.json browser/components/newtab/logs/ diff --git a/.stylelintignore b/.stylelintignore index e3b31b94f975..500f560cfb78 100644 --- a/.stylelintignore +++ b/.stylelintignore @@ -26,6 +26,7 @@ obj*/ browser/components/pocket/content/panels/css/main.compiled.css browser/components/newtab/**/*.css browser/components/aboutwelcome/**/*.css +browser/components/asrouter/**/*.css # Note that the debugger has its own stylelint setup, but that currently # produces errors. Bug 1831302 tracks making this better diff --git a/.stylelintrc.js b/.stylelintrc.js index f535e50146c3..8be63e69b239 100644 --- a/.stylelintrc.js +++ b/.stylelintrc.js @@ -260,6 +260,7 @@ module.exports = { { files: [ "browser/components/aboutwelcome/**", + "browser/components/asrouter/**", "browser/components/newtab/**", ], customSyntax: "postcss-scss", diff --git a/browser/components/BrowserGlue.sys.mjs b/browser/components/BrowserGlue.sys.mjs index 2f70b1107fee..f2476ff8a4bd 100644 --- a/browser/components/BrowserGlue.sys.mjs +++ b/browser/components/BrowserGlue.sys.mjs @@ -581,6 +581,7 @@ let JSWINDOWACTORS = { includeChrome: true, allFrames: true, matches: [ + "about:asrouter", "about:home", "about:newtab", "about:welcome", @@ -796,6 +797,7 @@ let JSWINDOWACTORS = { }, }, matches: [ + "about:asrouter*", "about:home*", "about:newtab*", "about:welcome*", diff --git a/browser/components/about/AboutRedirector.cpp b/browser/components/about/AboutRedirector.cpp index 41cbc511a093..46c2052fe290 100644 --- a/browser/components/about/AboutRedirector.cpp +++ b/browser/components/about/AboutRedirector.cpp @@ -44,6 +44,11 @@ struct RedirEntry { browser/components/about/components.conf */ static const RedirEntry kRedirMap[] = { + {"asrouter", "chrome://browser/content/asrouter/asrouter-admin.html", + nsIAboutModule::URI_SAFE_FOR_UNTRUSTED_CONTENT | + nsIAboutModule::URI_CAN_LOAD_IN_PRIVILEGEDABOUT_PROCESS | + nsIAboutModule::URI_MUST_LOAD_IN_CHILD | nsIAboutModule::ALLOW_SCRIPT | + nsIAboutModule::HIDE_FROM_ABOUTABOUT}, {"blocked", "chrome://browser/content/blockedSite.xhtml", nsIAboutModule::URI_SAFE_FOR_UNTRUSTED_CONTENT | nsIAboutModule::URI_CAN_LOAD_IN_CHILD | nsIAboutModule::ALLOW_SCRIPT | diff --git a/browser/components/about/components.conf b/browser/components/about/components.conf index 2e5ea937be9d..3be5b13115f1 100644 --- a/browser/components/about/components.conf +++ b/browser/components/about/components.conf @@ -5,6 +5,7 @@ # file, You can obtain one at http://mozilla.org/MPL/2.0/. pages = [ + 'asrouter', 'blocked', 'certerror', 'downloads', diff --git a/browser/components/asrouter/.eslintrc.js b/browser/components/asrouter/.eslintrc.js new file mode 100644 index 000000000000..aae942f85bac --- /dev/null +++ b/browser/components/asrouter/.eslintrc.js @@ -0,0 +1,175 @@ +/* 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/. */ + +module.exports = { + // When adding items to this file please check for effects on sub-directories. + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, + plugins: ["import", "react", "jsx-a11y"], + settings: { + react: { + version: "16.2.0", + }, + }, + extends: ["plugin:jsx-a11y/recommended"], + overrides: [ + { + // Only mark the files as modules which are actually modules. + // TODO: Add "tests/unit/**" to this list once we get our tests built. + files: ["content-src/**"], + parserOptions: { + sourceType: "module", + }, + }, + { + // TODO: Add ./*.js and tests/unit/** to this list if necessary + files: ["./*.js", "content-src/**"], + env: { + node: true, + }, + }, + /* TODO: Turn this rule on when I move the tests over. + { + // Use a configuration that's appropriate for modules, workers and + // non-production files. + files: ["modules/*.jsm", "tests/**"], + rules: { + "no-implicit-globals": "off", + }, + }, + */ + { + // TODO: Add "tests/unit/**" to this list once we get our tests built. + files: ["content-src/**"], + rules: { + // Disallow commonjs in these directories. + "import/no-commonjs": 2, + // Allow JSX with arrow functions. + "react/jsx-no-bind": 0, + }, + }, + /* TODO: Turn this rule on when I move the tests over. + { + // These tests simulate the browser environment. + files: "tests/unit/**", + env: { + browser: true, + mocha: true, + }, + globals: { + assert: true, + chai: true, + sinon: true, + }, + }, + { + files: "tests/**", + rules: { + "func-name-matching": 0, + "lines-between-class-members": 0, + "require-await": 0, + }, + },*/ + ], + rules: { + "fetch-options/no-fetch-credentials": "error", + + "react/jsx-boolean-value": ["error", "always"], + "react/jsx-key": "error", + "react/jsx-no-bind": "error", + "react/jsx-no-comment-textnodes": "error", + "react/jsx-no-duplicate-props": "error", + "react/jsx-no-target-blank": "error", + "react/jsx-no-undef": "error", + "react/jsx-pascal-case": "error", + "react/jsx-uses-react": "error", + "react/jsx-uses-vars": "error", + "react/no-access-state-in-setstate": "error", + "react/no-danger": "error", + "react/no-deprecated": "error", + "react/no-did-mount-set-state": "error", + "react/no-did-update-set-state": "error", + "react/no-direct-mutation-state": "error", + "react/no-is-mounted": "error", + "react/no-unknown-property": "error", + "react/require-render-return": "error", + + "accessor-pairs": ["error", { setWithoutGet: true, getWithoutSet: false }], + "array-callback-return": "error", + "block-scoped-var": "error", + "consistent-this": ["error", "use-bind"], + eqeqeq: "error", + "for-direction": "error", + "func-name-matching": "error", + "getter-return": "error", + "guard-for-in": "error", + "handle-callback-err": "error", + "lines-between-class-members": "error", + "max-depth": ["error", 4], + "max-nested-callbacks": ["error", 4], + "max-params": ["error", 6], + "max-statements": ["error", 50], + "max-statements-per-line": ["error", { max: 2 }], + "new-cap": ["error", { newIsCap: true, capIsNew: false }], + "no-alert": "error", + "no-buffer-constructor": "error", + "no-console": ["error", { allow: ["error"] }], + "no-div-regex": "error", + "no-duplicate-imports": "error", + "no-eq-null": "error", + "no-extend-native": "error", + "no-extra-label": "error", + "no-implicit-coercion": ["error", { allow: ["!!"] }], + "no-implicit-globals": "error", + "no-loop-func": "error", + "no-mixed-requires": "error", + "no-multi-assign": "error", + "no-multi-str": "error", + "no-new": "error", + "no-new-func": "error", + "no-new-require": "error", + "no-octal-escape": "error", + "no-param-reassign": "error", + "no-path-concat": "error", + "no-process-exit": "error", + "no-proto": "error", + "no-prototype-builtins": "error", + "no-return-assign": ["error", "except-parens"], + "no-script-url": "error", + "no-shadow": "error", + "no-template-curly-in-string": "error", + "no-undef-init": "error", + "no-unmodified-loop-condition": "error", + "no-unused-expressions": "error", + "no-use-before-define": "error", + "no-useless-computed-key": "error", + "no-useless-constructor": "error", + "no-useless-rename": "error", + "no-var": "error", + "no-void": ["error", { allowAsStatement: true }], + "one-var": ["error", "never"], + "operator-assignment": ["error", "always"], + "prefer-destructuring": [ + "error", + { + AssignmentExpression: { array: true }, + VariableDeclarator: { array: true, object: true }, + }, + ], + "prefer-numeric-literals": "error", + "prefer-promise-reject-errors": "error", + "prefer-rest-params": "error", + "prefer-spread": "error", + "prefer-template": "error", + radix: ["error", "always"], + "require-await": "error", + "sort-vars": "error", + "symbol-description": "error", + "vars-on-top": "error", + yoda: ["error", "never"], + }, +}; diff --git a/browser/components/asrouter/content-src/components/ASRouterAdmin/ASRouterAdmin.jsx b/browser/components/asrouter/content-src/components/ASRouterAdmin/ASRouterAdmin.jsx new file mode 100644 index 000000000000..7d4484dc8304 --- /dev/null +++ b/browser/components/asrouter/content-src/components/ASRouterAdmin/ASRouterAdmin.jsx @@ -0,0 +1,1503 @@ +/* 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/. */ + +import { ASRouterUtils } from "newtab/content-src/asrouter/asrouter-utils"; +import React from "react"; +import ReactDOM from "react-dom"; +import { SimpleHashRouter } from "./SimpleHashRouter"; +import { CopyButton } from "./CopyButton"; +import { ImpressionsSection } from "./ImpressionsSection"; + +const Row = props => ( + + {props.children} + +); + +function relativeTime(timestamp) { + if (!timestamp) { + return ""; + } + const seconds = Math.floor((Date.now() - timestamp) / 1000); + const minutes = Math.floor((Date.now() - timestamp) / 60000); + if (seconds < 2) { + return "just now"; + } else if (seconds < 60) { + return `${seconds} seconds ago`; + } else if (minutes === 1) { + return "1 minute ago"; + } else if (minutes < 600) { + return `${minutes} minutes ago`; + } + return new Date(timestamp).toLocaleString(); +} + +export class ToggleStoryButton extends React.PureComponent { + constructor(props) { + super(props); + this.handleClick = this.handleClick.bind(this); + } + + handleClick() { + this.props.onClick(this.props.story); + } + + render() { + return ; + } +} + +export class ToggleMessageJSON extends React.PureComponent { + constructor(props) { + super(props); + this.handleClick = this.handleClick.bind(this); + } + + handleClick() { + this.props.toggleJSON(this.props.msgId); + } + + render() { + let iconName = this.props.isCollapsed + ? "icon icon-arrowhead-forward-small" + : "icon icon-arrowhead-down-small"; + return ( + + ); + } +} + +export class TogglePrefCheckbox extends React.PureComponent { + constructor(props) { + super(props); + this.onChange = this.onChange.bind(this); + } + + onChange(event) { + this.props.onChange(this.props.pref, event.target.checked); + } + + render() { + return ( + <> + {" "} + {this.props.pref}{" "} + + ); + } +} + +export class ASRouterAdminInner extends React.PureComponent { + constructor(props) { + super(props); + this.handleEnabledToggle = this.handleEnabledToggle.bind(this); + this.handleUserPrefToggle = this.handleUserPrefToggle.bind(this); + this.onChangeMessageFilter = this.onChangeMessageFilter.bind(this); + this.onChangeMessageGroupsFilter = + this.onChangeMessageGroupsFilter.bind(this); + this.unblockAll = this.unblockAll.bind(this); + this.handleClearAllImpressionsByProvider = + this.handleClearAllImpressionsByProvider.bind(this); + this.handleExpressionEval = this.handleExpressionEval.bind(this); + this.onChangeTargetingParameters = + this.onChangeTargetingParameters.bind(this); + this.onChangeAttributionParameters = + this.onChangeAttributionParameters.bind(this); + this.setAttribution = this.setAttribution.bind(this); + this.onCopyTargetingParams = this.onCopyTargetingParams.bind(this); + this.onNewTargetingParams = this.onNewTargetingParams.bind(this); + this.handleOpenPB = this.handleOpenPB.bind(this); + this.selectPBMessage = this.selectPBMessage.bind(this); + this.resetPBJSON = this.resetPBJSON.bind(this); + this.resetPBMessageState = this.resetPBMessageState.bind(this); + this.toggleJSON = this.toggleJSON.bind(this); + this.toggleAllMessages = this.toggleAllMessages.bind(this); + this.resetGroups = this.resetGroups.bind(this); + this.onMessageFromParent = this.onMessageFromParent.bind(this); + this.setStateFromParent = this.setStateFromParent.bind(this); + this.setState = this.setState.bind(this); + this.state = { + messageFilter: "all", + messageGroupsFilter: "all", + collapsedMessages: [], + modifiedMessages: [], + selectedPBMessage: "", + evaluationStatus: {}, + stringTargetingParameters: null, + newStringTargetingParameters: null, + copiedToClipboard: false, + attributionParameters: { + source: "addons.mozilla.org", + medium: "referral", + campaign: "non-fx-button", + content: `rta:${btoa("uBlock0@raymondhill.net")}`, + experiment: "ua-onboarding", + variation: "chrome", + ua: "Google Chrome 123", + dltoken: "00000000-0000-0000-0000-000000000000", + }, + }; + } + + onMessageFromParent({ type, data }) { + // These only exists due to onPrefChange events in ASRouter + switch (type) { + case "UpdateAdminState": { + this.setStateFromParent(data); + break; + } + } + } + + setStateFromParent(data) { + this.setState(data); + if (!this.state.stringTargetingParameters) { + const stringTargetingParameters = {}; + for (const param of Object.keys(data.targetingParameters)) { + stringTargetingParameters[param] = JSON.stringify( + data.targetingParameters[param], + null, + 2 + ); + } + this.setState({ stringTargetingParameters }); + } + } + + componentWillMount() { + ASRouterUtils.addListener(this.onMessageFromParent); + const endpoint = ASRouterUtils.getPreviewEndpoint(); + ASRouterUtils.sendMessage({ + type: "ADMIN_CONNECT_STATE", + data: { endpoint }, + }).then(this.setStateFromParent); + } + + componentWillUnmount() { + ASRouterUtils.removeListener(this.onMessageFromParent); + } + + handleBlock(msg) { + return () => ASRouterUtils.blockById(msg.id); + } + + handleUnblock(msg) { + return () => ASRouterUtils.unblockById(msg.id); + } + + resetJSON(msg) { + // reset the displayed JSON for the given message + document.getElementById(`${msg.id}-textarea`).value = JSON.stringify( + msg, + null, + 2 + ); + // remove the message from the list of modified IDs + let index = this.state.modifiedMessages.indexOf(msg.id); + this.setState(prevState => ({ + modifiedMessages: [ + ...prevState.modifiedMessages.slice(0, index), + ...prevState.modifiedMessages.slice(index + 1), + ], + })); + } + + handleOverride(id) { + return () => + ASRouterUtils.overrideMessage(id).then(state => { + this.setStateFromParent(state); + }); + } + + resetPBMessageState() { + // Iterate over Private Browsing messages and block/unblock each one to clear impressions + const PBMessages = this.state.messages.filter( + message => message.template === "pb_newtab" + ); // messages from state go here + + PBMessages.forEach(message => { + if (message?.id) { + ASRouterUtils.blockById(message.id); + ASRouterUtils.unblockById(message.id); + } + }); + // Clear the selected messages & radio buttons + document.getElementById("clear radio").checked = true; + this.selectPBMessage("clear"); + } + + resetPBJSON(msg) { + // reset the displayed JSON for the given message + document.getElementById(`${msg.id}-textarea`).value = JSON.stringify( + msg, + null, + 2 + ); + } + + handleOpenPB() { + ASRouterUtils.sendMessage({ + type: "FORCE_PRIVATE_BROWSING_WINDOW", + data: { message: { content: this.state.selectedPBMessage } }, + }); + } + + expireCache() { + ASRouterUtils.sendMessage({ type: "EXPIRE_QUERY_CACHE" }); + } + + resetPref() { + ASRouterUtils.sendMessage({ type: "RESET_PROVIDER_PREF" }); + } + + resetGroups(id, value) { + ASRouterUtils.sendMessage({ + type: "RESET_GROUPS_STATE", + }).then(this.setStateFromParent); + } + + handleExpressionEval() { + const context = {}; + for (const param of Object.keys(this.state.stringTargetingParameters)) { + const value = this.state.stringTargetingParameters[param]; + context[param] = value ? JSON.parse(value) : null; + } + ASRouterUtils.sendMessage({ + type: "EVALUATE_JEXL_EXPRESSION", + data: { + expression: this.refs.expressionInput.value, + context, + }, + }).then(this.setStateFromParent); + } + + onChangeTargetingParameters(event) { + const { name } = event.target; + const { value } = event.target; + + this.setState(({ stringTargetingParameters }) => { + let targetingParametersError = null; + const updatedParameters = { ...stringTargetingParameters }; + updatedParameters[name] = value; + try { + JSON.parse(value); + } catch (e) { + console.error(`Error parsing value of parameter ${name}`); + targetingParametersError = { id: name }; + } + + return { + copiedToClipboard: false, + evaluationStatus: {}, + stringTargetingParameters: updatedParameters, + targetingParametersError, + }; + }); + } + + unblockAll() { + return ASRouterUtils.sendMessage({ + type: "UNBLOCK_ALL", + }).then(this.setStateFromParent); + } + + handleClearAllImpressionsByProvider() { + const providerId = this.state.messageFilter; + if (!providerId) { + return; + } + const userPrefInfo = this.state.userPrefs; + + const isUserEnabled = + providerId in userPrefInfo ? userPrefInfo[providerId] : true; + + ASRouterUtils.sendMessage({ + type: "DISABLE_PROVIDER", + data: providerId, + }); + if (!isUserEnabled) { + ASRouterUtils.sendMessage({ + type: "SET_PROVIDER_USER_PREF", + data: { id: providerId, value: true }, + }); + } + ASRouterUtils.sendMessage({ + type: "ENABLE_PROVIDER", + data: providerId, + }); + } + + handleEnabledToggle(event) { + const provider = this.state.providerPrefs.find( + p => p.id === event.target.dataset.provider + ); + const userPrefInfo = this.state.userPrefs; + + const isUserEnabled = + provider.id in userPrefInfo ? userPrefInfo[provider.id] : true; + const isSystemEnabled = provider.enabled; + const isEnabling = event.target.checked; + + if (isEnabling) { + if (!isUserEnabled) { + ASRouterUtils.sendMessage({ + type: "SET_PROVIDER_USER_PREF", + data: { id: provider.id, value: true }, + }); + } + if (!isSystemEnabled) { + ASRouterUtils.sendMessage({ + type: "ENABLE_PROVIDER", + data: provider.id, + }); + } + } else { + ASRouterUtils.sendMessage({ + type: "DISABLE_PROVIDER", + data: provider.id, + }); + } + + this.setState({ messageFilter: "all" }); + } + + handleUserPrefToggle(event) { + const action = { + type: "SET_PROVIDER_USER_PREF", + data: { id: event.target.dataset.provider, value: event.target.checked }, + }; + ASRouterUtils.sendMessage(action); + this.setState({ messageFilter: "all" }); + } + + onChangeMessageFilter(event) { + this.setState({ messageFilter: event.target.value }); + } + + onChangeMessageGroupsFilter(event) { + this.setState({ messageGroupsFilter: event.target.value }); + } + + // Simulate a copy event that sets to clipboard all targeting paramters and values + onCopyTargetingParams(event) { + const stringTargetingParameters = { + ...this.state.stringTargetingParameters, + }; + for (const key of Object.keys(stringTargetingParameters)) { + // If the value is not set the parameter will be lost when we stringify + if (stringTargetingParameters[key] === undefined) { + stringTargetingParameters[key] = null; + } + } + const setClipboardData = e => { + e.preventDefault(); + e.clipboardData.setData( + "text", + JSON.stringify(stringTargetingParameters, null, 2) + ); + document.removeEventListener("copy", setClipboardData); + this.setState({ copiedToClipboard: true }); + }; + + document.addEventListener("copy", setClipboardData); + + document.execCommand("copy"); + } + + onNewTargetingParams(event) { + this.setState({ newStringTargetingParameters: event.target.value }); + event.target.classList.remove("errorState"); + this.refs.targetingParamsEval.innerText = ""; + + try { + const stringTargetingParameters = JSON.parse(event.target.value); + this.setState({ stringTargetingParameters }); + } catch (e) { + event.target.classList.add("errorState"); + this.refs.targetingParamsEval.innerText = e.message; + } + } + + toggleJSON(msgId) { + if (this.state.collapsedMessages.includes(msgId)) { + let index = this.state.collapsedMessages.indexOf(msgId); + this.setState(prevState => ({ + collapsedMessages: [ + ...prevState.collapsedMessages.slice(0, index), + ...prevState.collapsedMessages.slice(index + 1), + ], + })); + } else { + this.setState(prevState => ({ + collapsedMessages: prevState.collapsedMessages.concat(msgId), + })); + } + } + + handleChange(msgId) { + if (!this.state.modifiedMessages.includes(msgId)) { + this.setState(prevState => ({ + modifiedMessages: prevState.modifiedMessages.concat(msgId), + })); + } + } + + renderMessageItem(msg) { + const isBlockedByGroup = this.state.groups + .filter(group => msg.groups.includes(group.id)) + .some(group => !group.enabled); + const msgProvider = + this.state.providers.find(provider => provider.id === msg.provider) || {}; + const isProviderExcluded = + msgProvider.exclude && msgProvider.exclude.includes(msg.id); + const isMessageBlocked = + this.state.messageBlockList.includes(msg.id) || + this.state.messageBlockList.includes(msg.campaign); + const isBlocked = + isMessageBlocked || isBlockedByGroup || isProviderExcluded; + const impressions = this.state.messageImpressions[msg.id] + ? this.state.messageImpressions[msg.id].length + : 0; + const isCollapsed = this.state.collapsedMessages.includes(msg.id); + const isModified = this.state.modifiedMessages.includes(msg.id); + const aboutMessagePreviewSupported = [ + "infobar", + "spotlight", + "cfr_doorhanger", + ].includes(msg.template); + + let itemClassName = "message-item"; + if (isBlocked) { + itemClassName += " blocked"; + } + + return ( + + + + {msg.id}
+
+ + + + + + + { + // eslint-disable-next-line no-nested-ternary + isBlocked ? null : isModified ? ( + + ) : ( + + ) + } + {isBlocked ? null : ( + + )} + {aboutMessagePreviewSupported ? ( + + `about:messagepreview?json=${encodeURIComponent(btoa(text))}` + } + label="Share" + copiedLabel="Copied!" + inputSelector={`#${msg.id}-textarea`} + className={"button share"} + /> + ) : null} +
({impressions} impressions) + + + {isBlocked && ( + + Block reason: + {isBlockedByGroup && " Blocked by group"} + {isProviderExcluded && " Excluded by provider"} + {isMessageBlocked && " Message blocked"} + + )} + +
+              
+            
+ + + + ); + } + + selectPBMessage(msgId) { + if (msgId === "clear") { + this.setState({ + selectedPBMessage: "", + }); + } else { + let selected = document.getElementById(`${msgId} radio`); + let msg = JSON.parse(document.getElementById(`${msgId}-textarea`).value); + + if (selected.checked) { + this.setState({ + selectedPBMessage: msg?.content, + }); + } else { + this.setState({ + selectedPBMessage: "", + }); + } + } + } + + modifyJson(content) { + const message = JSON.parse( + document.getElementById(`${content.id}-textarea`).value + ); + return ASRouterUtils.modifyMessageJson(message).then(state => { + this.setStateFromParent(state); + }); + } + + renderPBMessageItem(msg) { + const isBlocked = + this.state.messageBlockList.includes(msg.id) || + this.state.messageBlockList.includes(msg.campaign); + const impressions = this.state.messageImpressions[msg.id] + ? this.state.messageImpressions[msg.id].length + : 0; + + const isCollapsed = this.state.collapsedMessages.includes(msg.id); + + let itemClassName = "message-item"; + if (isBlocked) { + itemClassName += " blocked"; + } + + return ( + + + + {msg.id}
+
({impressions} impressions) +
+ + + + + + this.selectPBMessage(msg.id)} + disabled={isBlocked} + /> + + + + +
+            
+          
+ + + ); + } + + toggleAllMessages(messagesToShow) { + if (this.state.collapsedMessages.length) { + this.setState({ + collapsedMessages: [], + }); + } else { + Array.prototype.forEach.call(messagesToShow, msg => { + this.setState(prevState => ({ + collapsedMessages: prevState.collapsedMessages.concat(msg.id), + })); + }); + } + } + + renderMessages() { + if (!this.state.messages) { + return null; + } + const messagesToShow = + this.state.messageFilter === "all" + ? this.state.messages + : this.state.messages.filter( + message => + message.provider === this.state.messageFilter && + message.template !== "pb_newtab" + ); + + return ( +
+ +

+ {" "} + + To modify a message, change the JSON and click 'Modify' to see your + changes. Click 'Reset' to restore the JSON to the original. Click + 'Share' to copy a link to the clipboard that can be used to preview + the message by opening the link in Nightly/local builds. + +

+ + + {messagesToShow.map(msg => this.renderMessageItem(msg))} + +
+
+ ); + } + + renderMessagesByGroup() { + if (!this.state.messages) { + return null; + } + const messagesToShow = + this.state.messageGroupsFilter === "all" + ? this.state.messages.filter(m => m.groups.length) + : this.state.messages.filter(message => + message.groups.includes(this.state.messageGroupsFilter) + ); + + return ( + + {messagesToShow.map(msg => this.renderMessageItem(msg))} +
+ ); + } + + renderPBMessages() { + if (!this.state.messages) { + return null; + } + const messagesToShow = this.state.messages.filter( + message => message.template === "pb_newtab" + ); + return ( + + + {messagesToShow.map(msg => this.renderPBMessageItem(msg))} + +
+ ); + } + + renderMessageFilter() { + if (!this.state.providers) { + return null; + } + + return ( +

+ + Show messages from{" "} + + {this.state.messageFilter !== "all" && + !this.state.messageFilter.includes("_local_testing") ? ( + + ) : null} +

+ ); + } + + renderMessageGroupsFilter() { + if (!this.state.groups) { + return null; + } + + return ( +

+ Show messages from {/* eslint-disable-next-line jsx-a11y/no-onchange */} + +

+ ); + } + + renderTableHead() { + return ( + + + + Provider ID + Source + Cohort + Last Updated + + + ); + } + + renderProviders() { + const providersConfig = this.state.providerPrefs; + const providerInfo = this.state.providers; + const userPrefInfo = this.state.userPrefs; + + return ( + + {this.renderTableHead()} + + {providersConfig.map((provider, i) => { + const isTestProvider = provider.id.includes("_local_testing"); + const info = providerInfo.find(p => p.id === provider.id) || {}; + const isUserEnabled = + provider.id in userPrefInfo ? userPrefInfo[provider.id] : true; + const isSystemEnabled = isTestProvider || provider.enabled; + + let label = "local"; + if (provider.type === "remote") { + label = ( + + endpoint ( + + {info.url} + + ) + + ); + } else if (provider.type === "remote-settings") { + label = `remote settings (${provider.collection})`; + } else if (provider.type === "remote-experiments") { + label = ( + + remote settings ( + + nimbus-desktop-experiments + + ) + + ); + } + + let reasonsDisabled = []; + if (!isSystemEnabled) { + reasonsDisabled.push("system pref"); + } + if (!isUserEnabled) { + reasonsDisabled.push("user pref"); + } + if (reasonsDisabled.length) { + label = `disabled via ${reasonsDisabled.join(", ")}`; + } + + return ( + + + + + + + + ); + })} + +
+ {isTestProvider ? ( + + ) : ( + + )} + {provider.id} + + {label} + + {provider.cohort} + {info.lastUpdated + ? new Date(info.lastUpdated).toLocaleString() + : ""} +
+ ); + } + + renderTargetingParameters() { + // There was no error and the result is truthy + const success = + this.state.evaluationStatus.success && + !!this.state.evaluationStatus.result; + const result = + JSON.stringify(this.state.evaluationStatus.result, null, 2) || + "(Empty result)"; + + return ( + + + + + + +
+

Evaluate JEXL expression

+
+

+