Project import generated by Copybara.
GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/static_src/elements/issue-detail/mr-issue-page/mr-issue-header.js b/static_src/elements/issue-detail/mr-issue-page/mr-issue-header.js
new file mode 100644
index 0000000..0d04d32
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-issue-page/mr-issue-header.js
@@ -0,0 +1,379 @@
+// 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 'elements/issue-detail/mr-flipper/mr-flipper.js';
+import 'elements/chops/chops-dialog/chops-dialog.js';
+import 'elements/chops/chops-timestamp/chops-timestamp.js';
+import {store, connectStore} from 'reducers/base.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import * as userV0 from 'reducers/userV0.js';
+import * as projectV0 from 'reducers/projectV0.js';
+import {userIsMember} from 'shared/helpers.js';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+import 'elements/framework/links/mr-user-link/mr-user-link.js';
+import 'elements/framework/links/mr-crbug-link/mr-crbug-link.js';
+import 'elements/framework/mr-pref-toggle/mr-pref-toggle.js';
+import 'elements/framework/mr-dropdown/mr-dropdown.js';
+import {ISSUE_EDIT_PERMISSION, ISSUE_DELETE_PERMISSION,
+ ISSUE_FLAGSPAM_PERMISSION} from 'shared/consts/permissions.js';
+import {issueToIssueRef} from 'shared/convertersV0.js';
+import {prpcClient} from 'prpc-client-instance.js';
+import {AVAILABLE_MD_PROJECTS, DEFAULT_MD_PROJECTS} from 'shared/md-helper.js';
+
+const DELETE_ISSUE_CONFIRMATION_NOTICE = `\
+Normally, you would just close issues by setting their status to a closed value.
+Are you sure you want to delete this issue?`;
+
+
+/**
+ * `<mr-issue-header>`
+ *
+ * The header for a given launch issue.
+ *
+ */
+export class MrIssueHeader extends connectStore(LitElement) {
+ /** @override */
+ static get styles() {
+ return [
+ SHARED_STYLES,
+ css`
+ :host {
+ width: 100%;
+ margin-top: 0;
+ font-size: var(--chops-large-font-size);
+ background-color: var(--monorail-metadata-toggled-bg);
+ border-bottom: var(--chops-normal-border);
+ padding: 0.25em 8px;
+ box-sizing: border-box;
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ align-items: center;
+ }
+ h1 {
+ font-size: 100%;
+ line-height: 140%;
+ font-weight: bolder;
+ padding: 0;
+ margin: 0;
+ }
+ mr-flipper {
+ border-left: var(--chops-normal-border);
+ padding-left: 8px;
+ margin-left: 4px;
+ font-size: var(--chops-main-font-size);
+ }
+ mr-pref-toggle {
+ margin-right: 2px;
+ }
+ .issue-actions {
+ min-width: fit-content;
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ font-size: var(--chops-main-font-size);
+ }
+ .issue-actions div {
+ min-width: 70px;
+ display: flex;
+ justify-content: space-between;
+ }
+ .spam-notice {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 1px 6px;
+ border-radius: 3px;
+ background: #F44336;
+ color: var(--chops-white);
+ font-weight: bold;
+ font-size: var(--chops-main-font-size);
+ margin-right: 4px;
+ }
+ .byline {
+ display: block;
+ font-size: var(--chops-main-font-size);
+ width: 100%;
+ line-height: 140%;
+ color: var(--chops-primary-font-color);
+ }
+ .role-label {
+ background-color: var(--chops-gray-600);
+ border-radius: 3px;
+ color: var(--chops-white);
+ display: inline-block;
+ padding: 2px 4px;
+ font-size: 75%;
+ font-weight: bold;
+ line-height: 14px;
+ vertical-align: text-bottom;
+ margin-left: 16px;
+ }
+ .main-text-outer {
+ flex-basis: 100%;
+ display: flex;
+ justify-content: flex-start;
+ flex-direction: row;
+ align-items: center;
+ }
+ .main-text {
+ flex-basis: 100%;
+ }
+ @media (max-width: 840px) {
+ :host {
+ flex-wrap: wrap;
+ justify-content: center;
+ }
+ .main-text {
+ width: 100%;
+ margin-bottom: 0.5em;
+ }
+ }
+ `,
+ ];
+ }
+
+ /** @override */
+ render() {
+ const reporterIsMember = userIsMember(
+ this.issue.reporterRef, this.issue.projectName, this.usersProjects);
+ const markdownEnabled = AVAILABLE_MD_PROJECTS.has(this.projectName);
+ const markdownDefaultOn = DEFAULT_MD_PROJECTS.has(this.projectName);
+ return html`
+ <div class="main-text-outer">
+ <div class="main-text">
+ <h1>
+ ${this.issue.isSpam ? html`
+ <span class="spam-notice">Spam</span>
+ `: ''}
+ Issue ${this.issue.localId}: ${this.issue.summary}
+ </h1>
+ <small class="byline">
+ Reported by
+ <mr-user-link
+ .userRef=${this.issue.reporterRef}
+ aria-label="issue reporter"
+ ></mr-user-link>
+ on <chops-timestamp .timestamp=${this.issue.openedTimestamp}></chops-timestamp>
+ ${reporterIsMember ? html`
+ <span class="role-label">Project Member</span>` : ''}
+ </small>
+ </div>
+ </div>
+ <div class="issue-actions">
+ <div>
+ <mr-crbug-link .issue=${this.issue}></mr-crbug-link>
+ <mr-pref-toggle
+ .userDisplayName=${this.userDisplayName}
+ label="Code"
+ title="Code font"
+ prefName="code_font"
+ ></mr-pref-toggle>
+ ${markdownEnabled ? html`
+ <mr-pref-toggle
+ .userDisplayName=${this.userDisplayName}
+ initialValue=${markdownDefaultOn}
+ label="Markdown"
+ title="Render in markdown"
+ prefName="render_markdown"
+ ></mr-pref-toggle> ` : ''}
+ </div>
+ ${this._issueOptions.length ? html`
+ <mr-dropdown
+ .items=${this._issueOptions}
+ icon="more_vert"
+ label="Issue options"
+ ></mr-dropdown>
+ ` : ''}
+ <mr-flipper></mr-flipper>
+ </div>
+ `;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ userDisplayName: {type: String},
+ issue: {type: Object},
+ issuePermissions: {type: Object},
+ isRestricted: {type: Boolean},
+ projectTemplates: {type: Array},
+ projectName: {type: String},
+ usersProjects: {type: Object},
+ _action: {type: String},
+ _targetProjectError: {type: String},
+ };
+ }
+
+ /** @override */
+ constructor() {
+ super();
+ this.issuePermissions = [];
+ this.projectTemplates = [];
+ this.projectName = '';
+ this.issue = {};
+ this.usersProjects = new Map();
+ this.isRestricted = false;
+ }
+
+ /** @override */
+ stateChanged(state) {
+ this.issue = issueV0.viewedIssue(state);
+ this.issuePermissions = issueV0.permissions(state);
+ this.projectTemplates = projectV0.viewedTemplates(state);
+ this.projectName = projectV0.viewedProjectName(state);
+ this.usersProjects = userV0.projectsPerUser(state);
+
+ const restrictions = issueV0.restrictions(state);
+ this.isRestricted = restrictions && Object.keys(restrictions).length;
+ }
+
+ /**
+ * @return {Array<MenuItem>} Actions the user can take on the issue.
+ * @private
+ */
+ get _issueOptions() {
+ // We create two edit Arrays for the top and bottom half of the menu,
+ // to be separated by a separator in the UI.
+ const editOptions = [];
+ const riskyOptions = [];
+ const isSpam = this.issue.isSpam;
+ const isRestricted = this.isRestricted;
+
+ const permissions = this.issuePermissions;
+ const templates = this.projectTemplates;
+
+
+ if (permissions.includes(ISSUE_EDIT_PERMISSION)) {
+ editOptions.push({
+ text: 'Edit issue description',
+ handler: this._openEditDescription.bind(this),
+ });
+ if (templates.length) {
+ riskyOptions.push({
+ text: 'Convert issue template',
+ handler: this._openConvertIssue.bind(this),
+ });
+ }
+ }
+
+ if (permissions.includes(ISSUE_DELETE_PERMISSION)) {
+ riskyOptions.push({
+ text: 'Delete issue',
+ handler: this._deleteIssue.bind(this),
+ });
+ if (!isRestricted) {
+ editOptions.push({
+ text: 'Move issue',
+ handler: this._openMoveCopyIssue.bind(this, 'Move'),
+ });
+ editOptions.push({
+ text: 'Copy issue',
+ handler: this._openMoveCopyIssue.bind(this, 'Copy'),
+ });
+ }
+ }
+
+ if (permissions.includes(ISSUE_FLAGSPAM_PERMISSION)) {
+ const text = (isSpam ? 'Un-flag' : 'Flag') + ' issue as spam';
+ riskyOptions.push({
+ text,
+ handler: this._markIssue.bind(this),
+ });
+ }
+
+ if (editOptions.length && riskyOptions.length) {
+ editOptions.push({separator: true});
+ }
+ return editOptions.concat(riskyOptions);
+ }
+
+ /**
+ * Marks an issue as either spam or not spam based on whether the issue
+ * was spam.
+ */
+ _markIssue() {
+ prpcClient.call('monorail.Issues', 'FlagIssues', {
+ issueRefs: [{
+ projectName: this.issue.projectName,
+ localId: this.issue.localId,
+ }],
+ flag: !this.issue.isSpam,
+ }).then(() => {
+ store.dispatch(issueV0.fetch({
+ projectName: this.issue.projectName,
+ localId: this.issue.localId,
+ }));
+ });
+ }
+
+ /**
+ * Deletes an issue.
+ */
+ _deleteIssue() {
+ const ok = confirm(DELETE_ISSUE_CONFIRMATION_NOTICE);
+ if (ok) {
+ const issueRef = issueToIssueRef(this.issue);
+ // TODO(crbug.com/monorail/7374): Delete for the v0 -> v3 migration.
+ prpcClient.call('monorail.Issues', 'DeleteIssue', {
+ issueRef,
+ delete: true,
+ }).then(() => {
+ store.dispatch(issueV0.fetch(issueRef));
+ });
+ }
+ }
+
+ /**
+ * Launches the dialog to edit an issue's description.
+ * @fires CustomEvent#open-dialog
+ * @private
+ */
+ _openEditDescription() {
+ this.dispatchEvent(new CustomEvent('open-dialog', {
+ bubbles: true,
+ composed: true,
+ detail: {
+ dialogId: 'edit-description',
+ fieldName: '',
+ },
+ }));
+ }
+
+ /**
+ * Opens dialog to either move or copy an issue.
+ * @param {"move"|"copy"} action
+ * @fires CustomEvent#open-dialog
+ * @private
+ */
+ _openMoveCopyIssue(action) {
+ this.dispatchEvent(new CustomEvent('open-dialog', {
+ bubbles: true,
+ composed: true,
+ detail: {
+ dialogId: 'move-copy-issue',
+ action,
+ },
+ }));
+ }
+
+ /**
+ * Opens dialog for converting an issue.
+ * @fires CustomEvent#open-dialog
+ * @private
+ */
+ _openConvertIssue() {
+ this.dispatchEvent(new CustomEvent('open-dialog', {
+ bubbles: true,
+ composed: true,
+ detail: {
+ dialogId: 'convert-issue',
+ },
+ }));
+ }
+}
+
+customElements.define('mr-issue-header', MrIssueHeader);
diff --git a/static_src/elements/issue-detail/mr-issue-page/mr-issue-header.test.js b/static_src/elements/issue-detail/mr-issue-page/mr-issue-header.test.js
new file mode 100644
index 0000000..25ab0e7
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-issue-page/mr-issue-header.test.js
@@ -0,0 +1,167 @@
+// 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 {MrIssueHeader} from './mr-issue-header.js';
+import {store, resetState} from 'reducers/base.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import {ISSUE_EDIT_PERMISSION, ISSUE_DELETE_PERMISSION,
+ ISSUE_FLAGSPAM_PERMISSION} from 'shared/consts/permissions.js';
+
+let element;
+
+describe('mr-issue-header', () => {
+ beforeEach(() => {
+ store.dispatch(resetState());
+ element = document.createElement('mr-issue-header');
+ document.body.appendChild(element);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrIssueHeader);
+ });
+
+ it('updating issue id changes header', () => {
+ store.dispatch({type: issueV0.VIEW_ISSUE,
+ issueRef: {localId: 1, projectName: 'test'}});
+ store.dispatch({type: issueV0.FETCH_SUCCESS,
+ issue: {localId: 1, projectName: 'test', summary: 'test'}});
+
+ assert.deepEqual(element.issue, {localId: 1, projectName: 'test',
+ summary: 'test'});
+ });
+
+ it('_issueOptions toggles spam', () => {
+ element.issuePermissions = [ISSUE_FLAGSPAM_PERMISSION];
+ element.issue = {isSpam: false};
+ assert.isDefined(findOptionWithText(element._issueOptions,
+ 'Flag issue as spam'));
+ assert.isUndefined(findOptionWithText(element._issueOptions,
+ 'Un-flag issue as spam'));
+
+ element.issue = {isSpam: true};
+
+ assert.isUndefined(findOptionWithText(element._issueOptions,
+ 'Flag issue as spam'));
+ assert.isDefined(findOptionWithText(element._issueOptions,
+ 'Un-flag issue as spam'));
+
+ element.issuePermissions = [];
+
+ assert.isUndefined(findOptionWithText(element._issueOptions,
+ 'Flag issue as spam'));
+ assert.isUndefined(findOptionWithText(element._issueOptions,
+ 'Un-flag issue as spam'));
+
+ element.issue = {isSpam: false};
+ assert.isUndefined(findOptionWithText(element._issueOptions,
+ 'Flag issue as spam'));
+ assert.isUndefined(findOptionWithText(element._issueOptions,
+ 'Un-flag issue as spam'));
+ });
+
+ it('_issueOptions toggles convert issue', () => {
+ element.issuePermissions = [];
+ element.projectTemplates = [];
+
+ assert.isUndefined(findOptionWithText(element._issueOptions,
+ 'Convert issue template'));
+
+ element.projectTemplates = [{templateName: 'test'}];
+
+ assert.isUndefined(findOptionWithText(element._issueOptions,
+ 'Convert issue template'));
+
+ element.issuePermissions = [ISSUE_EDIT_PERMISSION];
+ element.projectTemplates = [];
+ assert.isUndefined(findOptionWithText(element._issueOptions,
+ 'Convert issue template'));
+
+ element.projectTemplates = [{templateName: 'test'}];
+ assert.isDefined(findOptionWithText(element._issueOptions,
+ 'Convert issue template'));
+ });
+
+ it('_issueOptions toggles delete', () => {
+ element.issuePermissions = [ISSUE_DELETE_PERMISSION];
+ assert.isDefined(findOptionWithText(element._issueOptions,
+ 'Delete issue'));
+
+ element.issuePermissions = [];
+
+ assert.isUndefined(findOptionWithText(element._issueOptions,
+ 'Delete issue'));
+ });
+
+ it('_issueOptions toggles move and copy', () => {
+ element.issuePermissions = [ISSUE_DELETE_PERMISSION];
+ assert.isDefined(findOptionWithText(element._issueOptions,
+ 'Move issue'));
+ assert.isDefined(findOptionWithText(element._issueOptions,
+ 'Copy issue'));
+
+ element.isRestricted = true;
+ assert.isUndefined(findOptionWithText(element._issueOptions,
+ 'Move issue'));
+ assert.isUndefined(findOptionWithText(element._issueOptions,
+ 'Copy issue'));
+
+ element.issuePermissions = [];
+
+ assert.isUndefined(findOptionWithText(element._issueOptions,
+ 'Move issue'));
+ assert.isUndefined(findOptionWithText(element._issueOptions,
+ 'Copy issue'));
+ });
+
+ it('_issueOptions toggles edit description', () => {
+ element.issuePermissions = [ISSUE_EDIT_PERMISSION];
+ assert.isDefined(findOptionWithText(element._issueOptions,
+ 'Edit issue description'));
+
+ element.issuePermissions = [];
+
+ assert.isUndefined(findOptionWithText(element._issueOptions,
+ 'Edit issue description'));
+ });
+
+ it('markdown toggle renders on enabled projects', async () => {
+ element.projectName = 'monkeyrail';
+
+ await element.updateComplete;
+
+ // This looks for how many mr-pref-toggle buttons there are,
+ // if there are two then this project also renders on markdown.
+ const chopsToggles = element.shadowRoot.querySelectorAll('mr-pref-toggle');
+ assert.equal(chopsToggles.length, 2);
+
+ });
+
+ it('markdown toggle does not render on disabled projects', async () => {
+ element.projectName = 'moneyrail';
+
+ await element.updateComplete;
+
+ const chopsToggles = element.shadowRoot.querySelectorAll('mr-pref-toggle');
+ assert.equal(chopsToggles.length, 1);
+ });
+
+ it('markdown toggle is on by default on enabled projects', async () => {
+ element.projectName = 'monkeyrail';
+
+ await element.updateComplete;
+
+ const chopsToggles = element.shadowRoot.querySelectorAll('mr-pref-toggle');
+ const markdownButton = chopsToggles[1];
+ assert.equal("true", markdownButton.getAttribute('initialvalue'));
+ });
+});
+
+function findOptionWithText(issueOptions, text) {
+ return issueOptions.find((option) => option.text === text);
+}
diff --git a/static_src/elements/issue-detail/mr-issue-page/mr-issue-page.js b/static_src/elements/issue-detail/mr-issue-page/mr-issue-page.js
new file mode 100644
index 0000000..a93822b
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-issue-page/mr-issue-page.js
@@ -0,0 +1,393 @@
+// 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 page from 'page';
+import {LitElement, html} from 'lit-element';
+
+import 'elements/chops/chops-button/chops-button.js';
+import './mr-issue-header.js';
+import './mr-restriction-indicator';
+import '../mr-issue-details/mr-issue-details.js';
+import '../metadata/mr-metadata/mr-issue-metadata.js';
+import '../mr-launch-overview/mr-launch-overview.js';
+import {store, connectStore} from 'reducers/base.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import * as projectV0 from 'reducers/projectV0.js';
+import * as userV0 from 'reducers/userV0.js';
+import * as sitewide from 'reducers/sitewide.js';
+
+import {ISSUE_DELETE_PERMISSION} from 'shared/consts/permissions.js';
+
+// eslint-disable-next-line max-len
+import 'elements/framework/dialogs/mr-issue-hotlists-action/mr-update-issue-hotlists-dialog.js';
+import '../dialogs/mr-edit-description/mr-edit-description.js';
+import '../dialogs/mr-move-copy-issue/mr-move-copy-issue.js';
+import '../dialogs/mr-convert-issue/mr-convert-issue.js';
+import '../dialogs/mr-related-issues/mr-related-issues.js';
+import '../../help/mr-click-throughs/mr-click-throughs.js';
+import {prpcClient} from 'prpc-client-instance.js';
+
+const APPROVAL_COMMENT_COUNT = 5;
+const DETAIL_COMMENT_COUNT = 100;
+
+/**
+ * `<mr-issue-page>`
+ *
+ * The main entry point for a Monorail issue detail page.
+ *
+ */
+export class MrIssuePage extends connectStore(LitElement) {
+ /** @override */
+ render() {
+ return html`
+ <style>
+ mr-issue-page {
+ --mr-issue-page-horizontal-padding: 12px;
+ --mr-toggled-font-family: inherit;
+ --monorail-metadata-toggled-bg: var(--monorail-metadata-open-bg);
+ }
+ mr-issue-page[issueClosed] {
+ --monorail-metadata-toggled-bg: var(--monorail-metadata-closed-bg);
+ }
+ mr-issue-page[codeFont] {
+ --mr-toggled-font-family: Monospace;
+ }
+ .container-issue {
+ width: 100%;
+ flex-direction: column;
+ align-items: stretch;
+ justify-content: flex-start;
+ z-index: 200;
+ }
+ .container-issue-content {
+ padding: 0;
+ flex-grow: 1;
+ display: flex;
+ align-items: stretch;
+ justify-content: space-between;
+ flex-direction: row;
+ flex-wrap: nowrap;
+ box-sizing: border-box;
+ padding-top: 0.5em;
+ }
+ .container-outside {
+ box-sizing: border-box;
+ width: 100%;
+ max-width: 100%;
+ margin: auto;
+ padding: 0;
+ display: flex;
+ align-items: stretch;
+ justify-content: space-between;
+ flex-direction: row;
+ flex-wrap: no-wrap;
+ }
+ .container-no-issue {
+ padding: 0.5em 16px;
+ font-size: var(--chops-large-font-size);
+ }
+ .metadata-container {
+ font-size: var(--chops-main-font-size);
+ background: var(--monorail-metadata-toggled-bg);
+ border-right: var(--chops-normal-border);
+ border-bottom: var(--chops-normal-border);
+ width: 24em;
+ min-width: 256px;
+ flex-grow: 0;
+ flex-shrink: 0;
+ box-sizing: border-box;
+ z-index: 100;
+ }
+ .issue-header-container {
+ z-index: 10;
+ position: sticky;
+ top: var(--monorail-header-height);
+ margin-bottom: 0.25em;
+ width: 100%;
+ }
+ mr-issue-details {
+ min-width: 50%;
+ max-width: 1000px;
+ flex-grow: 1;
+ box-sizing: border-box;
+ min-height: 100%;
+ padding-left: var(--mr-issue-page-horizontal-padding);
+ padding-right: var(--mr-issue-page-horizontal-padding);
+ }
+ mr-issue-metadata {
+ position: sticky;
+ overflow-y: auto;
+ top: var(--monorail-header-height);
+ height: calc(100vh - var(--monorail-header-height));
+ }
+ mr-launch-overview {
+ border-left: var(--chops-normal-border);
+ padding-left: var(--mr-issue-page-horizontal-padding);
+ padding-right: var(--mr-issue-page-horizontal-padding);
+ flex-grow: 0;
+ flex-shrink: 0;
+ width: 50%;
+ box-sizing: border-box;
+ min-height: 100%;
+ }
+ @media (max-width: 1126px) {
+ .container-issue-content {
+ flex-direction: column;
+ padding: 0 var(--mr-issue-page-horizontal-padding);
+ }
+ mr-issue-details, mr-launch-overview {
+ width: 100%;
+ padding: 0;
+ border: 0;
+ }
+ }
+ @media (max-width: 840px) {
+ .container-outside {
+ flex-direction: column;
+ }
+ .metadata-container {
+ width: 100%;
+ height: auto;
+ border: 0;
+ border-bottom: var(--chops-normal-border);
+ }
+ mr-issue-metadata {
+ min-width: auto;
+ max-width: auto;
+ width: 100%;
+ padding: 0;
+ min-height: 0;
+ border: 0;
+ }
+ mr-issue-metadata, .issue-header-container {
+ position: static;
+ }
+ }
+ </style>
+ <mr-click-throughs
+ .userDisplayName=${this.userDisplayName}></mr-click-throughs>
+ ${this._renderIssue()}
+ `;
+ }
+
+ /**
+ * Render the issue.
+ * @return {TemplateResult}
+ */
+ _renderIssue() {
+ const issueIsEmpty = !this.issue || !this.issue.localId;
+ const movedToRef = this.issue.movedToRef;
+ const commentShown = this.issue.approvalValues ? APPROVAL_COMMENT_COUNT :
+ DETAIL_COMMENT_COUNT;
+
+ if (this.fetchIssueError) {
+ return html`
+ <div class="container-no-issue" id="fetch-error">
+ ${this.fetchIssueError.description}
+ </div>
+ `;
+ }
+
+ if (this.fetchingIssue && issueIsEmpty) {
+ return html`
+ <div class="container-no-issue" id="loading">
+ Loading...
+ </div>
+ `;
+ }
+
+ if (this.issue.isDeleted) {
+ return html`
+ <div class="container-no-issue" id="deleted">
+ <p>Issue ${this.issueRef.localId} has been deleted.</p>
+ ${this.issuePermissions.includes(ISSUE_DELETE_PERMISSION) ? html`
+ <chops-button
+ @click=${this._undeleteIssue}
+ class="undelete emphasized"
+ >
+ Undelete Issue
+ </chops-button>
+ `: ''}
+ </div>
+ `;
+ }
+
+ if (movedToRef && movedToRef.localId) {
+ return html`
+ <div class="container-no-issue" id="moved">
+ <h2>Issue has moved.</h2>
+ <p>
+ This issue was moved to ${movedToRef.projectName}.
+ <a
+ class="new-location"
+ href="/p/${movedToRef.projectName}/issues/detail?id=${movedToRef.localId}"
+ >
+ Go to issue</a>.
+ </p>
+ </div>
+ `;
+ }
+
+ if (!issueIsEmpty) {
+ return html`
+ <div
+ class="container-outside"
+ @open-dialog=${this._openDialog}
+ id="issue"
+ >
+ <aside class="metadata-container">
+ <mr-issue-metadata></mr-issue-metadata>
+ </aside>
+ <div class="container-issue">
+ <div class="issue-header-container">
+ <mr-issue-header
+ .userDisplayName=${this.userDisplayName}
+ ></mr-issue-header>
+ <mr-restriction-indicator></mr-restriction-indicator>
+ </div>
+ <div class="container-issue-content">
+ <mr-issue-details
+ class="main-item"
+ .commentsShownCount=${commentShown}
+ ></mr-issue-details>
+ <mr-launch-overview class="main-item"></mr-launch-overview>
+ </div>
+ </div>
+ </div>
+ <mr-edit-description id="edit-description"></mr-edit-description>
+ <mr-move-copy-issue id="move-copy-issue"></mr-move-copy-issue>
+ <mr-convert-issue id="convert-issue"></mr-convert-issue>
+ <mr-related-issues id="reorder-related-issues"></mr-related-issues>
+ <mr-update-issue-hotlists-dialog
+ id="update-issue-hotlists"
+ .issueRefs=${[this.issueRef]}
+ .issueHotlists=${this.issueHotlists}
+ ></mr-update-issue-hotlists-dialog>
+ `;
+ }
+
+ return '';
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ userDisplayName: {type: String},
+ // Redux state.
+ fetchIssueError: {type: String},
+ fetchingIssue: {type: Boolean},
+ fetchingProjectConfig: {type: Boolean},
+ issue: {type: Object},
+ issueHotlists: {type: Array},
+ issueClosed: {
+ type: Boolean,
+ reflect: true,
+ },
+ codeFont: {
+ type: Boolean,
+ reflect: true,
+ },
+ issuePermissions: {type: Object},
+ issueRef: {type: Object},
+ prefs: {type: Object},
+ loginUrl: {type: String},
+ };
+ }
+
+ /** @override */
+ constructor() {
+ super();
+ this.issue = {};
+ this.issueRef = {};
+ this.issuePermissions = [];
+ this.prefs = {};
+ this.codeFont = false;
+ }
+
+ /** @override */
+ createRenderRoot() {
+ return this;
+ }
+
+ /** @override */
+ stateChanged(state) {
+ this.projectName = projectV0.viewedProjectName(state);
+ this.issue = issueV0.viewedIssue(state);
+ this.issueHotlists = issueV0.hotlists(state);
+ this.issueRef = issueV0.viewedIssueRef(state);
+ this.fetchIssueError = issueV0.requests(state).fetch.error;
+ this.fetchingIssue = issueV0.requests(state).fetch.requesting;
+ this.fetchingProjectConfig = projectV0.fetchingConfig(state);
+ this.issueClosed = !issueV0.isOpen(state);
+ this.issuePermissions = issueV0.permissions(state);
+ this.prefs = userV0.prefs(state);
+ }
+
+ /** @override */
+ update(changedProperties) {
+ if (changedProperties.has('prefs')) {
+ this.codeFont = !!this.prefs.get('code_font');
+ }
+ if (changedProperties.has('fetchIssueError') &&
+ !this.userDisplayName && this.fetchIssueError &&
+ this.fetchIssueError.codeName === 'PERMISSION_DENIED') {
+ page(this.loginUrl);
+ }
+ super.update(changedProperties);
+ }
+
+ /** @override */
+ updated(changedProperties) {
+ if (changedProperties.has('issueRef') || changedProperties.has('issue')) {
+ const title = this._pageTitle(this.issueRef, this.issue);
+ store.dispatch(sitewide.setPageTitle(title));
+ }
+ }
+
+ /**
+ * Generates a title for the currently viewed page based on issue data.
+ * @param {IssueRef} issueRef
+ * @param {Issue} issue
+ * @return {string}
+ */
+ _pageTitle(issueRef, issue) {
+ const titlePieces = [];
+ if (issueRef.localId) {
+ titlePieces.push(issueRef.localId);
+ }
+ if (!issue || !issue.localId) {
+ // Issue is not loaded.
+ titlePieces.push('Loading issue...');
+ } else {
+ if (issue.isDeleted) {
+ titlePieces.push('Deleted issue');
+ } else if (issue.summary) {
+ titlePieces.push(issue.summary);
+ }
+ }
+ return titlePieces.join(' - ');
+ }
+
+ /**
+ * Opens a dialog with a specific ID based on an Event.
+ * @param {CustomEvent} e
+ */
+ _openDialog(e) {
+ this.querySelector('#' + e.detail.dialogId).open(e);
+ }
+
+ /**
+ * Undeletes the current issue.
+ */
+ _undeleteIssue() {
+ prpcClient.call('monorail.Issues', 'DeleteIssue', {
+ issueRef: this.issueRef,
+ delete: false,
+ }).then(() => {
+ store.dispatch(issueV0.fetchIssuePageData(this.issueRef));
+ });
+ }
+}
+
+customElements.define('mr-issue-page', MrIssuePage);
diff --git a/static_src/elements/issue-detail/mr-issue-page/mr-issue-page.test.js b/static_src/elements/issue-detail/mr-issue-page/mr-issue-page.test.js
new file mode 100644
index 0000000..31edd4c
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-issue-page/mr-issue-page.test.js
@@ -0,0 +1,272 @@
+// 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 sinon from 'sinon';
+import {MrIssuePage} from './mr-issue-page.js';
+import {store, resetState} from 'reducers/base.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import {prpcClient} from 'prpc-client-instance.js';
+
+let element;
+let loadingElement;
+let fetchErrorElement;
+let deletedElement;
+let movedElement;
+let issueElement;
+
+function populateElementReferences() {
+ loadingElement = element.querySelector('#loading');
+ fetchErrorElement = element.querySelector('#fetch-error');
+ deletedElement = element.querySelector('#deleted');
+ movedElement = element.querySelector('#moved');
+ issueElement = element.querySelector('#issue');
+}
+
+describe('mr-issue-page', () => {
+ beforeEach(() => {
+ store.dispatch(resetState());
+ element = document.createElement('mr-issue-page');
+ document.body.appendChild(element);
+ sinon.stub(prpcClient, 'call');
+ // TODO(ehmaldonado): Remove once the old autocomplete code is deprecated.
+ window.TKR_populateAutocomplete = () => {};
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ prpcClient.call.restore();
+ // TODO(ehmaldonado): Remove once the old autocomplete code is deprecated.
+ window.TKR_populateAutocomplete = undefined;
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrIssuePage);
+ });
+
+ describe('_pageTitle', () => {
+ it('displays loading when no issue', () => {
+ assert.equal(element._pageTitle({}, {}), 'Loading issue...');
+ });
+
+ it('display issue ID when available', () => {
+ assert.equal(element._pageTitle({projectName: 'test', localId: 1}, {}),
+ '1 - Loading issue...');
+ });
+
+ it('display deleted issues', () => {
+ assert.equal(element._pageTitle({projectName: 'test', localId: 1},
+ {projectName: 'test', localId: 1, isDeleted: true},
+ ), '1 - Deleted issue');
+ });
+
+ it('displays loaded issue', () => {
+ assert.equal(element._pageTitle({projectName: 'test', localId: 2},
+ {projectName: 'test', localId: 2, summary: 'test'}), '2 - test');
+ });
+ });
+
+ it('issue not loaded yet', async () => {
+ // Prevent unrelated Redux changes from affecting this test.
+ // TODO(zhangtiff): Find a more canonical way to test components
+ // in and out of Redux.
+ sinon.stub(store, 'dispatch');
+
+ element.fetchingIssue = true;
+
+ await element.updateComplete;
+ populateElementReferences();
+
+ assert.isNotNull(loadingElement);
+ assert.isNull(fetchErrorElement);
+ assert.isNull(deletedElement);
+ assert.isNull(issueElement);
+
+ store.dispatch.restore();
+ });
+
+ it('no loading on future issue fetches', async () => {
+ element.issue = {localId: 222};
+ element.fetchingIssue = true;
+
+ await element.updateComplete;
+ populateElementReferences();
+
+ assert.isNull(loadingElement);
+ assert.isNull(fetchErrorElement);
+ assert.isNull(deletedElement);
+ assert.isNotNull(issueElement);
+ });
+
+ it('fetch error', async () => {
+ element.fetchingIssue = false;
+ element.fetchIssueError = 'error';
+
+ await element.updateComplete;
+ populateElementReferences();
+
+ assert.isNull(loadingElement);
+ assert.isNotNull(fetchErrorElement);
+ assert.isNull(deletedElement);
+ assert.isNull(issueElement);
+ });
+
+ it('deleted issue', async () => {
+ element.fetchingIssue = false;
+ element.issue = {isDeleted: true};
+
+ await element.updateComplete;
+ populateElementReferences();
+
+ assert.isNull(loadingElement);
+ assert.isNull(fetchErrorElement);
+ assert.isNotNull(deletedElement);
+ assert.isNull(issueElement);
+ });
+
+ it('normal issue', async () => {
+ element.fetchingIssue = false;
+ element.issue = {localId: 111};
+
+ await element.updateComplete;
+ populateElementReferences();
+
+ assert.isNull(loadingElement);
+ assert.isNull(fetchErrorElement);
+ assert.isNull(deletedElement);
+ assert.isNotNull(issueElement);
+ });
+
+ it('code font pref toggles attribute', async () => {
+ await element.updateComplete;
+
+ assert.isFalse(element.hasAttribute('codeFont'));
+
+ element.prefs = new Map([['code_font', true]]);
+ await element.updateComplete;
+
+ assert.isTrue(element.hasAttribute('codeFont'));
+
+ element.prefs = new Map([['code_font', false]]);
+ await element.updateComplete;
+
+ assert.isFalse(element.hasAttribute('codeFont'));
+ });
+
+ it('undeleting issue only shown if you have permissions', async () => {
+ sinon.stub(store, 'dispatch');
+
+ element.issue = {isDeleted: true};
+
+ await element.updateComplete;
+ populateElementReferences();
+
+ assert.isNotNull(deletedElement);
+
+ let button = element.querySelector('.undelete');
+ assert.isNull(button);
+
+ element.issuePermissions = ['deleteissue'];
+ await element.updateComplete;
+
+ button = element.querySelector('.undelete');
+ assert.isNotNull(button);
+
+ store.dispatch.restore();
+ });
+
+ it('undeleting issue updates page with issue', async () => {
+ const issueRef = {localId: 111, projectName: 'test'};
+ const deletedIssuePromise = Promise.resolve({
+ issue: {isDeleted: true},
+ });
+ const issuePromise = Promise.resolve({
+ issue: {localId: 111, projectName: 'test'},
+ });
+ const deletePromise = Promise.resolve({});
+
+ sinon.spy(element, '_undeleteIssue');
+
+ prpcClient.call.withArgs('monorail.Issues', 'GetIssue', {issueRef})
+ .onFirstCall().returns(deletedIssuePromise)
+ .onSecondCall().returns(issuePromise);
+ prpcClient.call.withArgs('monorail.Issues', 'DeleteIssue',
+ {delete: false, issueRef}).returns(deletePromise);
+
+ store.dispatch(issueV0.viewIssue(issueRef));
+ store.dispatch(issueV0.fetchIssuePageData(issueRef));
+
+ await deletedIssuePromise;
+ await element.updateComplete;
+
+ populateElementReferences();
+
+ assert.deepEqual(element.issue,
+ {isDeleted: true, localId: 111, projectName: 'test'});
+ assert.isNull(issueElement);
+ assert.isNotNull(deletedElement);
+
+ // Make undelete button visible. This must be after deletedIssuePromise
+ // resolves since issuePermissions are cleared by Redux after that promise.
+ element.issuePermissions = ['deleteissue'];
+ await element.updateComplete;
+
+ const button = element.querySelector('.undelete');
+ button.click();
+
+ sinon.assert.calledWith(prpcClient.call, 'monorail.Issues', 'GetIssue',
+ {issueRef});
+ sinon.assert.calledWith(prpcClient.call, 'monorail.Issues', 'DeleteIssue',
+ {delete: false, issueRef});
+
+ await deletePromise;
+ await issuePromise;
+ await element.updateComplete;
+
+ assert.isTrue(element._undeleteIssue.calledOnce);
+
+ assert.deepEqual(element.issue, {localId: 111, projectName: 'test'});
+
+ await element.updateComplete;
+
+ populateElementReferences();
+ assert.isNotNull(issueElement);
+
+ element._undeleteIssue.restore();
+ });
+
+ it('issue has moved', async () => {
+ element.fetchingIssue = false;
+ element.issue = {movedToRef: {projectName: 'hello', localId: 10}};
+
+ await element.updateComplete;
+ populateElementReferences();
+
+ assert.isNull(issueElement);
+ assert.isNull(deletedElement);
+ assert.isNotNull(movedElement);
+
+ const link = movedElement.querySelector('.new-location');
+ assert.equal(link.getAttribute('href'), '/p/hello/issues/detail?id=10');
+ });
+
+ it('moving to a restricted issue', async () => {
+ element.fetchingIssue = false;
+ element.issue = {localId: 111};
+
+ await element.updateComplete;
+
+ element.issue = {localId: 222};
+ element.fetchIssueError = 'error';
+
+ await element.updateComplete;
+ populateElementReferences();
+
+ assert.isNull(loadingElement);
+ assert.isNotNull(fetchErrorElement);
+ assert.isNull(deletedElement);
+ assert.isNull(movedElement);
+ assert.isNull(issueElement);
+ });
+});
diff --git a/static_src/elements/issue-detail/mr-issue-page/mr-restriction-indicator.js b/static_src/elements/issue-detail/mr-issue-page/mr-restriction-indicator.js
new file mode 100644
index 0000000..af558a4
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-issue-page/mr-restriction-indicator.js
@@ -0,0 +1,178 @@
+// 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 {connectStore} from 'reducers/base.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import * as userV0 from 'reducers/userV0.js';
+import {arrayToEnglish} from 'shared/helpers.js';
+
+
+/**
+ * `<mr-restriction-indicator>`
+ *
+ * Display for showing whether an issue is restricted.
+ *
+ */
+export class MrRestrictionIndicator extends connectStore(LitElement) {
+ /** @override */
+ static get styles() {
+ return css`
+ :host {
+ width: 100%;
+ margin-top: 0;
+ background-color: var(--monorail-metadata-toggled-bg);
+ border-bottom: var(--chops-normal-border);
+ font-size: var(--chops-main-font-size);
+ padding: 0.25em 8px;
+ box-sizing: border-box;
+ display: flex;
+ flex-direction: row;
+ justify-content: flex-start;
+ align-items: center;
+ }
+ :host([showWarning]) {
+ background-color: var(--chops-red-700);
+ color: var(--chops-white);
+ font-weight: bold;
+ }
+ :host([showWarning]) i {
+ color: var(--chops-white);
+ }
+ :host([hidden]) {
+ display: none;
+ }
+ i.material-icons {
+ color: var(--chops-primary-icon-color);
+ font-size: var(--chops-icon-font-size);
+ }
+ .lock-icon {
+ margin-right: 4px;
+ }
+ i.warning-icon {
+ margin-right: 4px;
+ }
+ i[hidden] {
+ display: none;
+ }
+ `;
+ }
+
+ /** @override */
+ render() {
+ return html`
+ <link href="https://fonts.googleapis.com/icon?family=Material+Icons"
+ rel="stylesheet">
+ <i
+ class="lock-icon material-icons"
+ icon="lock"
+ ?hidden=${!this._restrictionText}
+ title=${this._restrictionText}
+ >
+ lock
+ </i>
+ <i
+ class="warning-icon material-icons"
+ icon="warning"
+ ?hidden=${!this.showWarning}
+ title=${this._warningText}
+ >
+ warning
+ </i>
+ ${this._combinedText}
+ `;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ restrictions: Object,
+ prefs: Object,
+ hidden: {
+ type: Boolean,
+ reflect: true,
+ },
+ showWarning: {
+ type: Boolean,
+ reflect: true,
+ },
+ };
+ }
+
+ /** @override */
+ constructor() {
+ super();
+
+ this.hidden = true;
+ this.showWarning = false;
+ this.prefs = {};
+ }
+
+ /** @override */
+ stateChanged(state) {
+ this.restrictions = issueV0.restrictions(state);
+ this.prefs = userV0.prefs(state);
+ }
+
+ /** @override */
+ update(changedProperties) {
+ if (changedProperties.has('prefs') ||
+ changedProperties.has('restrictions')) {
+ this.hidden = !this._combinedText;
+
+ this.showWarning = !!this._warningText;
+ }
+
+ super.update(changedProperties);
+ }
+
+ /**
+ * Checks if the user should see a corp mode warning about an issue being
+ * public.
+ * @return {string}
+ */
+ get _warningText() {
+ const {restrictions, prefs} = this;
+ if (!prefs) return '';
+ if (!restrictions) return '';
+ if ('view' in restrictions && restrictions['view'].length) return '';
+ if (prefs.get('public_issue_notice')) {
+ return 'Public issue: Please do not post confidential information.';
+ }
+ return '';
+ }
+
+ /**
+ * Gets either corp mode or restricted issue text depending on which
+ * is relevant to the issue.
+ * @return {string}
+ */
+ get _combinedText() {
+ if (this._warningText) return this._warningText;
+ return this._restrictionText;
+ }
+
+ /**
+ * Computes the text to show users on a restricted issue.
+ * @return {string}
+ */
+ get _restrictionText() {
+ const {restrictions} = this;
+ if (!restrictions) return;
+ if ('view' in restrictions && restrictions['view'].length) {
+ return `Only users with ${arrayToEnglish(restrictions['view'])
+ } permission or issue reporter may view.`;
+ } else if ('edit' in restrictions && restrictions['edit'].length) {
+ return `Only users with ${arrayToEnglish(restrictions['edit'])
+ } permission may edit.`;
+ } else if ('comment' in restrictions && restrictions['comment'].length) {
+ return `Only users with ${arrayToEnglish(restrictions['comment'])
+ } permission or issue reporter may comment.`;
+ }
+ return '';
+ }
+}
+
+customElements.define('mr-restriction-indicator', MrRestrictionIndicator);
diff --git a/static_src/elements/issue-detail/mr-issue-page/mr-restriction-indicator.test.js b/static_src/elements/issue-detail/mr-issue-page/mr-restriction-indicator.test.js
new file mode 100644
index 0000000..3afbbcb
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-issue-page/mr-restriction-indicator.test.js
@@ -0,0 +1,130 @@
+// 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 {MrRestrictionIndicator} from './mr-restriction-indicator.js';
+
+let element;
+
+describe('mr-restriction-indicator', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-restriction-indicator');
+ document.body.appendChild(element);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrRestrictionIndicator);
+ });
+
+ it('shows element only when restricted or showWarning', async () => {
+ await element.updateComplete;
+
+ assert.isTrue(element.hasAttribute('hidden'));
+
+ element.restrictions = {view: ['Google']};
+ await element.updateComplete;
+
+ assert.isFalse(element.hasAttribute('hidden'));
+
+ element.restrictions = {};
+ await element.updateComplete;
+
+ assert.isTrue(element.hasAttribute('hidden'));
+
+ element.prefs = new Map([['public_issue_notice', true]]);
+ await element.updateComplete;
+
+ assert.isFalse(element.hasAttribute('hidden'));
+
+ element.prefs = new Map([['public_issue_notice', false]]);
+ await element.updateComplete;
+
+ assert.isTrue(element.hasAttribute('hidden'));
+
+ element.prefs = new Map([]);
+ await element.updateComplete;
+
+ assert.isTrue(element.hasAttribute('hidden'));
+
+ // It is possible to have an edit or comment restriction on
+ // a public issue when the user is opted in to public issue notices.
+ // In that case, the lock icon is shown, plus a warning icon and the
+ // public issue notice.
+ element.restrictions = new Map([['edit', ['Google']]]);
+ element.prefs = new Map([['public_issue_notice', true]]);
+ await element.updateComplete;
+
+ assert.isFalse(element.hasAttribute('hidden'));
+ });
+
+ it('displays view restrictions', async () => {
+ element.restrictions = {
+ view: ['Google', 'hello'],
+ edit: ['Editor', 'world'],
+ comment: ['commentor'],
+ };
+
+ await element.updateComplete;
+
+ const restrictString =
+ 'Only users with Google and hello permission or issue reporter may view.';
+ assert.equal(element._restrictionText, restrictString);
+
+ assert.include(element.shadowRoot.textContent, restrictString);
+ });
+
+ it('displays edit restrictions', async () => {
+ element.restrictions = {
+ view: [],
+ edit: ['Editor', 'world'],
+ comment: ['commentor'],
+ };
+
+ await element.updateComplete;
+
+ const restrictString =
+ 'Only users with Editor and world permission may edit.';
+ assert.equal(element._restrictionText, restrictString);
+
+ assert.include(element.shadowRoot.textContent, restrictString);
+ });
+
+ it('displays comment restrictions', async () => {
+ element.restrictions = {
+ view: [],
+ edit: [],
+ comment: ['commentor'],
+ };
+
+ await element.updateComplete;
+
+ const restrictString =
+ 'Only users with commentor permission or issue reporter may comment.';
+ assert.equal(element._restrictionText, restrictString);
+
+ assert.include(element.shadowRoot.textContent, restrictString);
+ });
+
+ it('displays public issue notice, if the user has that pref', async () => {
+ element.restrictions = {};
+
+ element.prefs = new Map();
+ assert.equal(element._restrictionText, '');
+ assert.include(element.shadowRoot.textContent, '');
+
+ element.prefs = new Map([['public_issue_notice', true]]);
+
+ await element.updateComplete;
+
+ const noticeString =
+ 'Public issue: Please do not post confidential information.';
+ assert.equal(element._warningText, noticeString);
+
+ assert.include(element.shadowRoot.textContent, noticeString);
+ });
+});