Project import generated by Copybara.
GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/static_src/elements/help/mr-cue/cue-helpers.js b/static_src/elements/help/mr-cue/cue-helpers.js
new file mode 100644
index 0000000..4aa30d7
--- /dev/null
+++ b/static_src/elements/help/mr-cue/cue-helpers.js
@@ -0,0 +1,49 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+/**
+ * @fileoverview Shared helpers for dealing with how <mr-cue> element instances
+ * are used.
+ */
+
+export const cueNames = Object.freeze({
+ CODE_OF_CONDUCT: 'code_of_conduct',
+ AVAILABILITY_MSGS: 'availability_msgs',
+ SWITCH_TO_PARENT_ACCOUNT: 'switch_to_parent_account',
+ SEARCH_FOR_NUMBERS: 'search_for_numbers',
+});
+
+export const AVAILABLE_CUES = Object.freeze(new Set(Object.values(cueNames)));
+
+export const CUE_DISPLAY_PREFIX = 'cue.';
+
+/**
+ * Converts a cue name to the format expected by components like <mr-metadata>
+ * for the purpose of ordering fields.
+ *
+ * @param {string} cueName The name of the cue.
+ * @return {string} A "cue.cue_name" formatted String used in ordering cues
+ * alongside field types (ie: Owner) in various field specs.
+ */
+export const cueNameToSpec = (cueName) => {
+ return CUE_DISPLAY_PREFIX + cueName;
+};
+
+/**
+ * Converts an issue field specifier to the name of the cue it references if
+ * it references a cue. ie: "cue.cue_name" would reference "cue_name".
+ *
+ * @param {string} spec A "cue.cue_name" format String specifying that a
+ * specific cue should be mixed alongside issue fields in a component like
+ * <mr-metadata>.
+ * @return {string} Name of the cue customized in the spec or an empty
+ * String if the spec does not reference a cue.
+ */
+export const specToCueName = (spec) => {
+ spec = spec.toLowerCase();
+ if (spec.startsWith(CUE_DISPLAY_PREFIX)) {
+ return spec.substring(CUE_DISPLAY_PREFIX.length);
+ }
+ return '';
+};
diff --git a/static_src/elements/help/mr-cue/cue-helpers.test.js b/static_src/elements/help/mr-cue/cue-helpers.test.js
new file mode 100644
index 0000000..3bc084a
--- /dev/null
+++ b/static_src/elements/help/mr-cue/cue-helpers.test.js
@@ -0,0 +1,30 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {assert} from 'chai';
+import {cueNameToSpec, specToCueName} from './cue-helpers.js';
+
+
+describe('cue-helpers', () => {
+ describe('cueNameToSpec', () => {
+ it('appends cue prefix', () => {
+ assert.equal(cueNameToSpec('test'), 'cue.test');
+ });
+ });
+
+ describe('specToCueName', () => {
+ it('extracts cue name from matching spec', () => {
+ assert.equal(specToCueName('cue.test'), 'test');
+ assert.equal(specToCueName('cue.hello-world'), 'hello-world');
+ assert.equal(specToCueName('cue.under_score'), 'under_score');
+ });
+
+ it('does not extract cue name from non-matching spec', () => {
+ assert.equal(specToCueName('.cue.test'), '');
+ assert.equal(specToCueName('hello-world-cue.'), '');
+ assert.equal(specToCueName('cu.under_score'), '');
+ assert.equal(specToCueName('field'), '');
+ });
+ });
+});
diff --git a/static_src/elements/help/mr-cue/mr-cue.js b/static_src/elements/help/mr-cue/mr-cue.js
new file mode 100644
index 0000000..22b1290
--- /dev/null
+++ b/static_src/elements/help/mr-cue/mr-cue.js
@@ -0,0 +1,282 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {LitElement, html, css} from 'lit-element';
+import qs from 'qs';
+import {store, connectStore} from 'reducers/base.js';
+import * as userV0 from 'reducers/userV0.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import * as projectV0 from 'reducers/projectV0.js';
+import 'elements/chops/chops-button/chops-button.js';
+import 'elements/chops/chops-dialog/chops-dialog.js';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+import {cueNames} from './cue-helpers.js';
+
+
+/**
+ * `<mr-cue>`
+ *
+ * An element that displays one of a set of predefined help messages
+ * iff that message is appropriate to the current user and page.
+ *
+ * TODO: Factor this class out into a base view component and separate
+ * usage-specific components, such as those for user prefs.
+ *
+ */
+export class MrCue extends connectStore(LitElement) {
+ /** @override */
+ constructor() {
+ super();
+ this.prefs = new Map();
+ this.issue = null;
+ this.referencedUsers = new Map();
+ this.nondismissible = false;
+ this.cuePrefName = '';
+ this.loginUrl = '';
+ this.hidden = this._shouldBeHidden(this.signedIn, this.prefsLoaded,
+ this.cuePrefName, this.message);
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ issue: {type: Object},
+ referencedUsers: {type: Object},
+ user: {type: Object},
+ cuePrefName: {type: String},
+ nondismissible: {type: Boolean},
+ prefs: {type: Object},
+ prefsLoaded: {type: Boolean},
+ jumpLocalId: {type: Number},
+ loginUrl: {type: String},
+ hidden: {
+ type: Boolean,
+ reflect: true,
+ },
+ };
+ }
+
+ /** @override */
+ static get styles() {
+ return [SHARED_STYLES, css`
+ :host {
+ display: block;
+ margin: 2px 0;
+ padding: 2px 4px 2px 8px;
+ background: var(--chops-notice-bubble-bg);
+ border: var(--chops-notice-border);
+ text-align: center;
+ }
+ :host([centered]) {
+ display: flex;
+ justify-content: center;
+ }
+ :host([hidden]) {
+ display: none;
+ }
+ button[hidden] {
+ visibility: hidden;
+ }
+ i.material-icons {
+ font-size: 14px;
+ }
+ button {
+ background: none;
+ border: none;
+ float: right;
+ padding: 2px;
+ cursor: pointer;
+ border-radius: 50%;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ }
+ button:hover {
+ background: rgba(0, 0, 0, .2);
+ }
+ `];
+ }
+
+ /** @override */
+ render() {
+ return html`
+ <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+ <button
+ @click=${this.dismiss}
+ title="Don't show this message again."
+ ?hidden=${this.nondismissible}>
+ <i class="material-icons">close</i>
+ </button>
+ <div id="message">${this.message}</div>
+ `;
+ }
+
+ /**
+ * @return {TemplateResult} lit-html template for the cue message a user
+ * should see.
+ */
+ get message() {
+ if (this.cuePrefName === cueNames.CODE_OF_CONDUCT) {
+ return html`
+ Please keep discussions respectful and constructive.
+ See our
+ <a href="${this.codeOfConductUrl}"
+ target="_blank">code of conduct</a>.
+ `;
+ } else if (this.cuePrefName === cueNames.AVAILABILITY_MSGS) {
+ if (this._availablityMsgsRelevant(this.issue)) {
+ return html`
+ <b>Note:</b>
+ Clock icons indicate that users may not be available.
+ Tooltips show the reason.
+ `;
+ }
+ } else if (this.cuePrefName === cueNames.SWITCH_TO_PARENT_ACCOUNT) {
+ if (this._switchToParentAccountRelevant()) {
+ return html`
+ You are signed in to a linked account.
+ <a href="${this.loginUrl}">
+ Switch to ${this.user.linkedParentRef.displayName}</a>.
+ `;
+ }
+ } else if (this.cuePrefName === cueNames.SEARCH_FOR_NUMBERS) {
+ if (this._searchForNumbersRelevant(this.jumpLocalId)) {
+ return html`
+ <b>Tip:</b>
+ To find issues containing "${this.jumpLocalId}", use quotes.
+ `;
+ }
+ }
+ return;
+ }
+
+ /**
+ * Conditionally returns a hardcoded code of conduct URL for
+ * different projects.
+ * @return {string} the URL for the code of conduct.
+ */
+ get codeOfConductUrl() {
+ // TODO(jrobbins): Store this in the DB and pass it via the API.
+ if (this.projectName === 'fuchsia') {
+ return 'https://fuchsia.dev/fuchsia-src/CODE_OF_CONDUCT';
+ }
+ return ('https://chromium.googlesource.com/' +
+ 'chromium/src/+/main/CODE_OF_CONDUCT.md');
+ }
+
+ /** @override */
+ updated(changedProperties) {
+ const hiddenWatchProps = ['prefsLoaded', 'cuePrefName', 'signedIn',
+ 'prefs'];
+ const shouldUpdateHidden = Array.from(changedProperties.keys())
+ .some((propName) => hiddenWatchProps.includes(propName));
+ if (shouldUpdateHidden) {
+ this.hidden = this._shouldBeHidden(this.signedIn, this.prefsLoaded,
+ this.cuePrefName, this.message);
+ }
+ }
+
+ /**
+ * Checks if there are any unavailable users and only displays this cue if so.
+ * @param {Issue} issue
+ * @return {boolean} Whether the User Availability cue should be
+ * displayed or not.
+ */
+ _availablityMsgsRelevant(issue) {
+ if (!issue) return false;
+ return (this._anyUnvailable([issue.ownerRef]) ||
+ this._anyUnvailable(issue.ccRefs));
+ }
+
+ /**
+ * Checks if a given list of users contains any unavailable users.
+ * @param {Array<UserRef>} userRefList
+ * @return {boolean} Whether there are unavailable users.
+ */
+ _anyUnvailable(userRefList) {
+ if (!userRefList) return false;
+ for (const userRef of userRefList) {
+ if (userRef) {
+ const participant = this.referencedUsers.get(userRef.displayName);
+ if (participant && participant.availability) return true;
+ }
+ }
+ }
+
+ /**
+ * Finds if the user has a linked parent account that's separate from the
+ * one they are logged into and conditionally hides the cue if so.
+ * @return {boolean} Whether to show the cue to switch to a parent account.
+ */
+ _switchToParentAccountRelevant() {
+ return this.user && this.user.linkedParentRef;
+ }
+
+ /**
+ * Determines whether the user should see a cue telling them how to avoid the
+ * "jump to issue" feature.
+ * @param {number} jumpLocalId the ID of the issue the user jumped to.
+ * @return {boolean} Whether the user jumped to a number or not.
+ */
+ _searchForNumbersRelevant(jumpLocalId) {
+ return !!jumpLocalId;
+ }
+
+ /**
+ * Checks the user's preferences to hide a particular cue if they have
+ * dismissed it.
+ * @param {boolean} signedIn Whether the user is signed in.
+ * @param {boolean} prefsLoaded Whether the user's prefs have been fetched
+ * from the API.
+ * @param {string} cuePrefName The name of the cue being checked.
+ * @param {string} message
+ * @return {boolean} Whether the cue should be hidden.
+ */
+ _shouldBeHidden(signedIn, prefsLoaded, cuePrefName, message) {
+ if (signedIn && !prefsLoaded) return true;
+ if (this.alreadyDismissed(cuePrefName)) return true;
+ return !message;
+ }
+
+ /** @override */
+ stateChanged(state) {
+ this.projectName = projectV0.viewedProjectName(state);
+ this.issue = issueV0.viewedIssue(state);
+ this.referencedUsers = issueV0.referencedUsers(state);
+ this.user = userV0.currentUser(state);
+ this.prefs = userV0.prefs(state);
+ this.signedIn = this.user && this.user.userId;
+ this.prefsLoaded = userV0.currentUser(state).prefsLoaded;
+
+ const queryString = window.location.search.substring(1);
+ const queryParams = qs.parse(queryString);
+ const q = queryParams.q;
+ if (q && q.match(new RegExp('^\\d+$'))) {
+ this.jumpLocalId = Number(q);
+ }
+ }
+
+ /**
+ * Check whether a cue has already been dismissed in a user's
+ * preferences.
+ * @param {string} pref The name of the user preference to check.
+ * @return {boolean} Whether the cue was dismissed or not.
+ */
+ alreadyDismissed(pref) {
+ return this.prefs && this.prefs.get(pref);
+ }
+
+ /**
+ * Sends a request to the API to save that a user has dismissed a cue.
+ * The results of this request update Redux's state, which leads to
+ * the cue disappearing for the user after the request finishes.
+ * @return {void}
+ */
+ dismiss() {
+ const newPrefs = [{name: this.cuePrefName, value: 'true'}];
+ store.dispatch(userV0.setPrefs(newPrefs, this.signedIn));
+ }
+}
+
+customElements.define('mr-cue', MrCue);
diff --git a/static_src/elements/help/mr-cue/mr-cue.test.js b/static_src/elements/help/mr-cue/mr-cue.test.js
new file mode 100644
index 0000000..2722076
--- /dev/null
+++ b/static_src/elements/help/mr-cue/mr-cue.test.js
@@ -0,0 +1,177 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {assert} from 'chai';
+import {MrCue} from './mr-cue.js';
+import page from 'page';
+import {rootReducer} from 'reducers/base.js';
+
+let element;
+
+describe('mr-cue', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-cue');
+ document.body.appendChild(element);
+
+ sinon.stub(page, 'call');
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+
+ page.call.restore();
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrCue);
+ });
+
+ it('stateChanged', () => {
+ const state = rootReducer({
+ userV0: {currentUser: {prefs: new Map(), prefsLoaded: false}},
+ }, {});
+ element.stateChanged(state);
+ assert.deepEqual(element.prefs, new Map([['render_markdown', false]]));
+ assert.isFalse(element.prefsLoaded);
+ });
+
+ it('cues are hidden before prefs load', () => {
+ element.prefsLoaded = false;
+ assert.isTrue(element.hidden);
+ });
+
+ it('cue is hidden if user already dismissed it', () => {
+ element.prefsLoaded = true;
+ element.cuePrefName = 'code_of_conduct';
+ element.prefs = new Map([['code_of_conduct', true]]);
+ assert.isTrue(element.hidden);
+ });
+
+ it('cue is hidden if no relevent message', () => {
+ element.prefsLoaded = true;
+ element.cuePrefName = 'this_has_no_message';
+ assert.isTrue(element.hidden);
+ });
+
+ it('cue is shown if relevant message has not been dismissed', async () => {
+ element.prefsLoaded = true;
+ element.cuePrefName = 'code_of_conduct';
+
+ await element.updateComplete;
+
+ assert.isFalse(element.hidden);
+ const messageEl = element.shadowRoot.querySelector('#message');
+ assert.include(messageEl.innerHTML, 'chromium.googlesource.com');
+ });
+
+ it('code of conduct is specific to the project', async () => {
+ element.prefsLoaded = true;
+ element.cuePrefName = 'code_of_conduct';
+ element.projectName = 'fuchsia';
+
+ await element.updateComplete;
+
+ assert.isFalse(element.hidden);
+ const messageEl = element.shadowRoot.querySelector('#message');
+ assert.include(messageEl.innerHTML, 'fuchsia.dev');
+ });
+
+ it('availability cue is hidden if no relevent issue particpants', () => {
+ element.prefsLoaded = true;
+ element.cuePrefName = 'availability_msgs';
+ element.issue = {summary: 'no owners or cc'};
+ assert.isTrue(element.hidden);
+
+ element.issue = {
+ summary: 'owner and ccs have no availability msg',
+ ownerRef: {},
+ ccRefs: [{}, {}],
+ };
+ assert.isTrue(element.hidden);
+ });
+
+ it('availability cue is shown if issue particpants are unavailable',
+ async () => {
+ element.prefsLoaded = true;
+ element.cuePrefName = 'availability_msgs';
+ element.referencedUsers = new Map([
+ ['user@example.com', {availability: 'Never visited'}],
+ ]);
+
+ element.issue = {
+ summary: 'owner is unavailable',
+ ownerRef: {displayName: 'user@example.com'},
+ ccRefs: [{}, {}],
+ };
+ await element.updateComplete;
+
+ assert.isFalse(element.hidden);
+ const messageEl = element.shadowRoot.querySelector('#message');
+ assert.include(messageEl.innerText, 'Clock icons');
+
+ element.issue = {
+ summary: 'owner is unavailable',
+ ownerRef: {},
+ ccRefs: [
+ {displayName: 'ok@example.com'},
+ {displayName: 'user@example.com'}],
+ };
+ await element.updateComplete;
+ assert.isFalse(element.hidden);
+ assert.include(messageEl.innerText, 'Clock icons');
+ });
+
+ it('switch_to_parent_account cue is hidden if no linked account', () => {
+ element.prefsLoaded = true;
+ element.cuePrefName = 'switch_to_parent_account';
+
+ element.user = undefined;
+ assert.isTrue(element.hidden);
+
+ element.user = {groups: []};
+ assert.isTrue(element.hidden);
+ });
+
+ it('switch_to_parent_account is shown if user has parent account',
+ async () => {
+ element.prefsLoaded = true;
+ element.cuePrefName = 'switch_to_parent_account';
+ element.user = {linkedParentRef: {displayName: 'parent@example.com'}};
+
+ await element.updateComplete;
+ assert.isFalse(element.hidden);
+ const messageEl = element.shadowRoot.querySelector('#message');
+ assert.include(messageEl.innerText, 'a linked account');
+ });
+
+ it('search_for_numbers cue is hidden if no number was used', () => {
+ element.prefsLoaded = true;
+ element.cuePrefName = 'search_for_numbers';
+ element.issue = {};
+ element.jumpLocalId = null;
+ assert.isTrue(element.hidden);
+ });
+
+ it('search_for_numbers cue is shown if jumped to issue ID',
+ async () => {
+ element.prefsLoaded = true;
+ element.cuePrefName = 'search_for_numbers';
+ element.issue = {};
+ element.jumpLocalId = '123'.match(new RegExp('^\\d+$'));
+
+ await element.updateComplete;
+ assert.isFalse(element.hidden);
+ const messageEl = element.shadowRoot.querySelector('#message');
+ assert.include(messageEl.innerText, 'use quotes');
+ });
+
+ it('cue is dismissible unless there is attribute nondismissible',
+ async () => {
+ assert.isFalse(element.nondismissible);
+
+ element.setAttribute('nondismissible', '');
+ await element.updateComplete;
+ assert.isTrue(element.nondismissible);
+ });
+});
diff --git a/static_src/elements/help/mr-cue/mr-fed-ref-cue.js b/static_src/elements/help/mr-cue/mr-fed-ref-cue.js
new file mode 100644
index 0000000..8e8626f
--- /dev/null
+++ b/static_src/elements/help/mr-cue/mr-fed-ref-cue.js
@@ -0,0 +1,83 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {html, css} from 'lit-element';
+import * as userV0 from 'reducers/userV0.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import {store} from 'reducers/base.js';
+import 'elements/chops/chops-button/chops-button.js';
+import 'elements/chops/chops-dialog/chops-dialog.js';
+import {fromShortlink, GoogleIssueTrackerIssue} from 'shared/federated.js';
+import {MrCue} from './mr-cue.js';
+
+/**
+ * `<mr-fed-ref-cue>`
+ *
+ * Displays information and login/logout links for the federated references
+ * info popup.
+ *
+ */
+export class MrFedRefCue extends MrCue {
+ /** @override */
+ static get properties() {
+ return {
+ ...MrCue.properties,
+ fedRefShortlink: {type: String},
+ };
+ }
+
+ /** @override */
+ static get styles() {
+ return [
+ ...MrCue.styles,
+ css`
+ :host {
+ margin: 0;
+ width: 120px;
+ font-size: 11px;
+ }
+ `,
+ ];
+ }
+
+ get message() {
+ const fedRef = fromShortlink(this.fedRefShortlink);
+ if (fedRef && fedRef instanceof GoogleIssueTrackerIssue) {
+ let authLink;
+ if (this.user && this.user.gapiEmail) {
+ authLink = html`
+ <br /><br />
+ <a href="#"
+ @click=${() => store.dispatch(userV0.initGapiLogout())}
+ >Sign out</a>
+ <br />
+ (for references only)
+ `;
+ } else {
+ const clickLoginHandler = async () => {
+ await store.dispatch(userV0.initGapiLogin(this.issue));
+ // Re-fetch related issues.
+ store.dispatch(issueV0.fetchRelatedIssues(this.issue));
+ };
+ authLink = html`
+ <br /><br />
+ Googlers, to enable viewing status & title,
+ <a href="#"
+ @click=${clickLoginHandler}
+ >sign in here</a> with your Google email.
+ `;
+ }
+ return html`
+ This references an issue in the ${fedRef.trackerName} issue tracker.
+ ${authLink}
+ `;
+ } else {
+ return html`
+ This references an issue in another tracker. Status not displayed.
+ `;
+ }
+ }
+}
+
+customElements.define('mr-fed-ref-cue', MrFedRefCue);